From 5e9e50a6822f02879dc1ab752ac5a9d933298f6d Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 29 Sep 2026 11:23:38 +0200 Subject: [PATCH] improve excel inference --- scautable/src-jvm/Excel.scala | 37 ++++ scautable/src-jvm/ExcelMacros.scala | 169 ++++++++++++++++++- scautable/src-jvm/ExcelRange.scala | 147 ++++++++++++++-- scautable/src/ExcelOpts.scala | 34 ++++ scautable/src/package.scala | 1 + scautable/test/resources/FooterNote.xlsx | Bin 0 -> 3560 bytes scautable/test/src-jvm/ExcelOptsSuite.scala | 125 ++++++++++++++ scautable/test/src-jvm/ExcelRangeSuite.scala | 40 +++++ scautable/test/src-jvm/testExcel.scala | 68 ++++++++ site/docs/excel.md | 65 +++++++ site/resources/FooterNote.xlsx | Bin 0 -> 3560 bytes 11 files changed, 661 insertions(+), 25 deletions(-) create mode 100644 scautable/src/ExcelOpts.scala create mode 100644 scautable/test/resources/FooterNote.xlsx create mode 100644 scautable/test/src-jvm/ExcelOptsSuite.scala create mode 100644 site/resources/FooterNote.xlsx diff --git a/scautable/src-jvm/Excel.scala b/scautable/src-jvm/Excel.scala index 7607a491c..4cfa7103c 100644 --- a/scautable/src-jvm/Excel.scala +++ b/scautable/src-jvm/Excel.scala @@ -24,6 +24,13 @@ object Excel: transparent inline def absolutePath[K](filePath: String, sheetName: String, range: String = "", inline typeInferrer: TypeInferrer = TypeInferrer.StringType) = ${ readExcelAbsolutePath('filePath, 'sheetName, 'range, 'typeInferrer) } + /** Read Excel file from an absolute path, configured with an [[ExcelOpts]]. + * + * Use this when you need an option the positional overloads do not expose, such as `skipFooter`. + */ + transparent inline def absolutePath[K](inline filePath: String, inline sheetName: String, inline opts: ExcelOpts) = + ${ readExcelAbsolutePathOpts('filePath, 'sheetName, 'opts) } + /** Read Excel file from the classpath with compile-time type inference * * @param filePath @@ -47,6 +54,22 @@ object Excel: transparent inline def resource[K](filePath: String, sheetName: String) = ${ readExcelResource('filePath, 'sheetName, '{ "" }, '{ TypeInferrer.FromAllRows }) } + /** Read Excel file from the classpath, configured with an [[ExcelOpts]]. + * + * Use this when you need an option the positional overloads do not expose, such as `skipFooter`. + * + * @param filePath + * Path to the Excel file in the classpath + * @param sheetName + * Name of the Excel sheet to read + * @param opts + * Range, type inference and footer handling + * @return + * ExcelIterator with inferred types + */ + transparent inline def resource[K](inline filePath: String, inline sheetName: String, inline opts: ExcelOpts) = + ${ readExcelResourceOpts('filePath, 'sheetName, 'opts) } + /** Read an Excel file at a path relative to the source file this is called from, with compile-time type inference. * * The path is anchored to the calling source file rather than to the compiler's working directory, so it resolves the same way no matter where the build was invoked from. In a @@ -70,6 +93,13 @@ object Excel: transparent inline def relativeToSource[K](filePath: String, sheetName: String, inline typeInferrer: TypeInferrer) = ${ readExcelRelativeToSource('filePath, 'sheetName, '{ "" }, 'typeInferrer) } + /** Read an Excel file relative to the calling source file, configured with an [[ExcelOpts]]. + * + * Use this when you need an option the positional overloads do not expose, such as `skipFooter`. + */ + transparent inline def relativeToSource[K](inline filePath: String, inline sheetName: String, inline opts: ExcelOpts) = + ${ readExcelRelativeToSourceOpts('filePath, 'sheetName, 'opts) } + /** Read an Excel file at a path relative to the discovered project root, with compile-time type inference. * * The root is the first ancestor of the calling source file holding a build marker (`build.mill`, `build.sbt`, `.git`, ...). In a notebook or REPL there is no source file on @@ -93,6 +123,13 @@ object Excel: transparent inline def projectRoot[K](filePath: String, sheetName: String, inline typeInferrer: TypeInferrer) = ${ readExcelProjectRoot('filePath, 'sheetName, '{ "" }, 'typeInferrer) } + /** Read an Excel file relative to the discovered project root, configured with an [[ExcelOpts]]. + * + * Use this when you need an option the positional overloads do not expose, such as `skipFooter`. + */ + transparent inline def projectRoot[K](inline filePath: String, inline sheetName: String, inline opts: ExcelOpts) = + ${ readExcelProjectRootOpts('filePath, 'sheetName, 'opts) } + /** Cleanup cached workbook resources for a specific file. * * Call this method when you know that no more operations will be performed on a specific Excel file to free up memory immediately. diff --git a/scautable/src-jvm/ExcelMacros.scala b/scautable/src-jvm/ExcelMacros.scala index e72d0914d..dd5378300 100644 --- a/scautable/src-jvm/ExcelMacros.scala +++ b/scautable/src-jvm/ExcelMacros.scala @@ -74,15 +74,141 @@ object ExcelMacros: processExcelFile(fPath, sheetName.valueOrAbort, colRangeExpr.value, typeInferrerExpr, fPath) end readExcelProjectRoot + /** Pulls the three fields out of an `ExcelOpts` expression at compile time. + * + * Handles the shapes the compiler can produce for it: + * 1. The full 3-argument case class constructor, matched by a quoted pattern + * 2. `ExcelOpts.default` and the single argument companion `apply` + * 3. Named and/or defaulted arguments, e.g. `ExcelOpts(range = "A5", skipFooter = 1)`, which the compiler wraps in a `Block` of default-value `ValDef`s followed by a 3-arg + * `Apply`. That needs a term level walk, identifying each argument by its name and falling back to its type. + * + * Mirrors `CSV.extractCsvOptsField`, but returns all three fields at once rather than re-walking the term per field. + */ + private def extractExcelOpts(optsExpr: Expr[ExcelOpts])(using Quotes): (Expr[String], Expr[TypeInferrer], Expr[Int]) = + import quotes.reflect.* + + val defaultRange: Expr[String] = '{ "" } + val defaultInferrer: Expr[TypeInferrer] = '{ TypeInferrer.FromAllRows } + val defaultSkip: Expr[Int] = '{ 0 } + + optsExpr match + case '{ ExcelOpts($r, $t, $s) } => (r, t, s) + case '{ new ExcelOpts($r, $t, $s) } => (r, t, s) + case '{ ExcelOpts.default } => (defaultRange, defaultInferrer, defaultSkip) + case '{ ExcelOpts.apply($t: TypeInferrer) } => (defaultRange, t, defaultSkip) + case _ => + def unwrapInlined(term: Term): Term = term match + case Inlined(_, _, body) => unwrapInlined(body) + case other => other + + val rawTerm = unwrapInlined(optsExpr.asTerm) + + // Defaulted arguments arrive as Idents pointing at ValDefs the compiler lifted out ahead of the call. + val (bindings, innerTerm) = rawTerm match + case Block(stats, expr) => + (stats.collect { case vd: ValDef => (vd.name, vd.rhs) }.toMap, unwrapInlined(expr)) + case other => (Map.empty[String, Option[Term]], other) + + def resolveDefault(arg: Term): Term = + val unwrapped = arg match + case NamedArg(_, value) => value + case other => other + unwrapInlined(unwrapped) match + case Ident(name) if bindings.contains(name) => + bindings(name).map(rhs => resolveDefault(unwrapInlined(rhs))).getOrElse(unwrapped) + case other => other + end match + end resolveDefault + + def isDefaultRef(t: Term): Boolean = unwrapInlined(t) match + case Select(_, name) if name.contains("$default$") => true + case _ => false + + innerTerm match + case Apply(_, args) => + var rExpr = defaultRange + var tExpr = defaultInferrer + var sExpr = defaultSkip + + for arg <- args do + val (name, rawValue) = arg match + case NamedArg(n, v) => (Some(n), v) + case v => (None, v) + + val resolved = resolveDefault(rawValue) + + if isDefaultRef(resolved) then () // leave the default in place + else + name match + case Some("range") => rExpr = resolved.asExprOf[String] + case Some("typeInferrer") => tExpr = resolved.asExprOf[TypeInferrer] + case Some("skipFooter") => sExpr = resolved.asExprOf[Int] + case _ => + if resolved.tpe <:< TypeRepr.of[TypeInferrer] then tExpr = resolved.asExprOf[TypeInferrer] + else if resolved.tpe <:< TypeRepr.of[String] then rExpr = resolved.asExprOf[String] + else if resolved.tpe <:< TypeRepr.of[Int] then sExpr = resolved.asExprOf[Int] + end if + end for + + (rExpr, tExpr, sExpr) + case _ => + report.throwError(s"Could not read the ExcelOpts given here: ${optsExpr.show}") + end match + end match + end extractExcelOpts + + /** `skipFooter` is only meaningful as a compile time constant, because it changes the range the types are inferred from. */ + private def skipFooterValue(skipExpr: Expr[Int])(using Quotes): Int = + import quotes.reflect.* + skipExpr.value.getOrElse( + report.throwError(s"skipFooter must be a compile time constant, but was ${skipExpr.show}.") + ) + end skipFooterValue + + /** Macro implementation for reading Excel files from the classpath, configured with an `ExcelOpts` */ + def readExcelResourceOpts(pathExpr: Expr[String], sheetName: Expr[String], optsExpr: Expr[ExcelOpts])(using Quotes) = + import quotes.reflect.* + val (rangeExpr, inferrerExpr, skipExpr) = extractExcelOpts(optsExpr) + val path = pathExpr.valueOrAbort + val resourcePath = this.getClass.getClassLoader.getResource(path) + if resourcePath == null then report.throwError(s"Resource not found: $path") + end if + val validatedPath = resourcePath.toURI.getPath + processExcelFile(validatedPath, sheetName.valueOrAbort, rangeExpr.value, inferrerExpr, validatedPath, skipFooterValue(skipExpr)) + end readExcelResourceOpts + + /** Macro implementation for reading Excel files from an absolute path, configured with an `ExcelOpts` */ + def readExcelAbsolutePathOpts(pathExpr: Expr[String], sheetName: Expr[String], optsExpr: Expr[ExcelOpts])(using Quotes) = + val (rangeExpr, inferrerExpr, skipExpr) = extractExcelOpts(optsExpr) + val fPath = pathExpr.valueOrAbort + processExcelFile(fPath, sheetName.valueOrAbort, rangeExpr.value, inferrerExpr, fPath, skipFooterValue(skipExpr)) + end readExcelAbsolutePathOpts + + /** Macro implementation for reading Excel files relative to the calling source file, configured with an `ExcelOpts` */ + def readExcelRelativeToSourceOpts(pathExpr: Expr[String], sheetName: Expr[String], optsExpr: Expr[ExcelOpts])(using Quotes) = + val (rangeExpr, inferrerExpr, skipExpr) = extractExcelOpts(optsExpr) + val fPath = SourceAnchor.relativeToSource(pathExpr.valueOrAbort).absolutePath.toString + processExcelFile(fPath, sheetName.valueOrAbort, rangeExpr.value, inferrerExpr, fPath, skipFooterValue(skipExpr)) + end readExcelRelativeToSourceOpts + + /** Macro implementation for reading Excel files relative to the project root, configured with an `ExcelOpts` */ + def readExcelProjectRootOpts(pathExpr: Expr[String], sheetName: Expr[String], optsExpr: Expr[ExcelOpts])(using Quotes) = + val (rangeExpr, inferrerExpr, skipExpr) = extractExcelOpts(optsExpr) + val fPath = SourceAnchor.projectRoot(pathExpr.valueOrAbort).absolutePath.toString + processExcelFile(fPath, sheetName.valueOrAbort, rangeExpr.value, inferrerExpr, fPath, skipFooterValue(skipExpr)) + end readExcelProjectRootOpts + /** Common processing logic for both resource and absolute path Excel reading */ - private def processExcelFile(filePath: String, sheetName: String, rawColRange: Option[String], typeInferrerExpr: Expr[TypeInferrer], outputPath: String)(using Quotes) = + private def processExcelFile(filePath: String, sheetName: String, rawColRange: Option[String], typeInferrerExpr: Expr[TypeInferrer], outputPath: String, skipFooter: Int = 0)( + using Quotes + ) = import quotes.reflect.* try // Resolve any open ended range (e.g. "B5") to a fully specified one (e.g. "B5:Q55") here, at compile time, // so that the generated iterator never has to discover the table's extent itself. - val colRange = resolveRange(filePath, sheetName, rawColRange) + val colRange = resolveRange(filePath, sheetName, rawColRange, skipFooter) // Extract headers at compile time val headers = extractHeaders(filePath, sheetName, colRange) @@ -151,8 +277,15 @@ object ExcelMacros: * A fully specified range (e.g. "B5:Q55") is returned untouched. An anchor with open ends (e.g. "B5", "B5:Q" or "B5:55") has its missing edges discovered by walking the sheet, * the way `ctrl-right` and `ctrl-down` walk it in Excel. */ - private def resolveRange(filePath: String, sheetName: String, colRange: Option[String]): Option[String] = - colRange.map(_.trim).filter(_.nonEmpty).map { spec => + private def resolveRange(filePath: String, sheetName: String, colRange: Option[String], skipFooter: Int)(using Quotes): Option[String] = + import quotes.reflect.* + val spec0 = colRange.map(_.trim).filter(_.nonEmpty) + if skipFooter != 0 && spec0.isEmpty then + report.throwError( + "skipFooter needs a range to trim - give one, or an anchor such as \"A1\" for the compiler to resolve. Without a range the sheet has no bottom edge to count back from." + ) + end if + spec0.map { spec => val workbook = ExcelWorkbookCache .getOrCreate(filePath) .getOrElse( @@ -161,10 +294,28 @@ object ExcelMacros: val sheet = Option(workbook.getSheet(sheetName)).getOrElse( throw new BadTableException(s"Sheet not found: $sheetName in $filePath") ) - ExcelRange.resolve(sheet, spec) + val resolved = ExcelRange.resolveDetailed(sheet, spec).dropFooterRows(skipFooter) + if (resolved.wasInferred || resolved.footerRowsSkipped > 0) && rangeDiagnosticEnabled then report.info(s"scautable: ${resolved.describe}") + end if + resolved.range } end resolveRange + /** An open ended range is resolved silently, at compile time, so a table whose extent was inferred wrongly is invisible at the call site - the only symptom is a column type that + * is more pessimistic than the data deserves. Set `SCAUTABLE_RANGE=1` in the environment, or the `scautable.range` system property on the compiler, to have every anchored range + * report what it actually resolved to, and how. + * + * The environment variable is the one to reach for from a shell, e.g. `SCAUTABLE_RANGE=1 ./mill scautable.jvm.compile`; the system property suits a build that wants it on + * permanently. `-Xmacro-settings` would be the idiomatic channel, but reading it requires `@experimental`, which would spread to everything that calls these macros. + * + * Reported with `report.info` rather than `report.warning`, because an anchored range is a supported way to ask, not a smell, and a diagnostic should never be able to fail a + * build under `-Xfatal-warnings`. + */ + private def rangeDiagnosticEnabled: Boolean = + def isOn(v: String) = v.nonEmpty && !v.equalsIgnoreCase("false") && v != "0" + sys.env.get("SCAUTABLE_RANGE").exists(isOn) || sys.props.get("scautable.range").exists(isOn) + end rangeDiagnosticEnabled + /** Extracts headers from an Excel sheet, either from a specific range or the first row */ private def extractHeaders(filePath: String, sheetName: String, colRange: Option[String]): List[String] = @@ -257,8 +408,12 @@ object ExcelMacros: val firstCol = cellRange.getFirstColumn val lastCol = cellRange.getLastColumn - // Read only the specific rows from the range (including headers) - val targetRows = (firstRow to lastRow).map(sheet.getRow).filter(_ != null).toList + // Read the header row plus at most `numRows` data rows from the range. Lazy, so that a + // small `FirstN` on a large table does not walk the whole thing, and `+ 1` for the header + // row that is dropped further down. Capped rather than added to, because `FromAllRows` + // arrives here as `Int.MaxValue` and would otherwise overflow. + val rowLimit = math.min(numRows.toLong + 1L, Int.MaxValue.toLong).toInt + val targetRows = (firstRow to lastRow).iterator.map(sheet.getRow).filter(_ != null).take(rowLimit).toList targetRows.map { row => (firstCol to lastCol).map { i => diff --git a/scautable/src-jvm/ExcelRange.scala b/scautable/src-jvm/ExcelRange.scala index 4a0710831..a6efd7c7c 100644 --- a/scautable/src-jvm/ExcelRange.scala +++ b/scautable/src-jvm/ExcelRange.scala @@ -23,14 +23,104 @@ import io.github.quafadas.scautable.BadTableException */ object ExcelRange: + /** How one edge of a resolved range came to be. Carried so that an inferred extent can be explained back to the caller, who never sees the walk happen. */ + enum EdgeOrigin: + /** The caller pinned this edge themselves, so nothing was inferred. */ + case Pinned + + /** An Excel Table (a "ListObject") anchored at the same cell declared it. */ + case DeclaredTable + + /** Discovered by walking the sheet's cells. */ + case Walked + end EdgeOrigin + + /** A resolved range, plus enough provenance to explain how it was arrived at. + * + * @param range + * The fully specified range, e.g. "B5:Q55" + * @param colEdge + * How the right edge was decided + * @param rowEdge + * How the bottom edge was decided + * @param spec + * What the caller originally wrote + */ + case class Resolved( + range: String, + firstRow: Int, + firstCol: Int, + lastRow: Int, + lastCol: Int, + colEdge: EdgeOrigin, + rowEdge: EdgeOrigin, + spec: String, + sheetName: String, + footerRowsSkipped: Int = 0 + ): + /** True when at least one edge was decided for the caller rather than by them. */ + def wasInferred: Boolean = colEdge != EdgeOrigin.Pinned || rowEdge != EdgeOrigin.Pinned + + def columns: Int = lastCol - firstCol + 1 + + /** Rows of data, i.e. excluding the header row at `firstRow`. */ + def dataRows: Int = lastRow - firstRow + + /** A human readable account of what was inferred and how, for the compile time diagnostic. */ + def describe: String = + val firstColRef = CellReference.convertNumToColString(firstCol) + val lastColRef = CellReference.convertNumToColString(lastCol) + val edges = List( + colEdge match + case EdgeOrigin.Pinned => None + case EdgeOrigin.DeclaredTable => Some("last column declared by an Excel Table anchored at the same cell") + case EdgeOrigin.Walked => Some(s"last column found by walking right along header row ${firstRow + 1} to the first empty header") + , + rowEdge match + case EdgeOrigin.Pinned => None + case EdgeOrigin.DeclaredTable => Some("last row declared by an Excel Table anchored at the same cell") + case EdgeOrigin.Walked => Some(s"last row found by walking down to the last row with content across $firstColRef..$lastColRef") + ).flatten + val footer = + if footerRowsSkipped == 0 then Nil + else + val plural = if footerRowsSkipped == 1 then "row" else "rows" + List(s"$footerRowsSkipped footer $plural skipped, so the table ends at row ${lastRow + 1} rather than ${lastRow + 1 + footerRowsSkipped}") + s"""range "$spec" on sheet "$sheetName" resolved to "$range" - $columns columns x $dataRows data rows""" + + (edges ++ footer).mkString("\n ", "\n ", "") + end describe + + /** Trim `n` rows off the bottom of the table. + * + * This is the escape hatch for a totals row or a "Source: ..." note sitting directly under the data. Nothing separates such a row from the table, so the downward walk has no + * way to know it is not data - but the reader does, and usually knows exactly how many rows it is. + * + * Applied to a pinned range as readily as to an inferred one, so that it means the same thing wherever the extent came from. + */ + def dropFooterRows(n: Int): Resolved = + if n == 0 then this + else if n < 0 then throw new BadTableException(s"skipFooter must not be negative, but was $n.") + else if n >= dataRows then + throw new BadTableException( + s"""skipFooter = $n would leave no data rows in "$range" on sheet "$sheetName", which has $dataRows.""" + ) + else + val trimmedLastRow = lastRow - n + copy( + range = s"${new CellReference(firstRow, firstCol).formatAsString()}:${new CellReference(trimmedLastRow, lastCol).formatAsString()}", + lastRow = trimmedLastRow, + footerRowsSkipped = footerRowsSkipped + n + ) + end if + end dropFooterRows + end Resolved + private val CellRef = "^\\$?([A-Za-z]+)\\$?([0-9]+)$".r private val ColRef = "^\\$?([A-Za-z]+)$".r private val RowRef = "^\\$?([0-9]+)$".r /** The anchor of a range, plus whichever of its far edges were pinned by the user. A `None` edge is one we have to discover. */ - private case class RangeSpec(firstRow: Int, firstCol: Int, lastRow: Option[Int], lastCol: Option[Int]): - def isComplete: Boolean = lastRow.isDefined && lastCol.isDefined - end RangeSpec + private case class RangeSpec(firstRow: Int, firstCol: Int, lastRow: Option[Int], lastCol: Option[Int]) /** A cell counts as empty if Excel would show nothing in it. Formatting, and formulas evaluating to the empty string, do not make a cell part of a table. */ private def isEmpty(cell: Cell | Null): Boolean = @@ -128,22 +218,43 @@ object ExcelRange: * @return * A range string with both corners pinned */ - def resolve(sheet: Sheet, spec: String): String = + def resolve(sheet: Sheet, spec: String): String = resolveDetailed(sheet, spec).range + + /** As `resolve`, but also reports how each edge was decided, so that an inferred extent can be explained back to the caller. + * + * @param sheet + * The sheet the range refers to + * @param spec + * A full range, or an anchor with open ends - see the class documentation + * @return + * The resolved range and its provenance + */ + def resolveDetailed(sheet: Sheet, spec: String): Resolved = val trimmed = spec.trim val parsed = parse(trimmed) - if parsed.isComplete then trimmed - else - import parsed.{firstRow, firstCol} - val declared = declaredTableAt(sheet, firstRow, firstCol) - val lastCol = parsed.lastCol.orElse(declared.map(_._2)).getOrElse(discoverLastCol(sheet, firstRow, firstCol, trimmed)) - val lastRow = parsed.lastRow.orElse(declared.map(_._1)).getOrElse(discoverLastRow(sheet, firstRow, firstCol, lastCol)) - if lastCol < firstCol || lastRow < firstRow then - throw new BadTableException( - s"""Inferred an empty range from "$trimmed" on sheet "${sheet.getSheetName}".""" - ) - end if - s"${new CellReference(firstRow, firstCol).formatAsString()}:${new CellReference(lastRow, lastCol).formatAsString()}" - end if - end resolve + import parsed.{firstRow, firstCol} + + (parsed.lastRow, parsed.lastCol) match + case (Some(lastRow), Some(lastCol)) => + // Nothing to discover, and nothing to second guess - pass the caller's range through untouched. + Resolved(trimmed, firstRow, firstCol, lastRow, lastCol, EdgeOrigin.Pinned, EdgeOrigin.Pinned, trimmed, sheet.getSheetName) + case _ => + val declared = declaredTableAt(sheet, firstRow, firstCol) + val (lastCol, colEdge) = parsed.lastCol.map((_, EdgeOrigin.Pinned)) `orElse` + declared.map((d: (Int, Int)) => (d._2, EdgeOrigin.DeclaredTable)) getOrElse + (discoverLastCol(sheet, firstRow, firstCol, trimmed), EdgeOrigin.Walked) + val (lastRow, rowEdge) = parsed.lastRow.map((_, EdgeOrigin.Pinned)) `orElse` + declared.map((d: (Int, Int)) => (d._1, EdgeOrigin.DeclaredTable)) getOrElse + (discoverLastRow(sheet, firstRow, firstCol, lastCol), EdgeOrigin.Walked) + + if lastCol < firstCol || lastRow < firstRow then + throw new BadTableException( + s"""Inferred an empty range from "$trimmed" on sheet "${sheet.getSheetName}".""" + ) + end if + val range = s"${new CellReference(firstRow, firstCol).formatAsString()}:${new CellReference(lastRow, lastCol).formatAsString()}" + Resolved(range, firstRow, firstCol, lastRow, lastCol, colEdge, rowEdge, trimmed, sheet.getSheetName) + end match + end resolveDetailed end ExcelRange diff --git a/scautable/src/ExcelOpts.scala b/scautable/src/ExcelOpts.scala new file mode 100644 index 000000000..88cae2f50 --- /dev/null +++ b/scautable/src/ExcelOpts.scala @@ -0,0 +1,34 @@ +package io.github.quafadas.scautable + +import io.github.quafadas.table.TypeInferrer + +/** Configuration options for reading Excel sheets. + * + * The plain entry points take a range and a type inferrer positionally, which covers most reading. Reach for this when you need an option they do not expose - currently + * `skipFooter`. + * + * @param range + * Cell range, empty string reads the whole sheet. Either fully specified ("A1:C10"), or a top left anchor whose open edges are discovered at compile time - "A1" infers both + * edges, "A1:C" pins the columns, "A1:10" pins the rows + * @param typeInferrer + * How to infer types for columns + * @param skipFooter + * Rows to drop from the bottom of the range before anything else happens, including type inference. The escape hatch for a totals row or a source note that sits directly under + * the data with no blank row to separate it from the table + */ +case class ExcelOpts( + range: String = "", + typeInferrer: TypeInferrer = TypeInferrer.FromAllRows, + skipFooter: Int = 0 +) + +object ExcelOpts: + /** Default Excel options: read the whole sheet, infer types from all rows, skip nothing. */ + transparent inline def default: ExcelOpts = ExcelOpts("", TypeInferrer.FromAllRows, 0) + + /** Excel options with custom type inference over the whole sheet. + * + * `typeInferrer` is the second field, so this overload exists to let it be given on its own without a named argument. + */ + inline def apply(typeInferrer: TypeInferrer): ExcelOpts = ExcelOpts("", typeInferrer, 0) +end ExcelOpts diff --git a/scautable/src/package.scala b/scautable/src/package.scala index 54506c663..bed170725 100644 --- a/scautable/src/package.scala +++ b/scautable/src/package.scala @@ -12,6 +12,7 @@ object table: export io.github.quafadas.scautable.Excel.* export io.github.quafadas.scautable.BadTableException export io.github.quafadas.scautable.CsvOpts + export io.github.quafadas.scautable.ExcelOpts export io.github.quafadas.scautable.ConsoleFormat.* export io.github.quafadas.scautable.CellFormat diff --git a/scautable/test/resources/FooterNote.xlsx b/scautable/test/resources/FooterNote.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c480d684599e8bdd73d4daafe9b18cbd8dec235c GIT binary patch literal 3560 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 A0RR91 literal 0 HcmV?d00001 diff --git a/scautable/test/src-jvm/ExcelOptsSuite.scala b/scautable/test/src-jvm/ExcelOptsSuite.scala new file mode 100644 index 000000000..57767d68e --- /dev/null +++ b/scautable/test/src-jvm/ExcelOptsSuite.scala @@ -0,0 +1,125 @@ +package io.github.quafadas.scautable + +import scala.NamedTuple.* + +import io.github.quafadas.table.{*, given} + +/** `ExcelOpts`, and the `skipFooter` it exists to carry. + * + * "FooterNote.xlsx" is the case this is for. Its "Price Sheet" holds a clean four column table anchored at A5, and immediately under the last data row - with no blank row to + * separate it - a note in column A: + * + * {{{ + * A1 Q3 Price Sheet - INTERNAL + * A5 Product Region Price Units + * A6 Widget EMEA 12.5 100 + * A7 Gadget APAC 8.25 250 + * A8 Doohickey AMER 31.0 40 + * A9 Thingamy EMEA 4.75 900 + * A10 Source: Bloomberg, retrieved 2026-09-29 + * }}} + * + * The downward walk cannot tell that note from data, so it swallows row 10. Every column but the first then sees a blank cell and becomes optional - one stray row makes the whole + * table Option. `skipFooter = 1` is how the reader says what the sheet does not. + */ +class ExcelOptsSuite extends munit.FunSuite: + + test("a footer note under the table makes every other column optional") { + // The problem, stated as a type. Nothing here is wrong given what is on the sheet - the walk + // has no way to know row 10 is a note rather than data. + def swallowed: ExcelIterator[ + ("Product", "Region", "Price", "Units"), + (String, Option[String], Option[Double], Option[Int]) + ] = Excel.resource("FooterNote.xlsx", "Price Sheet", "A5", TypeInferrer.FromAllRows) + + assertEquals(swallowed.getColRange, Some("A5:D10")) + assertEquals(swallowed.size, 5) // four real rows, plus the note + } + + test("skipFooter trims the note, and the types come back clean") { + def clean: ExcelIterator[ + ("Product", "Region", "Price", "Units"), + (String, String, Double, Int) + ] = Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts("A5", TypeInferrer.FromAllRows, skipFooter = 1)) + + // The trim happens at compile time, so it is the resolved range that shrinks. + assertEquals(clean.getColRange, Some("A5:D9")) + assertEquals(clean.size, 4) + + val rows = clean.toList + assertEquals(rows.column["Product"].toList, List("Widget", "Gadget", "Doohickey", "Thingamy")) + assertEquals(rows.column["Price"].toList, List(12.5, 8.25, 31.0, 4.75)) + assertEquals(rows.column["Units"].toList, List(100, 250, 40, 900)) + } + + test("skipFooter applies to a pinned range too, so it means the same thing everywhere") { + def pinned: ExcelIterator[("Product", "Region", "Price", "Units"), (String, String, Double, Int)] = + Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts("A5:D10", TypeInferrer.FromAllRows, skipFooter = 1)) + + assertEquals(pinned.getColRange, Some("A5:D9")) + assertEquals(pinned.size, 4) + } + + test("skipFooter composes with the type inferrer rather than replacing it") { + // Missing.xlsx has staggered blanks - column 2 is blank in the second data row, column 3 in the + // third. Trimming from the bottom therefore un-Options the columns in reverse order. + def none: ExcelIterator[("Column 1", "Column 2", "Column 3"), (String, Option[String], Option[String])] = + Excel.resource("Missing.xlsx", "Sheet1", ExcelOpts("A1:C4", TypeInferrer.FromAllRows)) + + def one: ExcelIterator[("Column 1", "Column 2", "Column 3"), (String, Option[String], String)] = + Excel.resource("Missing.xlsx", "Sheet1", ExcelOpts("A1:C4", TypeInferrer.FromAllRows, skipFooter = 1)) + + def two: ExcelIterator[("Column 1", "Column 2", "Column 3"), (String, String, String)] = + Excel.resource("Missing.xlsx", "Sheet1", ExcelOpts("A1:C4", TypeInferrer.FromAllRows, skipFooter = 2)) + + assertEquals(none.size, 3) + assertEquals(one.size, 2) + assertEquals(two.size, 1) + } + + test("ExcelOpts reads the same whichever way it is written") { + // Positional, named, out of order, and defaulted all have to reach the macro intact - the + // compiler produces a different tree shape for each. + def positional = Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts("A5", TypeInferrer.FromAllRows, 1)) + def named = Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts(range = "A5", typeInferrer = TypeInferrer.FromAllRows, skipFooter = 1)) + def reordered = Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts(skipFooter = 1, range = "A5")) + def partial = Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts("A5", skipFooter = 1)) + + assertEquals(positional.getColRange, Some("A5:D9")) + assertEquals(named.getColRange, Some("A5:D9")) + assertEquals(reordered.getColRange, Some("A5:D9")) + assertEquals(partial.getColRange, Some("A5:D9")) + + // Defaults: no skipFooter, and FromAllRows inference like CsvOpts. + def defaulted: ExcelIterator[("Product", "Region", "Price", "Units"), (String, Option[String], Option[Double], Option[Int])] = + Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts("A5")) + assertEquals(defaulted.getColRange, Some("A5:D10")) + + // The single argument companion apply, for a type inferrer over the whole sheet. + def inferrerOnly = Excel.resource("Numbers.xlsx", "Sheet1", ExcelOpts(TypeInferrer.FromAllRows)) + assertEquals(inferrerOnly.getColRange, None) + } + + test("skipFooter is rejected when it cannot mean anything") { + // No range, so there is no bottom edge to count back from. + assert( + compileErrors("""Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts(skipFooter = 1))""") + .contains("skipFooter needs a range to trim"), + compileErrors("""Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts(skipFooter = 1))""") + ) + + // Trimming everything leaves no table, which is a mistake rather than an empty result. + assert( + compileErrors("""Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts("A5", skipFooter = 5))""") + .contains("would leave no data rows"), + compileErrors("""Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts("A5", skipFooter = 5))""") + ) + + assert( + compileErrors("""Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts("A5", skipFooter = -1))""") + .contains("skipFooter must not be negative"), + compileErrors("""Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts("A5", skipFooter = -1))""") + ) + } + +end ExcelOptsSuite diff --git a/scautable/test/src-jvm/ExcelRangeSuite.scala b/scautable/test/src-jvm/ExcelRangeSuite.scala index b5db44bab..4ce90cd06 100644 --- a/scautable/test/src-jvm/ExcelRangeSuite.scala +++ b/scautable/test/src-jvm/ExcelRangeSuite.scala @@ -64,4 +64,44 @@ class ExcelRangeSuite extends munit.FunSuite: intercept[BadTableException](ExcelRange.resolve(sheet("SimpleTable.xlsx"), "A1:%%")) } + test("a pinned range reports that nothing was inferred") { + val r = ExcelRange.resolveDetailed(sheet("SimpleTable.xlsx"), "A1:C4") + assertEquals(r.range, "A1:C4") + assertEquals(r.colEdge, ExcelRange.EdgeOrigin.Pinned) + assertEquals(r.rowEdge, ExcelRange.EdgeOrigin.Pinned) + assert(!r.wasInferred) + // Nothing was inferred, so there is nothing to explain beyond the headline. + assert(!r.describe.contains("walking"), r.describe) + } + + test("an anchor reports both edges as walked, with dimensions") { + val r = ExcelRange.resolveDetailed(sheet("SimpleTable.xlsx"), "A1") + assertEquals(r.range, "A1:C4") + assertEquals(r.colEdge, ExcelRange.EdgeOrigin.Walked) + assertEquals(r.rowEdge, ExcelRange.EdgeOrigin.Walked) + assert(r.wasInferred) + assertEquals(r.columns, 3) + assertEquals(r.dataRows, 3) // 4 rows in the range, one of which is the header + } + + test("a half pinned range only reports the edge it actually inferred") { + val cols = ExcelRange.resolveDetailed(sheet("Offset.xlsx"), "E3:F") + assertEquals(cols.colEdge, ExcelRange.EdgeOrigin.Pinned) + assertEquals(cols.rowEdge, ExcelRange.EdgeOrigin.Walked) + + val rows = ExcelRange.resolveDetailed(sheet("Offset.xlsx"), "E3:5") + assertEquals(rows.colEdge, ExcelRange.EdgeOrigin.Walked) + assertEquals(rows.rowEdge, ExcelRange.EdgeOrigin.Pinned) + } + + test("describe names the spec, the sheet, the resolved range and how each edge was found") { + val d = ExcelRange.resolveDetailed(sheet("Bands.xlsx"), "B6").describe + assert(d.contains("\"B6\""), d) + assert(d.contains("\"B6:G11\""), d) + assert(d.contains("Sheet1"), d) + assert(d.contains("6 columns x 5 data rows"), d) + assert(d.contains("last column found by walking right along header row 6"), d) + assert(d.contains("last row found by walking down to the last row with content across B..G"), d) + } + end ExcelRangeSuite diff --git a/scautable/test/src-jvm/testExcel.scala b/scautable/test/src-jvm/testExcel.scala index 649038aef..6b414f48d 100644 --- a/scautable/test/src-jvm/testExcel.scala +++ b/scautable/test/src-jvm/testExcel.scala @@ -98,6 +98,74 @@ class ExcelSuite extends munit.FunSuite: } + /** "Missing.xlsx" spans A1:C4. Its blanks are staggered, one row apart, which is what makes it a usable probe for how far `FirstN` actually samples: + * + * Column 1 Column 2 Column 3 Row 1, Col 1 Row 1, Col 2 Row 1, Col 3 Row 2, Col 1 Row 2, Col 3 Row 3, Col 1 Row 3, Col 2 + * + * So the second column only becomes optional once two data rows have been read, and the third only once three have. The type ascriptions below are the assertion - a regression + * here is a compile error, not a failed `assertEquals`. + */ + test("an anchored range is resolved at compile time and readable off the iterator") { + // The macro bakes the *resolved* range into the generated iterator, so `getColRange` is how you + // see what an anchor turned into without recompiling under SCAUTABLE_RANGE. + def anchored = Excel.resource("Missing.xlsx", "Sheet1", "A1", TypeInferrer.StringType) + assertEquals(anchored.getColRange, Some("A1:C4")) + + // A pinned range is passed through untouched. + def pinned = Excel.resource("Missing.xlsx", "Sheet1", "A1:C4", TypeInferrer.StringType) + assertEquals(pinned.getColRange, Some("A1:C4")) + + // No range at all resolves to None rather than Some(""), and the iterator reads the whole sheet. + def whole = Excel.resource("Missing.xlsx", "Sheet1", TypeInferrer.StringType) + assertEquals(whole.getColRange, None) + } + + test("excel TypeInferrer.FirstN stops sampling at N rows when a range is given") { + // `def`, not `val` - an ExcelIterator is an Iterator, so each use needs a fresh one. + + // One data row read, none of it blank, so nothing is optional. + def one: ExcelIterator[("Column 1", "Column 2", "Column 3"), (String, String, String)] = + Excel.resource("Missing.xlsx", "Sheet1", "A1:C4", TypeInferrer.FirstN(1)) + + // Two data rows read, which reaches the blank in column 2 but not the one in column 3. + def two: ExcelIterator[("Column 1", "Column 2", "Column 3"), (String, Option[String], String)] = + Excel.resource("Missing.xlsx", "Sheet1", "A1:C4", TypeInferrer.FirstN(2)) + + // All three data rows read, so both blanks are seen. + def three: ExcelIterator[("Column 1", "Column 2", "Column 3"), (String, Option[String], Option[String])] = + Excel.resource("Missing.xlsx", "Sheet1", "A1:C4", TypeInferrer.FirstN(3)) + + // Sampling more rows than the range holds is not an error, it just saturates. + def more: ExcelIterator[("Column 1", "Column 2", "Column 3"), (String, Option[String], Option[String])] = + Excel.resource("Missing.xlsx", "Sheet1", "A1:C4", TypeInferrer.FirstN(1000)) + + // FromAllRows arrives at the macro as FirstN(Int.MaxValue) - the row cap must not overflow. + def all: ExcelIterator[("Column 1", "Column 2", "Column 3"), (String, Option[String], Option[String])] = + Excel.resource("Missing.xlsx", "Sheet1", "A1:C4", TypeInferrer.FromAllRows) + + // FirstRow is FirstN(1), and must agree with it. + def first: ExcelIterator[("Column 1", "Column 2", "Column 3"), (String, String, String)] = + Excel.resource("Missing.xlsx", "Sheet1", "A1:C4", TypeInferrer.FirstRow) + + // Narrow sampling only narrows the *types* - every row of the range is still read back. + assertEquals(one.size, 3) + assertEquals(two.size, 3) + assertEquals(three.size, 3) + + // The staggered blanks, read back under each inferred type. An optional column decodes a blank + // to None; a column that was not sampled far enough to become optional decodes it to "". + assertEquals(two.column["Column 2"].toList(1), None) + assertEquals(two.column["Column 3"].toList(2), "") + assertEquals(three.column["Column 3"].toList(2), None) + assertEquals(all.column["Column 3"].toList(2), None) + assertEquals(more.column["Column 2"].toList(1), None) + assertEquals(first.column["Column 2"].toList(1), "") + + // Populated cells are unaffected by how far the sampling went. + assertEquals(two.column["Column 2"].toList(0), Some("Row 1, Col 2")) + assertEquals(one.column["Column 2"].toList(0), "Row 1, Col 2") + } + test("excel provider with FromTuple TypeInferrer enforces specific types") { // Force specific types using FromTuple - all columns are actually strings in SimpleTable.xlsx def csv = Excel.resource("SimpleTable.xlsx", "Sheet1", "", TypeInferrer.FromTuple[(String, String, String)]()) diff --git a/site/docs/excel.md b/site/docs/excel.md index e8f2ad907..2ab23fb4b 100644 --- a/site/docs/excel.md +++ b/site/docs/excel.md @@ -62,6 +62,71 @@ Two details worth knowing: - If the anchor sits at the top left of a real Excel Table (a "ListObject" - the thing you get from _Insert -> Table_), the extent declared by that table is used verbatim, in preference to walking cells. Nothing is guessed when the workbook already says where the table ends. - A blank header cell ends the table, because a column with no name cannot be named in the result type. Merged header cells therefore end it too - anchor at the row of real headers beneath them. +## Seeing what an anchor resolved to + +An anchored range is resolved silently, while the compiler runs. That is usually what you want, but it means a table whose extent was inferred wrongly is invisible at the call site - the only symptom is a column type more pessimistic than the data deserves, which is easy to blame on the data. + +Set `SCAUTABLE_RANGE=1` and every anchor reports itself: + +``` +SCAUTABLE_RANGE=1 ./mill scautable.jvm.compile +``` + +``` +scautable/src/PriceReport.scala:12:24 + val prices = Excel.resource("prices.xlsx", "Price Sheet", "A5", TypeInferrer.FromAllRows) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +scautable: range "A5" on sheet "Price Sheet" resolved to "A5:D10" - 4 columns x 5 data rows + last column found by walking right along header row 5 to the first empty header + last row found by walking down to the last row with content across A..D +``` + +Five data rows where you expected four is the tell. Only ranges with an edge the compiler had to work out report themselves, so pinning a range silences it. + +Two practical notes. The macro only runs when the file is actually recompiled, so a cached build prints nothing - change the file, or clean, to see the output. And `scautable.range` works as a system property if you would rather turn it on permanently in the build than per invocation. + +The resolved range is also on the iterator itself, if you would rather read it at runtime than recompile: + +```scala mdoc:reset +import io.github.quafadas.table.{*, given} + +val anchored = Excel.resource("FooterNote.xlsx", "Price Sheet", "A5", TypeInferrer.FromAllRows) +println(anchored.getColRange) +``` + +## Dropping a footer row + +The downward walk stops at the first row that is blank all the way across the table. A totals row, or a "Source: ..." note sitting directly under the data with no blank row between, is therefore indistinguishable from data - and because it populates one column and not the rest, every other column picks up a blank cell and becomes optional. One stray row makes the whole table `Option`. + +Nothing on the sheet says which rows are the table, but you know, and you usually know exactly how many rows it is. `skipFooter` is where you say so. It takes an `ExcelOpts`, which is the Excel counterpart to `CsvOpts`: + +```scala mdoc:reset +import io.github.quafadas.table.{*, given} + +// The note at A10 is swallowed, so everything but the first column goes Option +val swallowed = Excel.resource("FooterNote.xlsx", "Price Sheet", "A5", TypeInferrer.FromAllRows) +println(swallowed.getColRange) +println(swallowed.toSeq.consoleFormatNt(fansi = false)) + +// One row off the bottom, and the types come back clean +val clean = Excel.resource("FooterNote.xlsx", "Price Sheet", ExcelOpts("A5", TypeInferrer.FromAllRows, skipFooter = 1)) +println(clean.getColRange) +println(clean.toSeq.consoleFormatNt(fansi = false)) +``` + +`skipFooter` is applied before anything else, so the rows it drops are never seen by type inference - that is the whole point, and it is why this cannot be done at runtime. By the time you have an iterator to `takeWhile` over, the column types are already pessimistic and no amount of row filtering will narrow them again. + +It applies to a pinned range as readily as an inferred one, so it means the same thing wherever the extent came from. Trimming more rows than the table has is a compile error rather than an empty result, as is asking for a footer skip with no range to count back from. + +`ExcelOpts` carries the range and the type inferrer too, and defaults to `TypeInferrer.FromAllRows` like `CsvOpts` does: + +| Written as | Means | +| ----------------------------------------------------- | ---------------------------------------------------------- | +| `ExcelOpts("A5")` | Anchor at A5, infer types from all rows, skip nothing | +| `ExcelOpts("A5", TypeInferrer.FirstN(300))` | Anchor at A5, infer types from the first 300 rows | +| `ExcelOpts("A5", skipFooter = 1)` | Anchor at A5, drop the last row | +| `ExcelOpts(TypeInferrer.StringType)` | Whole sheet, everything a `String` | + ## Problems and Hints It is strongly recommended to specify a range when working with Excel - either complete, or anchored as above. e..g. diff --git a/site/resources/FooterNote.xlsx b/site/resources/FooterNote.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c480d684599e8bdd73d4daafe9b18cbd8dec235c GIT binary patch literal 3560 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 A0RR91 literal 0 HcmV?d00001