diff --git a/Source/Core/Core/Transformer.cs b/Source/Core/Core/Transformer.cs
index 3d59107..a9aef73 100644
--- a/Source/Core/Core/Transformer.cs
+++ b/Source/Core/Core/Transformer.cs
@@ -13,7 +13,7 @@ namespace Open.Database.Extensions.Core;
/// The type of the model to create from the data.
public class Transformer
{
- const string ResultsObsolete = "Use table.CreateDataReader().Map(map).ToList() instead, where map is the RowMap you gave Transformer.Create. It returns a List, not a lazy enumerable, and has no clearTable argument: clear the table yourself after reading it. Removed in 12.";
+ const string ResultsObsolete = "Use table.Map(map).ToList() instead, where map is the RowMap you gave Transformer.Create. It returns a List, not a lazy enumerable, and has no clearTable argument: clear the table yourself after reading it. Removed in 12.";
///
/// Maximum number of arrays to hold in the local array pool per bucket.
@@ -164,7 +164,7 @@ static IEnumerable Drain(Queue
public static partial class CoreExtensions
{
- const string ToDataTableObsolete = "Use table.CreateDataReader().Map(map).ToList() instead. It returns a List, not a lazy enumerable, and has no clearSourceTable argument: clear the table yourself after reading it. Removed in 12.";
+ const string ToDataTableObsolete = "Use table.Map(map).ToList() instead. It returns a List, not a lazy enumerable, and has no clearSourceTable argument: clear the table yourself after reading it. Removed in 12.";
internal static IEnumerable Concat(T first, ICollection remaining)
=> (remaining == null || remaining.Count == 0) ? [first] : remaining.Prepend(first);
@@ -200,7 +200,7 @@ static IEnumerable AsEnumerableCore(DataRowCollection rows)
}
///
- /// Obsolete: use table.CreateDataReader().Map(map).ToList().
+ /// Obsolete: use table.Map(map).ToList().
/// Loads all data into a queue before iterating (dequeuing) the results as type .
///
///
diff --git a/Source/Core/MappedViews.cs b/Source/Core/MappedViews.cs
index 7165453..ea5a3ea 100644
--- a/Source/Core/MappedViews.cs
+++ b/Source/Core/MappedViews.cs
@@ -215,6 +215,110 @@ public static MappedCommand Map(
return new MappedCommand(
Views.CommandCore(command, new SelectorSource(selector)));
}
+
+ ///
+ /// Locks in a mapping to model type over the rows of a you already have and returns a deferred .
+ /// A shortcut for table.CreateDataReader().Map<T>(), with the difference that the view owns and disposes the readers it creates.
+ /// Nothing runs until a terminal (ToList, FirstOrDefault, and so on) is invoked.
+ ///
+ ///
+ ///
+ /// Each terminal reads the table's current rows through a new and disposes it.
+ /// The table is not changed or cleared, so the view can be read more than once.
+ ///
+ ///
+ /// How is mapped is the same as for
+ /// :
+ /// a reads columns whose names differ from the properties,
+ /// and the overload without one reads by column name
+ /// (exactly, else ignoring case, else ignoring case and underscores, so author_id feeds AuthorId).
+ /// A single value such as is read from the first column.
+ ///
+ ///
+ /// The model type to map the values to.
+ /// The table whose rows are mapped.
+ /// The prebuilt to read with; reads as Map<T>() does.
+ /// A deferred query whose results are mapped to .
+ /// is .
+ ///
+ [OverloadResolutionPriority(1)]
+ public static MappedTable Map(
+ this DataTable table,
+ RowMap? map)
+ {
+ if (table is null) throw new ArgumentNullException(nameof(table));
+ Contract.EndContractBlock();
+
+ // No map reads exactly as Map() does:
+ // a single value from the first column, a model by name.
+ return map is null
+ ? Map(table)
+ : new MappedTable(Views.TableCore(table, map));
+ }
+
+ ///
+ /// The table whose rows are mapped.
+ /// The (property name, column name) pairs of a new , built on every call; none reads by column name.
+ [OverloadResolutionPriority(2)]
+ public static MappedTable Map(
+ this DataTable table,
+ params ReadOnlySpan> mappings)
+ => mappings.IsEmpty
+ ? Map(table)
+ : Map(table, RowMap.From(mappings));
+
+ ///
+ /// The table whose rows are mapped.
+ /// The one (property name, column name) pair of a new , built on every call.
+ [OverloadResolutionPriority(3)]
+ public static MappedTable Map(
+ this DataTable table,
+ KeyValuePair mapping)
+ => Map(table, new RowMap(mapping));
+
+ ///
+ [OverloadResolutionPriority(4)]
+ public static MappedTable Map(
+ this DataTable table)
+ {
+ if (table is null) throw new ArgumentNullException(nameof(table));
+ Contract.EndContractBlock();
+
+ // A single value such as int is read from the first column;
+ // any other T maps by column name.
+ return FirstColumn.Instance is { } firstColumn
+ ? new MappedTable(Views.TableCore(table, firstColumn))
+ : Map(table, RowMap.Default);
+ }
+
+ ///
+ /// Locks in a custom projection over the rows of a you already have and returns a deferred .
+ /// Nothing runs until a terminal is invoked;
+ /// is applied per row as the table is read.
+ ///
+ ///
+ ///
+ /// Each terminal reads the table's current rows through a new and disposes it.
+ /// The table is not changed or cleared, so the view can be read more than once.
+ ///
+ ///
+ /// The type each record is projected to.
+ /// The table whose rows are projected.
+ /// The transform applied to each .
+ /// A deferred query whose results are produced by .
+ /// or is .
+ [OverloadResolutionPriority(-1)]
+ public static MappedTable Map(
+ this DataTable table,
+ Func selector)
+ {
+ if (table is null) throw new ArgumentNullException(nameof(table));
+ if (selector is null) throw new ArgumentNullException(nameof(selector));
+ Contract.EndContractBlock();
+
+ return new MappedTable(
+ Views.TableCore(table, new SelectorSource(selector)));
+ }
}
// Builds the core a view runs over.
@@ -233,6 +337,11 @@ internal static MappedQueryCore CommandCore(
IDbCommand command,
IRowSource source)
=> new(new CommandExecutor(command), source, 0, -1, false);
+
+ internal static MappedQueryCore TableCore(
+ DataTable table,
+ IRowSource source)
+ => new(new TableExecutor(table), source, 0, -1, true);
}
// The two executors let MappedQueryCore run over a reader or a raw command.
@@ -415,6 +524,47 @@ ConnectionState before
}
}
+// A DataTable the caller has: each call reads it through a new
+// DataTableReader, which is disposed when the call is done.
+// There is no connection, and a table has one result set,
+// so NextResult is never called.
+// The rows are in memory, so the asynchronous form completes synchronously.
+sealed class TableExecutor(DataTable table) : ViewExecutor
+{
+ public override TResult ExecuteReader(
+ TState state,
+ Func transform,
+ CommandBehavior behavior = CommandBehavior.Default)
+ {
+ using DataTableReader reader = table.CreateDataReader();
+ return transform(reader, state);
+ }
+
+ public override ValueTask ExecuteReaderAsync(
+ TState state,
+ Func> transform,
+ CommandBehavior behavior,
+ CancellationToken cancellationToken)
+ {
+ // A cancelled token gives a cancelled task, not an exception at the call.
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return new ValueTask(
+ Task.FromCanceled(cancellationToken));
+ }
+
+ return ReadAsync(state, transform);
+ }
+
+ async ValueTask ReadAsync(
+ TState state,
+ Func> transform)
+ {
+ using DataTableReader reader = table.CreateDataReader();
+ return await transform(reader, state).ConfigureAwait(false);
+ }
+}
+
///
/// A deferred, type-locked read over a data reader the caller owns.
/// Terminals are methods here, with more as extensions on .
@@ -688,3 +838,133 @@ public ValueTask> ToHashSetAsync(
CancellationToken cancellationToken = default)
=> _core.ToHashSetAsync(comparer, cancellationToken);
}
+
+///
+/// A deferred, type-locked read over the rows of a .
+/// Terminals are methods here, with more as extensions on .
+///
+/// The type each record is mapped to.
+///
+///
+/// Each terminal reads the table's current rows through a new and disposes it.
+/// The table itself is not changed or cleared, so the view can be read more than once.
+/// The view never touches a connection and never calls .
+/// The asynchronous terminals complete synchronously: the rows are already in memory.
+///
+///
+/// Skip and Take return the types of ;
+/// the SingleResult they carry changes nothing here, since a table has one result set.
+///
+///
+///
+///
+public readonly record struct MappedTable : IMappedQuery
+{
+ readonly MappedQueryCore _core;
+
+ internal MappedTable(MappedQueryCore core)
+ => _core = core;
+
+ ///
+ public MappedQueryWithSkip Skip(int count)
+ => new(_core.WithSkip(count));
+
+ ///
+ public MappedQueryWithTake Take(int count)
+ => new(_core.WithTake(count));
+
+ ///
+ public void Read(Action> handler)
+ => _core.Read(handler);
+
+ ///
+ public TResult Read(
+ Func, TResult> handler)
+ => _core.Read(handler);
+
+ ///
+ public ValueTask ReadAsync(
+ Func, CancellationToken, ValueTask> handler,
+ CancellationToken cancellationToken)
+ => _core.ReadAsync(handler, cancellationToken);
+
+ ///
+ public ValueTask ReadAsync(
+ Func<
+ IAsyncEnumerable,
+ CancellationToken,
+ ValueTask> handler,
+ CancellationToken cancellationToken)
+ => _core.ReadAsync(handler, cancellationToken);
+
+ ///
+ public List ToList()
+ => _core.ToList();
+
+ ///
+ public ValueTask> ToListAsync(
+ CancellationToken cancellationToken = default)
+ => _core.ToListAsync(cancellationToken);
+
+ ///
+ public T[] ToArray()
+ => _core.ToArray();
+
+ ///
+ public ValueTask ToArrayAsync(
+ CancellationToken cancellationToken = default)
+ => _core.ToArrayAsync(cancellationToken);
+
+ ///
+ public T? FirstOrDefault()
+ => _core.FirstOrDefault();
+
+ ///
+ public ValueTask FirstOrDefaultAsync(
+ CancellationToken cancellationToken = default)
+ => _core.FirstOrDefaultAsync(cancellationToken);
+
+ ///
+ public T? SingleOrDefault()
+ => _core.SingleOrDefault();
+
+ ///
+ public ValueTask SingleOrDefaultAsync(
+ CancellationToken cancellationToken = default)
+ => _core.SingleOrDefaultAsync(cancellationToken);
+
+ ///
+ public ImmutableArray ToImmutableArray()
+ => _core.ToImmutableArray();
+
+ ///
+ public ValueTask> ToImmutableArrayAsync(
+ CancellationToken cancellationToken = default)
+ => _core.ToImmutableArrayAsync(cancellationToken);
+
+ ///
+ public Dictionary ToDictionary(
+ Func keySelector,
+ IEqualityComparer? comparer = null)
+ where TKey : notnull
+ => _core.ToDictionary(keySelector, comparer);
+
+ ///
+ public ValueTask> ToDictionaryAsync(
+ Func keySelector,
+ IEqualityComparer? comparer = null,
+ CancellationToken cancellationToken = default)
+ where TKey : notnull
+ => _core.ToDictionaryAsync(keySelector, comparer, cancellationToken);
+
+ ///
+ public HashSet ToHashSet(
+ IEqualityComparer? comparer = null)
+ => _core.ToHashSet(comparer);
+
+ ///
+ public ValueTask> ToHashSetAsync(
+ IEqualityComparer? comparer = null,
+ CancellationToken cancellationToken = default)
+ => _core.ToHashSetAsync(comparer, cancellationToken);
+}
diff --git a/Source/Core/PublicAPI.Unshipped.txt b/Source/Core/PublicAPI.Unshipped.txt
index b93b9e3..7bf661a 100644
--- a/Source/Core/PublicAPI.Unshipped.txt
+++ b/Source/Core/PublicAPI.Unshipped.txt
@@ -83,6 +83,11 @@ static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbComman
static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbCommand! command, System.Collections.Generic.KeyValuePair mapping) -> Open.Database.Extensions.MappedCommand
static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbCommand! command, System.Func! selector) -> Open.Database.Extensions.MappedCommand
static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbCommand! command, params System.ReadOnlySpan> mappings) -> Open.Database.Extensions.MappedCommand
+static Open.Database.Extensions.CoreExtensions.Map(this System.Data.DataTable! table) -> Open.Database.Extensions.MappedTable
+static Open.Database.Extensions.CoreExtensions.Map(this System.Data.DataTable! table, Open.Database.Extensions.RowMap? map) -> Open.Database.Extensions.MappedTable
+static Open.Database.Extensions.CoreExtensions.Map(this System.Data.DataTable! table, System.Collections.Generic.KeyValuePair mapping) -> Open.Database.Extensions.MappedTable
+static Open.Database.Extensions.CoreExtensions.Map(this System.Data.DataTable! table, System.Func! selector) -> Open.Database.Extensions.MappedTable
+static Open.Database.Extensions.CoreExtensions.Map(this System.Data.DataTable! table, params System.ReadOnlySpan> mappings) -> Open.Database.Extensions.MappedTable
Open.Database.Extensions.MappedReader
Open.Database.Extensions.MappedReader.FirstOrDefault() -> T?
Open.Database.Extensions.MappedReader.FirstOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
@@ -128,6 +133,28 @@ Open.Database.Extensions.MappedCommand.ToImmutableArray() -> System.Collectio
Open.Database.Extensions.MappedCommand.ToImmutableArrayAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask>
Open.Database.Extensions.MappedCommand.ToList() -> System.Collections.Generic.List!
Open.Database.Extensions.MappedCommand.ToListAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+Open.Database.Extensions.MappedTable
+Open.Database.Extensions.MappedTable.FirstOrDefault() -> T?
+Open.Database.Extensions.MappedTable.FirstOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+Open.Database.Extensions.MappedTable.MappedTable() -> void
+Open.Database.Extensions.MappedTable.Read(System.Action!>! handler) -> void
+Open.Database.Extensions.MappedTable.Read(System.Func!, TResult>! handler) -> TResult
+Open.Database.Extensions.MappedTable.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
+Open.Database.Extensions.MappedTable.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
+Open.Database.Extensions.MappedTable.SingleOrDefault() -> T?
+Open.Database.Extensions.MappedTable.SingleOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+Open.Database.Extensions.MappedTable.Skip(int count) -> Open.Database.Extensions.MappedQueryWithSkip
+Open.Database.Extensions.MappedTable.Take(int count) -> Open.Database.Extensions.MappedQueryWithTake
+Open.Database.Extensions.MappedTable.ToArray() -> T[]!
+Open.Database.Extensions.MappedTable.ToArrayAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+Open.Database.Extensions.MappedTable.ToDictionary(System.Func! keySelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary!
+Open.Database.Extensions.MappedTable.ToDictionaryAsync(System.Func! keySelector, System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+Open.Database.Extensions.MappedTable.ToHashSet(System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.HashSet!
+Open.Database.Extensions.MappedTable.ToHashSetAsync(System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+Open.Database.Extensions.MappedTable.ToImmutableArray() -> System.Collections.Immutable.ImmutableArray
+Open.Database.Extensions.MappedTable.ToImmutableArrayAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask>
+Open.Database.Extensions.MappedTable.ToList() -> System.Collections.Generic.List!
+Open.Database.Extensions.MappedTable.ToListAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
*REMOVED*Open.Database.Extensions.ExpressiveDbCommandBase.RetrieveAsync() -> System.Threading.Tasks.ValueTask!>
*REMOVED*Open.Database.Extensions.ExpressiveDbCommandBase.RetrieveAsync(System.Collections.Generic.IEnumerable! ordinals) -> System.Threading.Tasks.ValueTask!>
*REMOVED*Open.Database.Extensions.ExpressiveDbCommandBase.RetrieveAsync(System.Collections.Generic.IEnumerable! columnNames, bool normalizeColumnOrder = false) -> System.Threading.Tasks.ValueTask!>
diff --git a/Tests/Open.Database.Extensions.Core.Tests/DeprecationTests.cs b/Tests/Open.Database.Extensions.Core.Tests/DeprecationTests.cs
index 2c8571c..186b4ac 100644
--- a/Tests/Open.Database.Extensions.Core.Tests/DeprecationTests.cs
+++ b/Tests/Open.Database.Extensions.Core.Tests/DeprecationTests.cs
@@ -55,7 +55,7 @@ static bool IsRecordSelector(ParameterInfo p)
yield return ("1a Results(IExecuteReader)", [.. OnReader("Results", false)], [".Map().ToList()", ".Map(map).ToList()", ".SingleResult()"]);
yield return ("1b/1c ResultsAsync", [.. Of(command, "ResultsAsync")], [".Map().ToListAsync()", ".Map(map).ToListAsync()", ".SingleResult()"]);
- yield return ("2 ResultsFromDataTable", [.. Of(typeof(CoreExtensions), "ResultsFromDataTable")], [".ToDataTable()", ".CreateDataReader().Map(map).ToList()", "RowMap.Default"]);
+ yield return ("2 ResultsFromDataTable", [.. Of(typeof(CoreExtensions), "ResultsFromDataTable")], [".ToDataTable()", "table.Map(map).ToList()", "RowMap.Default"]);
yield return ("3a FirstOrDefault(selector)", [.. OnReader("FirstOrDefault", true)], [".Map(selector).FirstOrDefault()"]);
yield return ("3b SingleOrDefault(selector)", [.. OnReader("SingleOrDefault", true)], [".Map(selector).SingleOrDefault()"]);
yield return ("3c Take(selector)", [.. OnReader("Take", true)], [".Map(selector).Take(count).ToList()", "Take(0) now runs the command"]);
@@ -76,8 +76,8 @@ static bool IsRecordSelector(ParameterInfo p)
yield return ("9a IExecuteReader ToList/ToArray/ToImmutableArray(transform)", [.. ReaderTerminals.SelectMany(name => OnReader(name, true))], [".Map(transform)", "CommandBehavior.SingleResult", ".SingleResult()"]);
yield return ("9b IExecuteReader IterateReader(transform, selector)", [.. OnReader("IterateReader", true).Where(m => m.GetGenericArguments().Length == 2)], [".Map(transform).Read(selector)", "CommandBehavior.SingleResult", ".SingleResult()"]);
yield return ("10 DbCommand ToListAsync(transform)", [.. Of(typeof(CommandExtensions), "ToListAsync", m => m.GetParameters() is [{ ParameterType: var first }, ..] && first == typeof(DbCommand) && m.GetParameters().Any(IsRecordSelector))], ["await command.Map(transform).ToListAsync(cancellationToken)", ".SingleResult()"]);
- yield return ("11a To(DataTable)", [.. Of(typeof(CoreExtensions), "To", m => m.GetParameters()[0].ParameterType == typeof(DataTable))], ["table.CreateDataReader().Map(map).ToList()", "clear the table yourself"]);
- yield return ("11b Transformer.Results(DataTable)", [.. Of(typeof(Transformer<>), "Results", m => m.GetParameters() is [{ ParameterType: var first }, ..] && first == typeof(DataTable))], ["table.CreateDataReader().Map(map).ToList()", "clear the table yourself"]);
+ yield return ("11a To(DataTable)", [.. Of(typeof(CoreExtensions), "To", m => m.GetParameters()[0].ParameterType == typeof(DataTable))], ["table.Map(map).ToList()", "clear the table yourself"]);
+ yield return ("11b Transformer.Results(DataTable)", [.. Of(typeof(Transformer<>), "Results", m => m.GetParameters() is [{ ParameterType: var first }, ..] && first == typeof(DataTable))], ["table.Map(map).ToList()", "clear the table yourself"]);
yield return ("12 MappedQueryExtensions.ReadAsync(token first)", [.. Of(typeof(MappedQueryExtensions), "ReadAsync", m => m.GetParameters()[1].ParameterType == typeof(CancellationToken))], ["query.ReadAsync(handler, cancellationToken)"]);
#pragma warning disable CS0618 // Obsolete in 11.0, removed in 12: kept covered until then.
yield return ("13 SqlClient types", [typeof(ExpressiveSqlCommand), typeof(SqlConnectionFactory), typeof(SqlCommandExtensions), typeof(SqlConnectionExtensions), typeof(SqlTransactionExtensions)], ["Open.Database.Extensions.MSSqlClient"]);
@@ -152,9 +152,7 @@ public static void ResultsFromDataTable_AndToDataTableThenTo_GiveTheSameRows()
string old = Show(source.CreateDataReader().ResultsFromDataTable());
#pragma warning restore CS0618
using var table = source.CreateDataReader().ToDataTable();
-#pragma warning disable CS0618 // Obsolete in 11.0, removed in 12: kept covered until then.
- string replacement = Show(table.To(null, true).ToList());
-#pragma warning restore CS0618
+ string replacement = Show(table.Map().ToList());
Assert.Equal("1:n1,2:n2", old);
Assert.Equal(old, replacement);
diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedTableTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedTableTests.cs
new file mode 100644
index 0000000..f8f5cfc
--- /dev/null
+++ b/Tests/Open.Database.Extensions.Core.Tests/MappedTableTests.cs
@@ -0,0 +1,215 @@
+using System.Data;
+
+#nullable enable
+
+namespace Open.Database.Extensions.Tests;
+
+// table.Map() reads a DataTable you already have, through a new
+// DataTableReader per terminal, and leaves the table as it found it.
+[ExcludeFromCodeCoverage]
+public static class MappedTableTests
+{
+ sealed record Person
+ {
+ public required int Id { get; init; }
+ public string? Name { get; init; }
+ }
+
+ static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ static DataTable Table(int count)
+ {
+ DataTable table = new();
+ table.Columns.Add("Id", typeof(int));
+ table.Columns.Add("Name", typeof(string));
+ for (int i = 1; i <= count; i++)
+ {
+ table.Rows.Add(i, "n" + i);
+ }
+
+ return table;
+ }
+
+ static string Show(IEnumerable people)
+ => string.Join(",", people.Select(p => p is null ? "null" : $"{p.Id}/{p.Name}"));
+
+ [Fact]
+ public static void EveryTerminal_GivesTheSameRowsAsAReaderView()
+ {
+ using DataTable table = Table(3);
+ MappedTable view = table.Map();
+
+ using DataTableReader reader = table.CreateDataReader();
+ string expected = Show(reader.Map().ToList());
+ Assert.Equal("1/n1,2/n2,3/n3", expected);
+
+ Assert.Equal(expected, Show(view.ToList()));
+ Assert.Equal(expected, Show(view.ToArray()));
+ Assert.Equal(expected, Show(view.ToImmutableArray()));
+ Assert.Equal(expected, Show(view.ToHashSet()));
+ Assert.Equal(expected, Show(view.ToDictionary(p => p.Id).Values));
+ Assert.Equal(expected, Show(view.Read(items => items.ToList())));
+ Assert.Equal("1/n1", Show([view.FirstOrDefault()]));
+ Assert.Equal("2/n2,3/n3", Show(view.Skip(1).ToList()));
+ Assert.Equal("1/n1,2/n2", Show(view.Take(2).ToList()));
+ Assert.Equal("2/n2", Show(view.Skip(1).Take(1).ToList()));
+ Assert.Equal("2/n2", Show(view.Skip(1).SingleResult().Take(1).ToList()));
+ Assert.Throws(() => view.SingleOrDefault());
+ }
+
+ [Fact]
+ public static async Task EveryAsyncTerminal_GivesTheSameRowsAsTheSyncOnes()
+ {
+ using DataTable table = Table(3);
+ MappedTable view = table.Map();
+ string expected = Show(view.ToList());
+
+ Assert.Equal(expected, Show(await view.ToListAsync(Ct)));
+ Assert.Equal(expected, Show(await view.ToArrayAsync(Ct)));
+ Assert.Equal(expected, Show(await view.ToImmutableArrayAsync(Ct)));
+ Assert.Equal(expected, Show(await view.ToHashSetAsync(null, Ct)));
+ Assert.Equal(
+ expected,
+ Show((await view.ToDictionaryAsync(p => p.Id, null, Ct)).Values));
+ Assert.Equal("1/n1", Show([await view.FirstOrDefaultAsync(Ct)]));
+ Assert.Equal("2/n2,3/n3", Show(await view.Skip(1).ToListAsync(Ct)));
+ Assert.Equal("1/n1,2/n2", Show(await view.Take(2).ToListAsync(Ct)));
+ await Assert.ThrowsAsync(
+ async () => await view.SingleOrDefaultAsync(Ct));
+
+ List read = [];
+ await view.ReadAsync(
+ async (items, token) =>
+ {
+ await foreach (Person p in items.WithCancellation(token))
+ read.Add(p);
+ },
+ Ct);
+ Assert.Equal(expected, Show(read));
+ }
+
+ // Each terminal reads through its own reader, so the view repeats,
+ // and the table is neither cleared nor changed.
+ [Fact]
+ public static void TheSameView_CanBeReadMoreThanOnce_AndLeavesTheTable()
+ {
+ using DataTable table = Table(3);
+ MappedTable view = table.Map();
+
+ Assert.Equal("1/n1,2/n2,3/n3", Show(view.ToList()));
+ Assert.Equal("1/n1,2/n2,3/n3", Show(view.ToList()));
+ Assert.Equal(3, table.Rows.Count);
+ Assert.Equal(2, table.Columns.Count);
+
+ // The view reads the rows the table has when a terminal runs.
+ table.Rows.Add(4, "n4");
+ Assert.Equal(4, view.ToList().Count);
+ }
+
+ // A reader abandoned part way is still disposed with its call.
+ [Fact]
+ public static void AnAbandonedRead_StillLeavesTheViewUsable()
+ {
+ using DataTable table = Table(3);
+ MappedTable view = table.Map();
+
+ Assert.Equal(1, view.Read(items => items.Take(1).Count()));
+ Assert.Equal(3, view.ToList().Count);
+ }
+
+ [Fact]
+ public static void NullMap_ReadsAsTheNoArgumentOverloadDoes()
+ {
+ using DataTable table = Table(2);
+ string expected = Show(table.Map().ToList());
+
+ Assert.Equal(expected, Show(table.Map((RowMap?)null).ToList()));
+ Assert.Equal(expected, Show(table.Map([]).ToList()));
+ Assert.Equal(expected, Show(table.Map(RowMap.Default).ToList()));
+ }
+
+ [Fact]
+ public static void PairsAndSelector_ReadTheTable()
+ {
+ using DataTable table = Table(2);
+ DataColumn name = table.Columns["Name"]!;
+ name.ColumnName = "Label";
+
+ Assert.Equal(
+ "1/n1,2/n2",
+ Show(table.Map(new KeyValuePair("Name", "Label")).ToList()));
+ Assert.Equal(
+ "1/n1,2/n2",
+ Show(table.Map(
+ new KeyValuePair("Id", "Id"),
+ new KeyValuePair("Name", "Label")).ToList()));
+ Assert.Equal(
+ "10,20",
+ string.Join(",", table.Map(r => r.GetInt32(0) * 10).ToList()));
+ }
+
+ [Fact]
+ public static void ASingleValue_ReadsTheFirstColumn()
+ {
+ using DataTable table = Table(3);
+
+ Assert.Equal("1,2,3", string.Join(",", table.Map().ToList()));
+ Assert.Equal("2,3", string.Join(",", table.Map().Skip(1).ToList()));
+ }
+
+ [Fact]
+ public static async Task AnEmptyTable_GivesNothing()
+ {
+ using DataTable table = Table(0);
+ MappedTable view = table.Map();
+
+ Assert.Empty(view.ToList());
+ Assert.Null(view.FirstOrDefault());
+ Assert.Null(view.SingleOrDefault());
+ Assert.Empty(await view.ToArrayAsync(Ct));
+ Assert.Null(await view.FirstOrDefaultAsync(Ct));
+ }
+
+ [Fact]
+ public static async Task ACancelledToken_GivesACancelledTask_NotAnExceptionAtTheCall()
+ {
+ using DataTable table = Table(2);
+ MappedTable view = table.Map();
+ using CancellationTokenSource cts = new();
+ cts.Cancel();
+
+ Task> task = view.ToListAsync(cts.Token).AsTask();
+ await Assert.ThrowsAnyAsync(() => task);
+ Assert.True(task.IsCanceled);
+ Assert.Equal(2, table.Rows.Count);
+ }
+
+ [Fact]
+ public static async Task ADefaultView_ThrowsInvalidOperation()
+ {
+ MappedTable view = default;
+
+ Assert.Throws(() => view.ToList());
+ Assert.Throws(() => view.FirstOrDefault());
+ Assert.Throws(() => view.Skip(1));
+ Assert.Throws(() => view.Take(1));
+ await Assert.ThrowsAsync(
+ async () => await view.ToListAsync(Ct));
+ Assert.Equal(view, new MappedTable());
+ }
+
+ [Fact]
+ public static void ANullTable_ThrowsArgumentNull()
+ {
+ DataTable? table = null;
+
+ Assert.Throws(() => table!.Map());
+ Assert.Throws(() => table!.Map((RowMap?)null));
+ Assert.Throws(() => table!.Map([]));
+ Assert.Throws(
+ () => table!.Map(new KeyValuePair("Id", "Id")));
+ Assert.Throws(() => table!.Map(r => r.GetInt32(0)));
+ Assert.Throws