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 queue, Func map, Array } /// - /// Obsolete: use table.CreateDataReader().Map(map).ToList(). + /// Obsolete: use table.Map(map).ToList(). /// Processes the data from the data table into a queue. Then dequeues the results and transforms each one by one during enumeration. /// /// Obsolete in 11.0, removed in 12. See Deprecated in 11.0 for the difference and a before/after example. diff --git a/Source/Core/Extensions/Results.cs b/Source/Core/Extensions/Results.cs index d3b543e..61157b3 100644 --- a/Source/Core/Extensions/Results.cs +++ b/Source/Core/Extensions/Results.cs @@ -193,10 +193,10 @@ public static ValueTask> ResultsAsync( CancellationToken cancellationToken) => ResultsAsync(command, null, true, cancellationToken); - const string ResultsFromDataTableObsolete = "Use .ToDataTable() then table.CreateDataReader().Map(map).ToList() instead, for example: using var table = command.ToDataTable(); using var tableReader = table.CreateDataReader(); var rows = tableReader.Map(map).ToList(). This method cleared the table as the rows were read; Map does not, so dispose or clear the table yourself. This method threw for a type that cannot be mapped before it ran; Map still throws, but after the data is loaded: read RowMap.Default (or build your RowMap) first to keep the early failure. Removed in 12."; + const string ResultsFromDataTableObsolete = "Use .ToDataTable() then table.Map(map).ToList() instead, for example: using var table = command.ToDataTable(); var rows = table.Map(map).ToList(). This method cleared the table as the rows were read; Map does not, so dispose or clear the table yourself. This method threw for a type that cannot be mapped before it ran; Map still throws, but after the data is loaded: read RowMap.Default (or build your RowMap) first to keep the early failure. Removed in 12."; /// - /// Obsolete: use then table.CreateDataReader().Map(map).ToList(). + /// Obsolete: use then table.Map(map).ToList(). /// Loads all data into a DataTable before Iterates each record and attempts to map the fields to type T. /// Data is temporarily stored (buffered in entirety) in a queue before applying the transform for each iteration. /// @@ -218,7 +218,7 @@ public static IEnumerable ResultsFromDataTable(this IDataReader reader, Ro } /// - /// Obsolete: use then table.CreateDataReader().Map(map).ToList(). + /// Obsolete: use then table.Map(map).ToList(). /// Loads all data into a DataTable before Iterates each record and attempts to map the fields to type T. /// Data is temporarily stored (buffered in entirety) in a queue before applying the transform for each iteration. /// diff --git a/Source/Core/Extensions/_.cs b/Source/Core/Extensions/_.cs index 412107d..309fdb0 100644 --- a/Source/Core/Extensions/_.cs +++ b/Source/Core/Extensions/_.cs @@ -5,7 +5,7 @@ /// 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( + () => new DataTable().Map((Func)null!)); + } +} diff --git a/docsite/articles/mapping.md b/docsite/articles/mapping.md index 570cee1..6c6cb8f 100644 --- a/docsite/articles/mapping.md +++ b/docsite/articles/mapping.md @@ -244,6 +244,10 @@ On a command, the terminal opens the connection only if it is closed, and closes [!code-csharp[](../samples/Mapping.cs#map_command_view)] +A `DataTable` you already have works too: `table.Map()` returns a `MappedTable`, with the same overloads and terminals. Each terminal reads the table through its own `DataTableReader` and disposes it; the table is not changed or cleared, so the view can be read more than once, and there is no connection to open. It is a shortcut for `table.CreateDataReader().Map()` where the view owns the readers it creates. + +[!code-csharp[](../samples/Mapping.cs#map_table_view)] + ## Taking over: `Map(selector)` When the rules are not enough, `Map(Func)` runs your function for each row. It has the same deferred terminals: diff --git a/docsite/articles/whats-new-11.md b/docsite/articles/whats-new-11.md index 0d19c90..437f10f 100644 --- a/docsite/articles/whats-new-11.md +++ b/docsite/articles/whats-new-11.md @@ -14,6 +14,7 @@ Rebuild consumers of rc.3. Both changes are source-compatible for every call exc ## Changes since rc.3 +- `table.Map()` maps a DataTable you already have (a `MappedTable`; each terminal reads through its own DataTableReader). - `reader.Map()` and `command.Map()` map a plain `IDataReader` or `IDbCommand` with the same terminals as an expressive command's `Map()`. The reader is never opened, closed, disposed or advanced to the next result set; a command opens and closes only the connection it opened. See [Mapping a reader or a command you already have](mapping.md#mapping-a-reader-or-a-command-you-already-have). - A null `RowMap` passed to `Map(map)` no longer throws `ArgumentNullException`: it maps exactly as `Map()` does (a single value reads the first column). - A type of your own that implements both `IExecuteReader` and `IDbCommand` (or `IDataReader`) now gets an ambiguous `Map` call (CS0121); cast it to the interface you mean. @@ -95,7 +96,7 @@ These are obsolete in 11.0 (compile warning CS0618) and will be removed in 12. E |---|---|---| | `Results(IExecuteReader)` | `.Map().ToList()`; with a map `.Map(map).ToList()` | Later result sets are read, so a later failure throws (`.SingleResult()` keeps the old behaviour). Returns a `List`. | | `ExpressiveDbCommand.ResultsAsync` | `.Map().ToListAsync()` (token optional: `.ToListAsync(cancellationToken)`); with a map `.Map(map).ToListAsync()` | The same as above. | -| `ResultsFromDataTable` (reader, command) | `using var table = command.ToDataTable(); using var tableReader = table.CreateDataReader(); tableReader.Map(map).ToList()` | The same rows. The old call cleared the table as rows were read; clear or dispose it yourself. A type that cannot be mapped is reported after the data is loaded, not before the command runs: read `RowMap.Default` first to fail early. | +| `ResultsFromDataTable` (reader, command) | `using var table = command.ToDataTable(); table.Map(map).ToList()` | The same rows. The old call cleared the table as rows were read; clear or dispose it yourself. A type that cannot be mapped is reported after the data is loaded, not before the command runs: read `RowMap.Default` first to fail early. | | `FirstOrDefault(selector)`, `SingleOrDefault(selector)` on `IExecuteReader` | `.Map(selector).FirstOrDefault()`, `.Map(selector).SingleOrDefault()` | Later result sets are read (`.SingleResult()` keeps the old behaviour). | | `Take(n, selector)`, `Skip(n, selector)`, `SkipThenTake(s, t, selector)` on `IExecuteReader` | `.Map(selector).Take(n).ToList()`, `.Map(selector).Skip(n).ToList()`, `.Map(selector).Skip(s).Take(t).ToList()` | The selector is not run on skipped rows, so a selector that throws on one no longer fails the call. `Take(0)` now runs the command. Later result sets are read. | | `ExpressiveDbCommand.TakeAsync(selector, n)`, `ExpressiveDbCommand.ToListAsync(selector)` | `.Map(selector).Take(n).ToListAsync(cancellationToken)`, `.Map(selector).ToListAsync(cancellationToken)` | Later result sets are read. Returns a `List`. `Take(0)` now runs the command. `Map` has no `CommandBehavior` argument: if you passed one, call `IterateReaderAsync(handler, behavior)` or `IterateReaderWhileAsync(handler, behavior)` yourself. | @@ -106,7 +107,7 @@ These are obsolete in 11.0 (compile warning CS0618) and will be removed in 12. E | `ToList(transform, behavior)`, `ToArray(transform, behavior)`, `ToImmutableArray(transform, behavior)` on an `IExecuteReader` | `.Map(transform).ToList()`, `.Map(transform).ToArray()`, `.Map(transform).ToImmutableArray()` | Later result sets are read (`.SingleResult()` keeps the old behaviour). `Map` has no `CommandBehavior` argument: if you passed one, call `cmd.ExecuteReader(r => r.Map(transform).ToList(), behavior \| CommandBehavior.SingleResult)`. | | `IterateReader(transform, selector, behavior)` on an `IExecuteReader` | `.Map(transform).Read(selector)` | Later result sets are read (`.SingleResult()` keeps the old behaviour). If you passed a `CommandBehavior`, call `cmd.ExecuteReader(r => selector(r.Select(transform)), behavior \| CommandBehavior.SingleResult)`. | | `ToListAsync(transform)` on a `DbCommand` (three overloads, with a synchronous transform) | `await command.Map(transform).ToListAsync(cancellationToken)` | Later result sets are read (`.SingleResult()` keeps the old behaviour). `Map` executes asynchronously and then reads the rows with `Read()`, as `useReadAsync: false` did. It has no `CommandBehavior` argument: if you passed one, call `command.ExecuteReaderAsync(r => r.Map(transform).ToList(), behavior \| CommandBehavior.SingleResult, cancellationToken)`. The `IDbCommand` and `DbCommand` selector methods that are not listed here are not deprecated. | -| `To(DataTable, map, clearSourceTable)` and `Transformer.Results(DataTable, bool)` | `table.CreateDataReader().Map(map).ToList()` | Returns a `List`, not a lazy enumerable. There is no `clearSourceTable`: clear the table yourself after reading it. | +| `To(DataTable, map, clearSourceTable)` and `Transformer.Results(DataTable, bool)` | `table.Map(map).ToList()` | Returns a `List`, not a lazy enumerable. There is no `clearSourceTable`: clear the table yourself after reading it. | | `MappedQueryExtensions.ReadAsync` with the `CancellationToken` first (four overloads) | `query.ReadAsync(handler, cancellationToken)` | The same call with the token last. | | Every public type of the `Open.Database.Extensions.SqlClient` package | The same type in `Open.Database.Extensions.MSSqlClient` | The same API on `Microsoft.Data.SqlClient`. | | `AddParameterType(this IDbCommand, string, SqlDbType)` (both SQL packages) | Call `AddParameterType` on the `SqlCommand` | The overload casts any `IDbCommand` to `SqlCommand`, so it throws `InvalidCastException` for any other command. | diff --git a/docsite/samples/Deprecated.cs b/docsite/samples/Deprecated.cs index 9aaa856..2ee73f7 100644 --- a/docsite/samples/Deprecated.cs +++ b/docsite/samples/Deprecated.cs @@ -70,7 +70,7 @@ static SqliteCommand Create(SqliteConnection connection) // Reading the map first keeps the old behavior of failing before the command runs for a type that cannot be mapped. RowMap map = RowMap.Default; using var table = newCommand.ToDataTable(); - List after = table.To(map, true).ToList(); + List after = table.Map(map).ToList(); #endregion return (Key(before), Key(after)); diff --git a/docsite/samples/Mapping.cs b/docsite/samples/Mapping.cs index ac05e6d..764c268 100644 --- a/docsite/samples/Mapping.cs +++ b/docsite/samples/Mapping.cs @@ -306,6 +306,23 @@ public static async Task MapCommandView( return posts; } + public static async Task MapTableView( + SqliteConnection connection) + { + #region map_table_view + // The table is yours and is left as it is: + // each terminal reads it through its own DataTableReader. + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "select * from posts order by id"; + using SqliteDataReader reader = await command.ExecuteReaderAsync(); + using DataTable table = reader.ToDataTable(); + + Post[] posts = table.Map().ToArray(); + #endregion + + return posts; + } + public static string[] MapSelector(SqliteConnection connection) { #region map_selector diff --git a/docsite/samples/Program.cs b/docsite/samples/Program.cs index 292e012..c7124ff 100644 --- a/docsite/samples/Program.cs +++ b/docsite/samples/Program.cs @@ -70,6 +70,8 @@ void Check(bool condition, string what) Check(snake[0] is { Id: 0, FirstName: "Ada", LastName: "Lovelace" }, "snake_case columns feed PascalCase properties with no map"); Post[] readerView = await Mapping.MapReaderView(connection); Check(readerView.Length == 5 && readerView[0].Title == "Post 1", "reader.Map() maps the rows of the caller's reader"); +Post[] tableView = await Mapping.MapTableView(connection); +Check(tableView.Length == 5 && tableView[0].Title == "Post 1", "table.Map() maps the rows of a DataTable"); Post[] commandView = await Mapping.MapCommandView(connection); Check(commandView.Length == 3 && connection.State == ConnectionState.Open, "command.Map() on an open connection leaves it open"); using (SqliteConnection closedConnection = factory.Create()) @@ -202,7 +204,7 @@ void Check(bool condition, string what) (resultsBefore, resultsAfter) = await Deprecated.ResultsAsync(connection); Check(resultsBefore.Length > 0 && resultsBefore == resultsAfter, "ResultsAsync() and Map().ToListAsync() return the same rows"); (resultsBefore, resultsAfter) = Deprecated.ResultsFromDataTable(connection); -Check(resultsBefore.Length > 0 && resultsBefore == resultsAfter, "ResultsFromDataTable() and ToDataTable().To(map, true) return the same rows"); +Check(resultsBefore.Length > 0 && resultsBefore == resultsAfter, "ResultsFromDataTable() and ToDataTable() read with ToDataTable().Map(map).ToList() return the same rows"); (resultsBefore, resultsAfter) = Deprecated.SelectorForms(connection); Check(resultsBefore == "1|1,2|4,5|2,3" && resultsBefore == resultsAfter, "FirstOrDefault, Take, Skip and SkipThenTake with a selector match their Map forms"); Check(Deprecated.SkippedRowSelector(connection) == (true, false), "a selector that throws on a skipped row failed Skip, and does not fail Map(...).Skip");