ToolUp.Tabular 0.37.0

Prefix Reserved
dotnet add package ToolUp.Tabular --version 0.37.0
                    
NuGet\Install-Package ToolUp.Tabular -Version 0.37.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ToolUp.Tabular" Version="0.37.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ToolUp.Tabular" Version="0.37.0" />
                    
Directory.Packages.props
<PackageReference Include="ToolUp.Tabular" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ToolUp.Tabular --version 0.37.0
                    
#r "nuget: ToolUp.Tabular, 0.37.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ToolUp.Tabular@0.37.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ToolUp.Tabular&version=0.37.0
                    
Install as a Cake Addin
#tool nuget:?package=ToolUp.Tabular&version=0.37.0
                    
Install as a Cake Tool

ToolUp.Tabular

Typed CSV / XLSX ingestion for ToolUp.Platform consumers: parse a tabular file into typed rows against a declared column schema, and get back a structured per-row / per-cell error report — row index, column, what was expected, what the file actually said — instead of an exception on the first bad cell.

Built for the "upload your data as a spreadsheet" surface every data-shaped consumer app eventually grows: bulk submission import, reference-data loads, batch updates. One read call returns both the rows that bound cleanly and a report the operator can act on without reopening the file.

  • CSV leg — zero third-party dependencies. A BCL-only RFC 4180 parser: quoting, embedded delimiters and newlines, "" escapes, configurable delimiter, BOM-first encoding detection (UTF-8 / UTF-16 / UTF-32; BOM-less input decodes as UTF-8).
  • XLSX / XLSM leg — reads via DocumentFormat.OpenXml (the package's only third-party dependency, isolated here per GP 1 — ToolUp.Platform.* never references it). Shared strings, inline strings, number-format-aware date detection, sheet selection by name or index. Macro-enabled .xlsm workbooks read through the same call and produce identical results — macros are never executed, see below.
  • Additive by construction (GP 13) — the companion references nothing in ToolUp.Platform.* and nothing references it; deployments that don't compose it pay zero cost.

Quick start

open ToolUp.Tabular

let schema =
    TableSchema.make [
        ColumnSchema.make "Sku" ColumnType.Text
        |> ColumnSchema.required
        |> ColumnSchema.withConstraints {
            ColumnConstraints.none with
                Pattern = Some "[A-Z]{2}-[0-9]{4}"
        }
        ColumnSchema.make "Name" ColumnType.Text |> ColumnSchema.required
        ColumnSchema.make "Price" ColumnType.Number
        |> ColumnSchema.required
        |> ColumnSchema.withConstraints {
            ColumnConstraints.none with
                MinValue = Some 0m
        }
        ColumnSchema.make "Stock" ColumnType.Integer
        ColumnSchema.make "Launched" ColumnType.Date
        ColumnSchema.make "Status" (ColumnType.Choice [ "Active"; "Discontinued" ])
    ]

let result = TabularReader.readCsvBytes schema CsvReadOptions.defaults uploadedBytes

// result.Rows       : Map<string, TabularValue> list — rows where every cell bound
// result.CellErrors : CellError list — every failing cell (row, column, expected, actual)
// result.RowErrors  : RowError list  — structural problems (missing column, bad quoting, …)

For XLSX the call is symmetric — same schema, same result shape:

let result = TabularReader.readXlsxBytes schema XlsxReadOptions.defaults uploadedBytes

Binding to domain records

Rows come back as Map<string, TabularValue> by default. The binder seam maps them onto domain records without re-parsing — cells arrive already typed:

type Product = { Sku: string; Price: decimal }

let bindProduct (row: Map<string, TabularValue>) : Result<Product, CellError list> =
    match row["Sku"], row["Price"] with
    | TabularValue.Text sku, TabularValue.Number price -> Ok { Sku = sku; Price = price }
    | _ -> Error []   // unreachable when the schema declares these columns

let typed = TabularReader.readCsvBytesWith bindProduct schema CsvReadOptions.defaults bytes
// typed.Rows : Product list

A binder may also return domain-rule errors (Error [cellError]); the reader stamps the correct RowIndex onto them, so binders can leave it 0.

Error model

No exception escapes for data-shaped problems — wrong types, constraint violations, malformed quoting, a missing column, a corrupt workbook all come back as data:

Problem Where it lands
Cell fails its type parse or constraint CellErrors (RowIndex, Column, Expected, Actual, Violation)
Row has malformed CSV quoting / unreadable XLSX row RowErrors (UnparseableRow)
Declared column absent (MissingColumnPolicy.Reject) RowErrors (MissingColumn), read aborts
Undeclared column present (ExtraColumnPolicy.Reject) RowErrors (ExtraColumn), rows still bind
Same header twice RowErrors (DuplicateHeader), read aborts
Not a workbook / sheet not found one RowErrors entry at RowIndex = 0

A row binds only when every declared cell binds; all of a failing row's cell errors are reported together, not just the first. Row numbers are 1-based as a spreadsheet UI shows them (header = row 1; XLSX rows keep the sheet's own numbering). Fully-empty rows are skipped, not reported. The only exceptions thrown are for schema-authoring bugs (an invalid Pattern regex), raised eagerly before any row is read.

MaxErrors (default 1000) caps the materialised report: when the cap fires the result is marked Truncated and enumeration stops, so a pathological file (wrong delimiter, binary bytes) can't produce a hundred-thousand-entry report.

Memory contract (large files)

readCsv / readXlsx materialise only the result (bound rows + the capped error report) — never the file's full raw cell grid:

  • The CSV parser is a single-pass character state machine; nothing is buffered beyond the current record.
  • The XLSX leg walks rows with OpenXmlReader — one Row element is materialised at a time, never the worksheet DOM. The one deliberately-resident structure is the workbook's shared-string table (a workbook-level lookup any row may reference); its size is proportional to the file's distinct strings, not its row count.

Consumers that must not hold even the bound rows in memory use the streaming surface and fold per-row outcomes themselves:

TabularReader.streamCsv schema CsvReadOptions.defaults stream
|> Seq.iter (function
    | RowOutcome.Bound(rowIndex, row) -> ingest rowIndex row
    | RowOutcome.Invalid errors -> reportCells errors
    | RowOutcome.Structural error -> reportRow error)

The streams are lazy end-to-end: taking the first N outcomes of a 100k-row file reads only those rows' bytes (the test pack proves this with a byte-counting stream). MaxErrors does not apply to the streaming surface — the cap bounds materialised reports; stream consumers decide their own stopping rule.

Dates in XLSX

Real-world XLSX stores dates as numeric serials whose style (numFmtId) marks them as dates. The reader detects the built-in date formats and date-shaped custom format codes, honours the 1904 date system (workbookPr date1904), and converts serials via the OLE-automation epoch. Two documented corners:

  • A Date column over a numeric cell whose style is not date-shaped is read as a 1900-system serial (the style is the only 1904 marker, so unstyled serials in a 1904 workbook are out of reach).
  • Serials below 61 (dates before 1900-03-01) inherit the historical Excel leap-year-1900 off-by-one; dates from 1900-03-01 onward are exact.

Formula cells are read as their cached results (the stored value), not evaluated.

Macro-enabled workbooks (.xlsm) — macros are never executed

Macro-enabled workbooks are read by the same readXlsx* / streamXlsx calls, with the same options and the same XlsxReadOptions. There is no separate API and no flag to set: a .xlsm and the equivalent .xlsx produce byte-for-byte the same rows, the same cell values and the same error report. The container differs only in its [Content_Types].xml declaration and in carrying an extra xl/vbaProject.bin part; neither is part of the sheet grid.

The macro part is ignored — never executed, never evaluated, never extracted. That is structural, not a promise: the reader resolves exactly four parts — the workbook part, the shared-string table, the workbook stylesheet, and the selected worksheet — and vbaProject.bin is not among them. Nothing here resolves its relationship, opens its stream or reads a byte of it, and the package contains no interpreter, formula evaluator or VBA host that could run it. Enumerating the container to locate the workbook part reads part names, not part contents.

Two consequences worth stating to your own users:

  • An upload surface that accepts .xlsm can honestly tell an uploader "macros in this workbook are ignored" — not "we scanned them", not "we ran them safely".
  • Accepting .xlsm is still accepting a file that carries executable content for other software. If your deployment stores originals and serves them back, the macro travels with the file; that is a storage-and-egress decision, independent of this reader. The upload-validation seam (SniffingUploadValidator with MimeSniffOptions.withSpreadsheetPackages) corroborates that a payload declared as a workbook really is one, which is a different question again.

Running a workbook — evaluating macros or recalculating formulas — is a different feature with a different threat model and is deliberately out of scope.

Out of scope

XLSX/XLSM writing, the legacy .xls binary format, macro execution or extraction, formula evaluation, and schema inference (schemas are declared, not guessed).

The write side lives in ToolUp.OpenXml.Spreadsheet. This package reads a workbook that already exists; that one builds one from a typed model — sheets, rows, cells, number formats, column widths, merged ranges — and emits .xlsx bytes deterministically. The two are counterparts by design and share no code: a workbook emitted there reopens here with its values and its date-vs-number distinction intact, which is exactly what that package's test pack asserts, using this reader.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on ToolUp.Tabular:

Package Downloads
ToolUp.KnowledgeBase.Server

ToolUp.KnowledgeBase Server — document upload + multi-format extraction (PDF / PPTX / DOCX / XLSX / CSV), ingestion observer, narrative-commit, notes / AI-context API. Depends on ToolUp.AI.Server + ToolUp.RAG.Server.

ToolUp.DataSources.Excel

Spreadsheet IDataSource companion for ToolUp.Platform. Reads .xlsx / .xlsm workbooks through IBlobStorage (any provider, including LocalFileStorage for files on disk). Sheets, Excel Tables (ListObjects) and workbook-scoped named ranges are all addressable as first-class table names; schema is inferred by type-probing a configurable row sample and Query emits RFC 4180 CSV. Cached values only — no formula evaluation and no Power Query refresh.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.37.0 0 10/29/2026
0.36.0 0 10/29/2026
0.35.0 41 10/28/2026
0.34.1 55 10/27/2026
0.34.0 91 10/26/2026
0.33.0 89 10/25/2026
0.32.0 96 10/24/2026
0.31.0 128 10/22/2026
0.30.0 132 10/20/2026
0.29.0 95 10/19/2026
0.28.0 95 10/19/2026
0.27.0 103 10/16/2026
0.26.0 107 10/12/2026
0.25.1 108 10/9/2026
0.24.1 100 10/6/2026
0.23.0 117 9/23/2026
0.22.0 113 8/27/2026
0.21.0 120 8/26/2026
0.20.1 123 8/20/2026
0.20.0 137 8/19/2026