Skip to content
Open
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
10 changes: 10 additions & 0 deletions main/SS/Formula/Functions/Indirect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ private static ValueEval EvaluateIndirect(OperationEvaluationContext ec, String
}
catch (FormulaParseException)
{
// Malformed syntax, unknown table or column, or a row-relative specifier with
// no usable row index.
return ErrorEval.REF_INVALID;
}
catch (InvalidOperationException)
{
// Valid syntax that designates no single area, so there is nothing to return a
// reference to: [#Totals] on a table with no totals row, or [#This Row]/@ on a
// row outside the table. INDIRECT reports any reference it cannot resolve as
// #REF!, matching the FormulaParseException case above.
return ErrorEval.REF_INVALID;
}
return ec.GetArea3DEval(areaPtg);
Expand Down
169 changes: 168 additions & 1 deletion main/SS/Util/AreaReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ namespace NPOI.SS.Util
using System;
using System.Text;
using System.Collections;
using NPOI.SS.Formula;
using NPOI.SS.Formula.PTG;
using NPOI.SS.UserModel;

public class AreaReference
{
Expand All @@ -32,6 +35,12 @@ public class AreaReference
/** The Char (') used to quote sheet names when they contain special Chars */
private const char SPECIAL_NAME_DELIMITER = '\'';
private static readonly SpreadsheetVersion DEFAULT_SPREADSHEET_VERSION = SpreadsheetVersion.EXCEL97;
/**
* Sentinel row index meaning "the caller did not supply one". Structured references that
* resolve relative to a row ([#This Row], @) reject this with an explicit error rather than
* silently resolving against row 0.
*/
private const int ROW_INDEX_NOT_SPECIFIED = -1;

private readonly CellReference _firstCell;
private readonly CellReference _lastCell;
Expand All @@ -40,12 +49,94 @@ public class AreaReference

/**
* Create an area ref from a string representation. Sheet names containing special Chars should be
* delimited and escaped as per normal syntax rules for formulas.<br/>
* delimited and escaped as per normal syntax rules for formulas.<br/>
* The area reference must be contiguous (i.e. represent a single rectangle, not a Union of rectangles)
*/
public AreaReference(String reference, SpreadsheetVersion version)
: this(reference, version, null, ROW_INDEX_NOT_SPECIFIED)
{
}

/// <summary>
/// Creates an area reference from a string that may be either a standard cell reference
/// (e.g., <c>Sheet1!A1:B5</c>) or a structured table reference (e.g., <c>Table1[#Headers]</c>).
/// When <paramref name="workbook"/> is provided, structured table references are resolved
/// automatically against the workbook's table definitions.
/// </summary>
/// <remarks>
/// <para>
/// Structured reference resolution creates a <see cref="FormulaParser"/> instance internally.
/// This is fine for typical named-range resolution, but callers resolving thousands of
/// references in a tight loop may want to cache results.
/// </para>
/// <para>
/// <paramref name="version"/> is not consulted when resolving a structured reference — the
/// spreadsheet version is taken from <paramref name="workbook"/> in that case, and structured
/// references are only supported for <see cref="SpreadsheetVersion.EXCEL2007"/>. Callers that
/// know they are resolving a structured reference should prefer
/// <see cref="FromStructuredReference(String, IFormulaParsingWorkbook, int)"/>, which derives
/// the version from the workbook and rejects non-structured input outright.
/// </para>
/// </remarks>
/// <param name="reference">The reference string to parse.</param>
/// <param name="version">
/// The spreadsheet version for cell reference validation. Ignored when
/// <paramref name="reference"/> is resolved as a structured table reference (see remarks).
/// </param>
/// <param name="workbook">
/// The formula parsing workbook used to resolve structured table references.
/// Use <c>XSSFEvaluationWorkbook.Create(workbook)</c> to obtain this from an <c>XSSFWorkbook</c>.
/// Pass <c>null</c> if structured references are not expected.
/// </param>
/// <param name="rowIndex">
/// The 0-based row index of the cell containing the reference. Required only for
/// <c>[#This Row]</c> or <c>@</c> specifiers, which resolve relative to it. Leave at the
/// default (<c>-1</c>, meaning "not specified") otherwise: a row-relative reference then
/// fails with an explicit <see cref="FormulaParseException"/> rather than silently
/// resolving against an unintended row.
/// </param>
/// <exception cref="FormulaParseException">
/// The structured reference syntax is malformed, names a table that does not exist in the
/// workbook, names a column the table does not have, uses <c>[#This Row]</c> or <c>@</c>
/// without a <paramref name="rowIndex"/>, or targets a workbook that is not
/// <see cref="SpreadsheetVersion.EXCEL2007"/>.
/// </exception>
/// <exception cref="InvalidOperationException">
/// The structured reference is syntactically valid but does not designate a single area, so
/// there is no range to return. In a formula this would evaluate to an Excel error value
/// instead. The common causes are <c>[#Totals]</c> on a table with no totals row (<c>#REF!</c>)
/// and <c>[#This Row]</c> or <c>@</c> with a <paramref name="rowIndex"/> outside the table's
/// row range (<c>#VALUE!</c>).
/// </exception>
public AreaReference(String reference, SpreadsheetVersion version,
IFormulaParsingWorkbook workbook, int rowIndex = ROW_INDEX_NOT_SPECIFIED)
{
_version = (null != version) ? version : DEFAULT_SPREADSHEET_VERSION;

// When a workbook is provided, detect and resolve structured table references
// (e.g., "Table1[#Headers]", "Table1[[#Data],[Column1]]") that would otherwise
// fail in the A1-style parser below. Anything the detector rejects falls through to
// that parser, so non-structured input behaves exactly as it did before.
if (workbook != null && IsStructuredReference(reference))
Comment thread
ken-swyfft marked this conversation as resolved.
{
Area3DPxg area = FormulaParser.ParseStructuredReference(reference, workbook, rowIndex);
Comment thread
ken-swyfft marked this conversation as resolved.
// CellReference takes isAbsolute flags; AreaPtgBase stores isRelative — invert.
_firstCell = new CellReference(
area.SheetName,
area.FirstRow,
area.FirstColumn,
!area.IsFirstRowRelative,
Comment thread
ken-swyfft marked this conversation as resolved.
!area.IsFirstColRelative);
_lastCell = new CellReference(
area.SheetName,
area.LastRow,
area.LastColumn,
!area.IsLastRowRelative,
!area.IsLastColRelative);
_isSingleCell = area.FirstRow == area.LastRow && area.FirstColumn == area.LastColumn;
return;
}

if (!IsContiguous(reference))
{
throw new ArgumentException(
Expand Down Expand Up @@ -245,6 +336,82 @@ public AreaReference(CellReference topLeft, CellReference botRight)
_isSingleCell = false;
}

/// <summary>
/// Resolves an Excel structured table reference (e.g., <c>Table1[#Headers]</c>,
/// <c>Table1[[#Data],[Column1]]</c>) to a concrete cell range against the given workbook's
/// table definitions.
/// </summary>
/// <remarks>
/// This is the explicit counterpart to
/// <see cref="AreaReference(String, SpreadsheetVersion, IFormulaParsingWorkbook, int)"/>:
/// it derives the spreadsheet version from <paramref name="workbook"/> rather than taking a
/// version that would be ignored, and it rejects non-structured input instead of silently
/// falling back to A1-style parsing. Use the constructor when a reference may be either form
/// (e.g. resolving a named range whose <c>RefersToFormula</c> is not known in advance).
/// </remarks>
/// <param name="reference">The structured table reference to resolve.</param>
/// <param name="workbook">
/// The formula parsing workbook holding the table definitions. Obtain one from an
/// <c>XSSFWorkbook</c> via <c>XSSFEvaluationWorkbook.Create(workbook)</c>.
/// </param>
/// <param name="rowIndex">
/// The 0-based row index the reference appears on. Required only for <c>[#This Row]</c> or
/// <c>@</c> specifiers; leave at the default otherwise.
/// </param>
/// <returns>The resolved area.</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="reference"/> or <paramref name="workbook"/> is <c>null</c>.
/// </exception>
/// <exception cref="ArgumentException">
/// <paramref name="reference"/> is not a structured table reference.
/// </exception>
/// <exception cref="FormulaParseException">
/// As documented on <see cref="AreaReference(String, SpreadsheetVersion, IFormulaParsingWorkbook, int)"/>.
/// </exception>
/// <exception cref="InvalidOperationException">
/// As documented on <see cref="AreaReference(String, SpreadsheetVersion, IFormulaParsingWorkbook, int)"/>.
/// </exception>
public static AreaReference FromStructuredReference(String reference,
IFormulaParsingWorkbook workbook, int rowIndex = ROW_INDEX_NOT_SPECIFIED)
{
if (reference == null)
{
throw new ArgumentNullException(nameof(reference));
}
if (workbook == null)
{
throw new ArgumentNullException(nameof(workbook));
}
if (!IsStructuredReference(reference))
{
throw new ArgumentException(
"'" + reference + "' is not a structured table reference. Use the AreaReference " +
"constructor for A1-style references.", nameof(reference));
}

return new AreaReference(reference, workbook.GetSpreadsheetVersion(), workbook, rowIndex);
}

/// <summary>
/// Returns <c>true</c> if the given reference string uses Excel structured table reference
/// syntax (e.g., <c>Table1[#Headers]</c>, <c>Table1[[#Data],[Column1]]</c>).
/// </summary>
/// <param name="reference">The reference string to test.</param>
/// <returns><c>true</c> if the reference is a structured table reference; otherwise <c>false</c>.</returns>
public static bool IsStructuredReference(String reference)
{
if (reference == null)
{
return false;
}

// Require the regex to span the entire input — the underlying pattern is
// unanchored, so without this check a bracketed substring inside a larger
// value would false-positive.
var match = Table.IsStructuredReference.Match(reference);
return match.Success && match.Index == 0 && match.Length == reference.Length;
}

/**
* is the reference for a contiguous (i.e.
* Unbroken) area, or is it made up of
Expand Down
6 changes: 5 additions & 1 deletion ooxml/XSSF/UserModel/BaseXSSFEvaluationWorkbook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,11 @@ public ITable GetTable(String name)
{
if (name == null) return null;
String lname = name.ToLower(CultureInfo.CurrentCulture);
return GetTableCache()[lname];
// Deliberately a lookup-or-null rather than an indexer: this method is documented to
// return null for an unknown name, and callers depend on that to report the bad name
// themselves (FormulaParser raises FormulaParseException, which INDIRECT() turns into
// #REF!). An indexer throws KeyNotFoundException straight past them instead.
return GetTableCache().TryGetValue(lname, out XSSFTable table) ? table : null;
}

public UDFFinder GetUDFFinder()
Expand Down
5 changes: 4 additions & 1 deletion testcases/main/HSSF/UserModel/TestCellStyle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,10 @@ public async Task TestNPOI1469()
const int rows = 1_000;
const int dop = 2;

var time = DateTime.UtcNow.AddYears(-1);
// Truncate to whole seconds so that ToString("HH:mm:ss") (which truncates)
// and DataFormatter (which rounds the Excel OLE date double) agree.
var raw = DateTime.UtcNow.AddYears(-1);
var time = new DateTime(raw.Ticks - raw.Ticks % TimeSpan.TicksPerSecond, raw.Kind);

Console.WriteLine($"Start time: {time:yyyy/MM/dd} {time:HH:mm:ss}");

Expand Down
61 changes: 61 additions & 0 deletions testcases/ooxml/SS/Formula/TestStructuredReferences.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,67 @@ public void TestTableFormulas()
}
}

/**
* INDIRECT() with a structured reference to a table that does not exist must evaluate to
* #REF!, not fail evaluation outright. Indirect only handles FormulaParseException, so this
* depends on the workbook's table lookup returning null for an unknown name (as it
* documents) rather than throwing KeyNotFoundException past Indirect's handler.
*/
[Test]
public void TestIndirectWithUnknownTableName()
{
XSSFWorkbook wb = XSSFTestDataSamples.OpenSampleWorkbook("StructuredReferences.xlsx");
try
{
IFormulaEvaluator eval = new XSSFFormulaEvaluator(wb);
XSSFSheet formulaSheet = wb.GetSheet("Formulas") as XSSFSheet;

ICell cell = formulaSheet.CreateRow(10).CreateCell(0, CellType.Formula);
cell.CellFormula = (/*setter*/"INDIRECT(\"NoSuchTable[#Data]\")");

CellValue cv = eval.Evaluate(cell);

ClassicAssert.AreEqual(CellType.Error, cv.CellType,
"An unknown table name should evaluate to an error, not throw");
ClassicAssert.AreEqual(FormulaError.REF.Code, cv.ErrorValue);
}
finally
{
wb.Close();
}
}

/**
* INDIRECT() with a structured reference that is syntactically valid but designates no
* range must evaluate to #REF! rather than failing evaluation. These parse to an error Ptg,
* which ParseStructuredReference surfaces as InvalidOperationException.
*/
[TestCase("\\_Prime.1[#Totals]", TestName = "TestIndirectUnresolvable_TotalsOnTableWithNoTotalsRow")]
[TestCase("\\_Prime.1[#This Row]", TestName = "TestIndirectUnresolvable_ThisRowOutsideTable")]
public void TestIndirectWithUnresolvableStructuredReference(String reference)
{
XSSFWorkbook wb = XSSFTestDataSamples.OpenSampleWorkbook("StructuredReferences.xlsx");
try
{
IFormulaEvaluator eval = new XSSFFormulaEvaluator(wb);
XSSFSheet formulaSheet = wb.GetSheet("Formulas") as XSSFSheet;

// Row 20 is well outside \_Prime.1 (A1:C7), so [#This Row] cannot resolve either.
ICell cell = formulaSheet.CreateRow(20).CreateCell(0, CellType.Formula);
cell.CellFormula = (/*setter*/"INDIRECT(\"" + reference + "\")");

CellValue cv = eval.Evaluate(cell);

ClassicAssert.AreEqual(CellType.Error, cv.CellType,
"An unresolvable structured reference should evaluate to an error, not throw");
ClassicAssert.AreEqual(FormulaError.REF.Code, cv.ErrorValue);
}
finally
{
wb.Close();
}
}

private static void Confirm(IFormulaEvaluator fe, ICell cell, double expectedResult)
{
fe.ClearAllCachedResultValues();
Expand Down
Loading
Loading