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
2 changes: 2 additions & 0 deletions docs/KNOWN-ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,5 @@ Read back through this library, xlsx and ods follow the same rules and add no ex
- LibreOffice (and Excel) keep 15 significant digits when they re-save a number: a 16-digit integer such as 9007199254740992 comes back as 9007199254740990 after opening and saving the file there. Write identifiers longer than 15 digits as text.

Reading ods back with this library, an all-empty row is passed over rather than handed out, so the import does not count it as skipped; the rows after it keep their numbers.

TabularExport picks a column type by overload resolution, and some lambdas do not resolve: a char property becomes an integer column (its code point); ulong, `p => default` and a lambda returning null are ambiguous; an int or long with a DecimalImportField is ambiguous between decimal and double; TimeSpan, DateTimeOffset, Guid and enums have no overload and fail with a misleading "cannot convert to string" (CS0029). Convert explicitly (`.ToString()`, `.UtcDateTime`, …); write enums and identifiers as text.
1,192 changes: 1,192 additions & 0 deletions docs/superpowers/plans/2026-10-04-writing-part-4-export.md

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions docs/superpowers/specs/2026-10-03-writing-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,16 @@ static readonly TabularExport<Portfolio> Export = TabularExport.For<Portfolio>()
`.Column(DecimalImportField, Func<T, decimal?>)` and `Func<T, double?>`,
`.Column(DateImportField, Func<T, DateTime?>)` and `Func<T, DateOnly?>`,
`.Column(BooleanImportField, Func<T, bool?>)`. A mismatch is a compile error. The header is
`field.Name`. An untyped `ImportField` is accepted too and checked against its `Type` at
`Build()`, with `ArgumentException`; a translated field throws there.
- Each column is an `ExportColumn<T, TValue>` holding a typed delegate: one delegate call and one
typed `Write` per cell.
`field.Name`. An untyped `ImportField` is not accepted: an overload for it would also catch a
typed field passed with a lambda of the wrong type, turning the compile error into a run-time one;
a caller with an untyped field writes `.Column(field.Name, …)`. A translated field's variant is
refused at `Column(...)` with `ArgumentException`.
- Sources may also be a stream of messages that each carry a chunk, with a selector
(`IAsyncEnumerable<TChunk>` and `Func<TChunk, IReadOnlyList<T>>`) — a gRPC server stream whose
messages hold a repeated field, passed as it comes.
- Each column is an `ExportColumn<T, TValue>` holding a typed delegate: per cell, one virtual call
and two delegate calls (the accessor and the typed `Write`), no allocation; measured at 1–6% over
a hand-written loop.
- Width: set per column, or defaulted by type (wider for date-time).
- The built export is immutable and thread-safe.

Expand Down
34 changes: 34 additions & 0 deletions src/TriasDev.Tabular/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,37 @@ static TriasDev.Tabular.Ods.OdsWriterOptions.Default.get -> TriasDev.Tabular.Ods
static TriasDev.Tabular.Ods.OdsWriterOptions.operator !=(TriasDev.Tabular.Ods.OdsWriterOptions? left, TriasDev.Tabular.Ods.OdsWriterOptions? right) -> bool
static TriasDev.Tabular.Ods.OdsWriterOptions.operator ==(TriasDev.Tabular.Ods.OdsWriterOptions? left, TriasDev.Tabular.Ods.OdsWriterOptions? right) -> bool
TriasDev.Tabular.TabularFormat.Tar = 5 -> TriasDev.Tabular.TabularFormat
static TriasDev.Tabular.TabularExport.For<T>() -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExport
TriasDev.Tabular.TabularExport<T>
TriasDev.Tabular.TabularExport<T>.Columns.get -> System.Collections.Generic.IReadOnlyList<TriasDev.Tabular.WriteColumn>!
TriasDev.Tabular.TabularExportBuilder<T>
TriasDev.Tabular.TabularExportBuilder<T>.Build() -> TriasDev.Tabular.TabularExport<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, bool?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, bool>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, decimal?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, decimal>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, double?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, double>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, long?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, long>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, string?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, System.DateOnly?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, System.DateOnly>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, System.DateTime?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(string! header, System.Func<T, System.DateTime>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(TriasDev.Tabular.BooleanImportField! field, System.Func<T, bool?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(TriasDev.Tabular.DateImportField! field, System.Func<T, System.DateOnly?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(TriasDev.Tabular.DateImportField! field, System.Func<T, System.DateTime?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(TriasDev.Tabular.DecimalImportField! field, System.Func<T, decimal?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(TriasDev.Tabular.DecimalImportField! field, System.Func<T, double?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(TriasDev.Tabular.IntegerImportField! field, System.Func<T, long?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExportBuilder<T>.Column(TriasDev.Tabular.TextImportField! field, System.Func<T, string?>! value, double? width = null) -> TriasDev.Tabular.TabularExportBuilder<T>!
TriasDev.Tabular.TabularExport<T>.WriteAsync(System.IO.Stream! stream, TriasDev.Tabular.TabularFormat format, string! sheetName, System.Collections.Generic.IAsyncEnumerable<System.Collections.Generic.IReadOnlyList<T>!>! chunks, TriasDev.Tabular.TabularWriterOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<long>
TriasDev.Tabular.TabularExport<T>.WriteAsync(System.IO.Stream! stream, TriasDev.Tabular.TabularFormat format, string! sheetName, System.Collections.Generic.IAsyncEnumerable<T>! items, TriasDev.Tabular.TabularWriterOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<long>
TriasDev.Tabular.TabularExport<T>.WriteAsync(System.IO.Stream! stream, TriasDev.Tabular.TabularFormat format, string! sheetName, System.Collections.Generic.IEnumerable<T>! items, TriasDev.Tabular.TabularWriterOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<long>
TriasDev.Tabular.TabularExport<T>.WriteAsync<TChunk>(System.IO.Stream! stream, TriasDev.Tabular.TabularFormat format, string! sheetName, System.Collections.Generic.IAsyncEnumerable<TChunk>! chunks, System.Func<TChunk, System.Collections.Generic.IReadOnlyList<T>!>! itemsOf, TriasDev.Tabular.TabularWriterOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<long>
TriasDev.Tabular.TabularExport<T>.WriteSheetAsync(TriasDev.Tabular.TabularWriter! writer, string! sheetName, System.Collections.Generic.IAsyncEnumerable<System.Collections.Generic.IReadOnlyList<T>!>! chunks, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<long>
TriasDev.Tabular.TabularExport<T>.WriteSheetAsync(TriasDev.Tabular.TabularWriter! writer, string! sheetName, System.Collections.Generic.IAsyncEnumerable<T>! items, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<long>
TriasDev.Tabular.TabularExport<T>.WriteSheetAsync(TriasDev.Tabular.TabularWriter! writer, string! sheetName, System.Collections.Generic.IEnumerable<T>! items, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<long>
TriasDev.Tabular.TabularExport<T>.WriteSheetAsync<TChunk>(TriasDev.Tabular.TabularWriter! writer, string! sheetName, System.Collections.Generic.IAsyncEnumerable<TChunk>! chunks, System.Func<TChunk, System.Collections.Generic.IReadOnlyList<T>!>! itemsOf, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<long>
110 changes: 110 additions & 0 deletions src/TriasDev.Tabular/Writing/ExportColumn.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
namespace TriasDev.Tabular;

/// <summary>One column of an export: its header and width, and how to write an item's value.</summary>
internal abstract class ExportColumn<T>(WriteColumn column)
{
public WriteColumn Column { get; } = column;

/// <summary>Writes the item's value for this column as the writer's next cell.</summary>
public abstract void Write(TabularWriter writer, T item);
}

/// <summary>
/// A column of a known type: the caller's lambda for the value, and a static delegate that writes a
/// value of that type — two delegate calls per cell, no boxing.
/// </summary>
internal sealed class ExportColumn<T, TValue>(WriteColumn column, Func<T, TValue> value, Action<TabularWriter, TValue> write)
: ExportColumn<T>(column)
{
public override void Write(TabularWriter writer, T item) => write(writer, value(item));
}

/// <summary>The typed writes, one per value type a column can have; a missing value is an empty cell.</summary>
internal static class CellWriters
{
public static readonly Action<TabularWriter, string?> Text = static (writer, value) => writer.Write(value);

public static readonly Action<TabularWriter, long> Long = static (writer, value) => writer.Write(value);

public static readonly Action<TabularWriter, long?> NullableLong = static (writer, value) =>
{
if (value is { } present)
{
writer.Write(present);
}
else
{
writer.WriteEmpty();
}
};

public static readonly Action<TabularWriter, decimal> Decimal = static (writer, value) => writer.Write(value);

public static readonly Action<TabularWriter, decimal?> NullableDecimal = static (writer, value) =>
{
if (value is { } present)
{
writer.Write(present);
}
else
{
writer.WriteEmpty();
}
};

public static readonly Action<TabularWriter, double> Double = static (writer, value) => writer.Write(value);

public static readonly Action<TabularWriter, double?> NullableDouble = static (writer, value) =>
{
if (value is { } present)
{
writer.Write(present);
}
else
{
writer.WriteEmpty();
}
};

public static readonly Action<TabularWriter, DateTime> DateTime = static (writer, value) => writer.Write(value);

public static readonly Action<TabularWriter, DateTime?> NullableDateTime = static (writer, value) =>
{
if (value is { } present)
{
writer.Write(present);
}
else
{
writer.WriteEmpty();
}
};

public static readonly Action<TabularWriter, DateOnly> DateOnly = static (writer, value) => writer.Write(value);

public static readonly Action<TabularWriter, DateOnly?> NullableDateOnly = static (writer, value) =>
{
if (value is { } present)
{
writer.Write(present);
}
else
{
writer.WriteEmpty();
}
};

public static readonly Action<TabularWriter, bool> Boolean = static (writer, value) => writer.Write(value);

public static readonly Action<TabularWriter, bool?> NullableBoolean = static (writer, value) =>
{
if (value is { } present)
{
writer.Write(present);
}
else
{
writer.WriteEmpty();
}
};
}
Loading
Loading