DHI.Services.Spreadsheets - Internal Developer Guide¶
This guide explains the Spreadsheets module in DHI.Services.Spreadsheets: what it does, the core types, how grouping & the data model work, how ranges/cells are addressed, how to use the service and repositories, and how to wire JSON converters so your models serialize cleanly.
1) What the Spreadsheets module does¶
At a glance:
- Store and retrieve tabular sheets as domain entities (
Spreadsheet<TId>). - Group spreadsheets (folder/tenant/etc.) and list by group.
- Read cells, ranges, used ranges, and format hints from provider backends.
- Upload/download raw spreadsheet files via streams (provider-dependent).
- Swap persistence by changing the repository (Markdown files, Excel/OpenXML, cloud…).
The module follows the standard Domain Services pattern:
[Entity] Spreadsheet<TId> -> [Repository] ISpreadsheetRepository<TId> -> [Service] SpreadsheetService<TId>
2) Key concepts¶
2.1 Spreadsheet identity & grouping¶
Spreadsheet<TId> inherits BaseGroupedEntity<TId> so every spreadsheet has:
- Id (
TId) – stable key the repo uses to address the sheet (e.g.,"projectA/tables/parameters"). - Name (
string) – display name of the spreadsheet (e.g.,"parameters"). - Group (
string?) – logical folder/tenant;FullName = "{Group}/{Name}". - Metadata (
IDictionary<string,object?>) and Permissions (fromBaseEntity).
2.2 Data model (multi-sheet)¶
A spreadsheet’s tabular content lives in:
public List<object[,]> Data { get; } // one 2D array per sheet
- Each
object[,]is a rectangular matrix. - Row 0 must be the header row (column names as strings).
- Rows
1..Nare the data rows (cells may benull). - Values are typed (
int,double,bool,DateTime, orstring) - JSON converters map them back and forth.
Don’t need to stuff all data in
Data? You don’t have to. Some providers can read directly from the native file viaGetRange/GetUsedRangewithout populatingData.
2.3 Sheets vs. Data vs. Sheet¶
Repositories speak in sheet names (e.g., "parameters"). In-memory, you can also convert a single object[,] into a more ergonomic row-object view using Sheet:
object[,] data = ...; // [0,*] = headers, [1..,*] = row values
Sheet rows = data.ToSheet(); // extension method
var v = rows.GetValue(0, "Name"); // first data row’s value of "Name"
2.4 Cells, ranges & formats¶
Cell(row, col)andRange(upperLeft, lowerRight)are 0-based indices.- Rows/cols in
Cell/Rangerefer to the data body, not the header row, unless a provider states otherwise. CellFormat(Number,Text,DateTime,Boolean) is a hint that some providers can return for the used range.
Provider differences exist (e.g., Markdown vs. Excel). See the provider docs for exact behavior; the service keeps the signatures uniform.
3) Core types overview¶
3.1 Entity¶
[Serializable]
public class Spreadsheet<TId> : BaseGroupedEntity<TId>
{
public Spreadsheet(TId id, string name, string group) : base(id, name, group) {}
public List<object[,]> Data { get; } = new(); // optional, one entry per sheet
public bool ShouldSerializeData() => Data.Count > 0;
}
3.2 Repository contract¶
public interface ISpreadsheetRepository<TId> :
IRepository<Spreadsheet<TId>, TId>,
IDiscreteRepository<Spreadsheet<TId>, TId>,
IUpdatableRepository<Spreadsheet<TId>, TId>,
IGroupedRepository<Spreadsheet<TId>>,
IStreamableRepository<TId>
{
// Sheet-centric reads
Maybe<object> GetCellValue(TId id, string sheetName, Cell cell, ClaimsPrincipal user = null);
Maybe<object[,]> GetNamedRange(TId id, string sheetName, string rangeName, ClaimsPrincipal user = null);
Maybe<object[,]> GetRange(TId id, string sheetName, Range range, ClaimsPrincipal user = null);
Maybe<object[,]> GetUsedRange(TId id, string sheetName, ClaimsPrincipal user = null);
Maybe<CellFormat[,]>GetUsedRangeFormats(TId id, string sheetName, ClaimsPrincipal user = null);
bool ContainsSheet(TId id, string sheetName, ClaimsPrincipal user = null);
// Raw file ingress/egress
void AddStream(TId id, string name, string group, Stream stream, ClaimsPrincipal user = null);
// from IStreamableRepository<TId>:
(Maybe<Stream>, string fileType, string fileName) GetStream(TId id, ClaimsPrincipal user = null);
}
Base helper: BaseSpreadsheetRepository<TId> implements the discrete/group listing boilerplate you can inherit in file-based providers.
3.3 Service¶
public class SpreadsheetService<TId> :
BaseGroupedUpdatableDiscreteService<Spreadsheet<TId>, TId>,
ISpreadsheetService<TId>
{
// Query helpers wrap Maybe<T> -> T? and throw where appropriate
public object GetCellValue(TId id, string sheet, Cell cell, ClaimsPrincipal user = null);
public object[,] GetNamedRange(TId id, string sheet, string rangeName, ClaimsPrincipal user = null);
public object[,] GetRange(TId id, string sheet, Range range, ClaimsPrincipal user = null);
public object[,] GetUsedRange(TId id, string sheet, ClaimsPrincipal user = null);
public CellFormat[,] GetUsedRangeFormats(TId id, string sheet, ClaimsPrincipal user = null);
public bool SheetExists(TId id, string sheet, ClaimsPrincipal user = null);
// Streams
public void AddStream(TId id, string name, string group, Stream stream, ClaimsPrincipal user = null);
public (Stream stream, string fileType, string fileName) GetStream(TId id, ClaimsPrincipal user = null);
// Provider discovery (plugin scanning)
public static Type[] GetRepositoryTypes();
public static Type[] GetRepositoryTypes(string path);
public static Type[] GetRepositoryTypes(string path, string searchPattern);
}
Being a BaseGroupedUpdatableDiscreteService, you also get:
- CRUD (
Add,Update,Remove,AddOrUpdate), list (GetAll,GetByGroup, …), and Exists/Count. - Lifecycle events:
Adding/Added,Updating/Updated,Deleting/Deleted, plus group variants (DeletingGroup/DeletedGroup). - Automatic stamping of
Added/Updatedwhen the entity implementsITraceableEntity<TId>.
3.4 JSON converters (important)¶
The module ships converters so spreadsheets serialize predictably via System.Text.Json:
TwoDimensionalArrayConverter<T>- serializesT[,]to JSON arrays of arrays and back.DoubleConverter- writes numbers with a trailing.0when they look like integers to avoid1/1.0instability.SpreadsheetConverter<TId>- controls the JSON shape ofSpreadsheet<TId>(Id/Name/Group/Data/Metadata/Permissions), using the converters above and core converters (PermissionConverter,ObjectToInferredTypeConverter).
Registering converters in your host:
builder.Services.AddControllers()
.AddJsonOptions(o =>
{
o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
o.JsonSerializerOptions.Converters.Add(new DHI.Services.Spreadsheets.Converters.SpreadsheetConverter<string>());
o.JsonSerializerOptions.Converters.Add(new DHI.Services.Spreadsheets.Converters.TwoDimensionalArrayConverter<object>());
o.JsonSerializerOptions.Converters.Add(new DHI.Services.Spreadsheets.Converters.DoubleConverter());
o.JsonSerializerOptions.Converters.Add(new DHI.Services.Converters.ObjectToInferredTypeConverter());
o.JsonSerializerOptions.Converters.Add(new DHI.Services.Converters.PermissionConverter());
});
JSON shape (example)
{
"id": "projectA/tables/parameters",
"name": "parameters",
"group": "projectA/tables",
"data": [
[
["Name","Value","Active"],
["Alpha",1.0,true],
["Beta",2.5,false]
]
],
"metadata": { "SheetNames": ["parameters"], "DefinedNames": ["values"] },
"permissions": [ { "principals":["Editors"], "operation":"update", "type":"Allowed" } ]
}
4) Built-in file provider (Markdown) - quick peek¶
While providers get their own docs, there’s a lightweight Markdown provider inside this package that treats *.md tables as sheets:
- Each heading becomes a sheet name.
- The first table after a heading becomes the sheet’s data.
- Markdown round-trips using
MarkdownDocument+MarkdownParser/MarkdownBuilder. - Repository:
DHI.Services.Spreadsheets.Markdown.MarkdownRepository(TId = string, IDs look like"group/file.md"without the.mdis fine; it’s appended).
Use it for simple file-backed scenarios and tests; see provider doc for details.
5) Typical tasks (with code)¶
The snippets below use
stringIDs for simplicity. Swap in your provider andTIdas needed.
5.1 Create & save a spreadsheet (in-memory data)¶
// Build the entity
var id = "projects/demo/parameters";
var name = "parameters";
var group = "projects/demo";
var s = new Spreadsheet<string>(id, name, group);
// one sheet called "parameters": header row + 2 data rows
s.Data.Add(new object[,]
{
{ "Name", "Value", "Active" },
{ "Alpha", 1.0, true },
{ "Beta", 2.5, false }
});
// Persist with your repository (example uses Markdown file repo)
ISpreadsheetRepository<string> repo = new DHI.Services.Spreadsheets.Markdown.MarkdownRepository(@"C:\AppData\md");
var svc = new SpreadsheetService<string>(repo);
svc.Add(s);
5.2 List, group, and fetch¶
foreach (var sheet in svc.GetAll())
Console.WriteLine(sheet.FullName);
foreach (var sheet in svc.GetByGroup("projects/demo"))
Console.WriteLine($"{sheet.Name} -> {sheet.Metadata.Count} meta items");
5.3 Read a cell / a range / the used range¶
// First data row, first column (0-based, data body only)
var cell = svc.GetCellValue(id, "parameters", new Cell(0, 0)); // "Alpha"
// A 2x2 body range (rows 0..1, cols 0..1)
var range = svc.GetRange(id, "parameters",
new Range(new Cell(0,0), new Cell(1,1)));
// The whole table as a rectangular array with header row at [0,*]
var used = svc.GetUsedRange(id, "parameters");
// Optional format hints for the used range
var formats = svc.GetUsedRangeFormats(id, "parameters");
5.4 Work with rows as objects¶
object[,] data = svc.GetUsedRange(id, "parameters");
Sheet rows = data.ToSheet();
foreach (var row in rows)
Console.WriteLine($"{row["Name"]} = {row["Value"]} (Active={row["Active"]})");
5.5 Upload/download native files (provider-specific)¶
// Add as raw file (e.g., an .md or .xlsx depending on the repo)
using var stream = File.OpenRead(@"C:\temp\parameters.md");
svc.AddStream("projects/demo/parameters", "parameters", "projects/demo", stream);
// Download for clients
var (fileStream, fileType, fileName) = svc.GetStream("projects/demo/parameters");
using var dest = File.Create($@"C:\out\{fileName}");
fileStream.CopyTo(dest);
5.6 Remove by group (group-aware repos)¶
// From BaseGroupedUpdatableDiscreteService: remove all sheets under a group
svc.RemoveByGroup("projects/demo");
6) Events & interception¶
Hook into lifecycle events to validate, veto, or audit:
svc.Adding += (_, e) =>
{
var ss = e.Item;
if (ss.Data.Count == 0) e.Cancel = true; // require at least one sheet
};
svc.Updating += (_, e) =>
{
var hasHeader = e.Item.Data.All(d => d.GetLength(0) >= 1);
if (!hasHeader) e.Cancel = true;
};
Grouped variants:
DeletingGroup/DeletedGroupfire aroundRemoveByGroup(group).
7) Provider discovery & connections¶
7.1 Discover repository providers (plugins)¶
// Scan the current base directory for assemblies implementing ISpreadsheetRepository<string>
var types = SpreadsheetService<string>.GetRepositoryTypes(AppContext.BaseDirectory, "dhi*.dll");
Use this to populate UI drop-downs or to validate configuration.
7.2 Construct from a connection object¶
SpreadsheetServiceConnection<TId> lets you create services from config:
var conn = new SpreadsheetServiceConnection<string>("sheets-md", "Markdown sheets")
{
RepositoryType = "DHI.Services.Spreadsheets.Markdown.MarkdownRepository, DHI.Services.Spreadsheets",
ConnectionString = @"C:\AppData\md"
};
var svc = (ISpreadsheetService<string>)conn.Create();
ServiceLocator.Register(svc, conn.Id);
SpreadsheetServiceConnection<TId>.CreateConnectionType<TConnection>(path) is available to discover provider types for tooling (e.g., an admin UI).
8) Security & permissions¶
- Entities carry
Permissions : IList<Permission>and helpers likeIsAllowed(ClaimsPrincipal, op). - All repository methods accept an optional
ClaimsPrincipal user- providers may enforce access control based on it. - The core service does not perform AuthZ by default; enforce at the API layer or in your provider.
9) Guarding inputs (quick recap)¶
Use Guard.Against.* at module boundaries:
Null,NullOrEmpty,NullOrWhiteSpace,NegativeOrZero, etc.CellandRangectors throw on negative indices.SpreadsheetService.SheetExists(id, name)throwsKeyNotFoundExceptionif the spreadsheet itself is missing.
10) Errors & edge cases¶
SpreadsheetService.GetStream(id)throwsKeyNotFoundExceptionif the repo returns no stream.- Indices are 0-based. For
Cell/Range, row/col refer to the data body (i.e., row 0 inCell= first data row). Header handling is provider-specific when reading from native files; the JSONDataalways encodes headers at[0,*]. Sheetthrows if the header row contains duplicate column names.TwoDimensionalArrayConverter<T>expects rectangular data. Keep your arrays rectangular.DoubleConverteremits trailing.0for integer-looking doubles (JSON stability). If your consumer relies on pure integers, store them asintnotdouble.- Named ranges may be unsupported by some providers (e.g., Markdown throws
NotSupportedException).
11) Quick reference¶
| Need… | Use… | Notes |
|---|---|---|
| Store grouped tabular data | Spreadsheet<TId> with group + name + Data |
One object[,] per sheet; row 0 is headers |
| Read a cell / range / used range | SpreadsheetService.GetCellValue/GetRange/GetUsedRange |
Indices are 0-based; refer to the data body |
| List by group / full names | GetByGroup(...), GetFullNames(...) |
From IGroupedRepository |
| Upload/download native sheet files | AddStream(...) / GetStream(...) |
File type/name come back with the stream |
| CRUD + events | BaseGroupedUpdatableDiscreteService methods + Adding/Updating/... |
Events are cancelable (CancelEventArgs<T>) |
| JSON stability for 2D arrays | Register TwoDimensionalArrayConverter<object> + DoubleConverter |
See §3.4 |
| Pluggable storage | Implement ISpreadsheetRepository<TId> or use an existing provider |
Discover via SpreadsheetService.GetRepositoryTypes(...) |
| Row-object view for a single sheet | Sheet rows = data.ToSheet() |
rows[i]["ColName"] |
12) Minimal wiring (in-process)¶
// Choose a repo (Markdown for local files; swap for Excel/OpenXML or cloud provider later)
ISpreadsheetRepository<string> repo = new DHI.Services.Spreadsheets.Markdown.MarkdownRepository(@"C:\AppData\md");
var svc = new SpreadsheetService<string>(repo);
// Register for lookups by id (used by Web API packages)
ServiceLocator.Register(svc, "sheets-file");
// Create and add a spreadsheet
var sheet = new Spreadsheet<string>("projects/demo/parameters", "parameters", "projects/demo");
sheet.Data.Add(new object[,] { { "Name","Value" }, { "Alpha", 1.0 }, { "Beta", 2.5 } });
svc.Add(sheet);
13) How the Markdown utils fit (only if you use the Markdown provider)¶
The Markdown sub-namespace provides:
MarkdownDocument- object model (headings, text blocks, tables).MarkdownParser/MarkdownBuilder- parse/build.mdtext.ExtensionMethods- map betweenMarkdownDocumentandSpreadsheet<string>(sheet names come from headings; first table after a heading is the sheet).
You don’t need these for non-Markdown providers; they’re used internally by MarkdownRepository.
See also¶
- Spreadsheets Web API - Internal Guide (routes, auth, Swagger shapes)
- Spreadsheets Providers - Internal Guides (OpenXML/Excel, MCLite, …)
- Domain Services Core (entities, security, events, connections)