diff --git a/Source/Core/MappedQuery.cs b/Source/Core/MappedQuery.cs index af396ae..adada99 100644 --- a/Source/Core/MappedQuery.cs +++ b/Source/Core/MappedQuery.cs @@ -29,6 +29,8 @@ public static partial class CoreExtensions /// /// /// Pass a to read columns whose names differ from the properties; the overload without a map reads by column name. + /// Columns match a property exactly, else ignoring case, else ignoring case and underscores (author_id feeds AuthorId); + /// the resolved layout is cached per result-set layout. /// Declare the once and pass it: a map built in the call (a new RowMap<T>(...) or a collection expression in the argument) is rebuilt, and its row reader compiled, on every call /// (about 29 KB and a reader compile for a one-row read of a 12-property model, against 464 B through a declared map; see /// Columns named differently). @@ -42,20 +44,24 @@ public static partial class CoreExtensions /// /// The model type to map the values to. /// The command to read from. - /// The prebuilt to read with. + /// The prebuilt to read with; reads as Map<T>() does (before 11.0.0-rc.4 it threw). /// A deferred query whose results are mapped to . - /// or is . + /// is . /// [OverloadResolutionPriority(1)] public static MappedQuery Map( this IExecuteReader command, - RowMap map) + RowMap? map) { if (command is null) throw new ArgumentNullException(nameof(command)); - if (map is null) throw new ArgumentNullException(nameof(map)); Contract.EndContractBlock(); - return new MappedQuery(new MappedQueryCore(command, map, 0, -1, false)); + // No map reads exactly as Map() does: + // a single value from the first column, a model by name. + return map is null + ? Map(command) + : new MappedQuery( + new MappedQueryCore(command, map, 0, -1, false)); } /// @@ -65,7 +71,9 @@ public static MappedQuery Map( public static MappedQuery Map( this IExecuteReader command, params ReadOnlySpan> mappings) - => Map(command, RowMap.From(mappings)); + => mappings.IsEmpty + ? Map(command) + : Map(command, RowMap.From(mappings)); /// /// The command to read from. @@ -123,45 +131,50 @@ readonly struct MappedQueryCore // A caller-supplied selector may read in any order, so it runs with the default access mode. const CommandBehavior GeneratedMapping = CommandBehavior.SingleResult | CommandBehavior.SequentialAccess; - readonly IExecuteReader _command; readonly IRowSource _source; readonly int _skip; readonly int _take; // -1 == unbounded. readonly bool _singleResult; // SingleResult() was called: stop after the first result set and do not read through the rest. internal MappedQueryCore(IExecuteReader command, IRowSource source, int skip, int take, bool singleResult) - { _command = command; _source = source; _skip = skip; _take = take; _singleResult = singleResult; } + { Command = command; _source = source; _skip = skip; _take = take; _singleResult = singleResult; } + + // A default struct has no command: any use of it says so, + // the same way for every type of the family. + IExecuteReader Command + => field ?? throw new InvalidOperationException( + "This query was not created by Map(); a default value cannot be used."); // Without .SingleResult() the reader is asked for every result set, because a terminal reads through the later ones to report their failures. CommandBehavior Behavior => _singleResult ? (_source is SelectorSource ? CommandBehavior.SingleResult : GeneratedMapping) : (_source is SelectorSource ? CommandBehavior.Default : CommandBehavior.SequentialAccess); - internal MappedQueryCore WithSingleResult() => new(_command, _source, _skip, _take, true); + internal MappedQueryCore WithSingleResult() => new(Command, _source, _skip, _take, true); internal MappedQueryCore WithSkip(int count) - => count >= 0 ? new(_command, _source, _skip + count, _take, _singleResult) + => count >= 0 ? new(Command, _source, _skip + count, _take, _singleResult) : throw new ArgumentOutOfRangeException(nameof(count), count, "Cannot be negative."); // Take is set exactly once (the staging structs only expose it before any take exists), so this // just records the limit; it applies after the skip, i.e. Enumerable.Skip(a).Take(b). internal MappedQueryCore WithTake(int count) - => count >= 0 ? new(_command, _source, _skip, count, _singleResult) + => count >= 0 ? new(Command, _source, _skip, count, _singleResult) : throw new ArgumentOutOfRangeException(nameof(count), count, "Cannot be negative."); // Honors the command's UseAsyncRead choice (default false: rows are pulled with Read() after an async execute). - bool UseAsyncRead => _command is IExecuteReaderAsync { UseAsyncRead: true }; + bool UseAsyncRead => Command is IExecuteReaderAsync { UseAsyncRead: true }; // One token governs a whole call: the one passed to the terminal together with the command's own (either cancels), for the connection open, the name lookup, the execute and the reads. // No token, one token, or the same token twice needs no source, so those calls allocate nothing for it. Two different tokens need a linked source, which the call disposes when it ends. CancellationToken Combine(CancellationToken cancellationToken, out CancellationTokenSource? linked) - => CancellationTokens.Combine(_command.CancellationToken, cancellationToken, out linked); + => CancellationTokens.Combine(Command.CancellationToken, cancellationToken, out linked); // `token` is the combined token; it is also in `state`, which the terminal's static lambda reads it from. ValueTask ExecuteAsync(TState state, Func> transform, CommandBehavior behavior, CancellationTokenSource? linked, CancellationToken token) => linked is null - ? _command.ExecuteReaderAsync(state, transform, behavior, token) - : ExecuteLinkedAsync(_command, state, transform, behavior, linked, token); + ? Command.ExecuteReaderAsync(state, transform, behavior, token) + : ExecuteLinkedAsync(Command, state, transform, behavior, linked, token); static async ValueTask ExecuteLinkedAsync(IExecuteReader command, TState state, Func> transform, CommandBehavior behavior, CancellationTokenSource linked, CancellationToken token) { @@ -173,8 +186,8 @@ static async ValueTask ExecuteLinkedAsync(IExecuteRead // Nothing is read through when the transform throws, so the caller's own exception is never replaced. TResult Run(TState state, Func transform, CommandBehavior behavior) => _singleResult - ? _command.ExecuteReader(state, transform, behavior) - : _command.ExecuteReader((state, transform), static (reader, s) => + ? Command.ExecuteReader(state, transform, behavior) + : Command.ExecuteReader((state, transform), static (reader, s) => { TResult result = s.transform(reader, s.state); RowReader.ReadThrough(reader); @@ -197,14 +210,14 @@ ValueTask RunAsync(TState state, Func> handler) - => _command.ExecuteReader((handler, _source, _skip, _take, _singleResult), static (reader, s) => + => Command.ExecuteReader((handler, _source, _skip, _take, _singleResult), static (reader, s) => { s.handler(RowReader.Stream(s._source, reader, s._skip, s._take, !s._singleResult)); return false; }, Behavior); internal TResult Read(Func, TResult> handler) - => _command.ExecuteReader((handler, _source, _skip, _take, _singleResult), static (reader, s) + => Command.ExecuteReader((handler, _source, _skip, _take, _singleResult), static (reader, s) => s.handler(RowReader.Stream(s._source, reader, s._skip, s._take, !s._singleResult)), Behavior); internal ValueTask ReadAsync(Func, CancellationToken, ValueTask> handler, CancellationToken cancellationToken) diff --git a/Source/Core/MappedViews.cs b/Source/Core/MappedViews.cs new file mode 100644 index 0000000..7165453 --- /dev/null +++ b/Source/Core/MappedViews.cs @@ -0,0 +1,690 @@ +using System.Diagnostics; + +namespace Open.Database.Extensions; + +public static partial class CoreExtensions +{ + /// + /// Locks in a mapping to model type over a data reader the caller owns and returns a deferred . + /// Nothing runs until a terminal (ToList, FirstOrDefault, and so on) is invoked. + /// + /// + /// + /// The reader belongs to the caller: it is never opened, closed or disposed, and is never called on it. + /// Rows are read from the current position of the current result set, and the rows read stay read. + /// + /// + /// 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 reader to read from, positioned on the result set to map. + /// The prebuilt to read with; reads as Map<T>() does (before 11.0.0-rc.4 it threw). + /// A deferred query whose results are mapped to . + /// is . + /// + [OverloadResolutionPriority(1)] + public static MappedReader Map( + this IDataReader reader, + RowMap? map) + { + if (reader is null) throw new ArgumentNullException(nameof(reader)); + 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(reader) + : new MappedReader(Views.ReaderCore(reader, map)); + } + + /// + /// The reader to read from, positioned on the result set to map. + /// The (property name, column name) pairs of a new , built on every call; none reads by column name. + [OverloadResolutionPriority(2)] + public static MappedReader Map( + this IDataReader reader, + params ReadOnlySpan> mappings) + => mappings.IsEmpty + ? Map(reader) + : Map(reader, RowMap.From(mappings)); + + /// + /// The reader to read from, positioned on the result set to map. + /// The one (property name, column name) pair of a new , built on every call. + [OverloadResolutionPriority(3)] + public static MappedReader Map( + this IDataReader reader, + KeyValuePair mapping) + => Map(reader, new RowMap(mapping)); + + /// + [OverloadResolutionPriority(4)] + public static MappedReader Map( + this IDataReader reader) + { + if (reader is null) throw new ArgumentNullException(nameof(reader)); + 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 MappedReader(Views.ReaderCore(reader, firstColumn)) + : Map(reader, RowMap.Default); + } + + /// + /// Locks in a custom projection over a data reader the caller owns and returns a deferred . + /// Nothing runs until a terminal is invoked; + /// is applied per row as the reader streams. + /// + /// + /// + /// The reader belongs to the caller: it is never opened, closed or disposed, and is never called on it. + /// Rows are read from the current position of the current result set, and the rows read stay read. + /// + /// + /// The type each record is projected to. + /// The reader to read from, positioned on the result set to map. + /// The transform applied to each . + /// A deferred query whose results are produced by . + /// or is . + [OverloadResolutionPriority(-1)] + public static MappedReader Map( + this IDataReader reader, + Func selector) + { + if (reader is null) throw new ArgumentNullException(nameof(reader)); + if (selector is null) throw new ArgumentNullException(nameof(selector)); + Contract.EndContractBlock(); + + return new MappedReader( + Views.ReaderCore(reader, new SelectorSource(selector))); + } + + /// + /// Locks in a mapping to model type over a command and returns a deferred . + /// Nothing runs until a terminal (ToList, FirstOrDefault, and so on) is invoked. + /// + /// + /// + /// The command belongs to the caller and is never disposed. + /// The connection is opened only if it is closed, and a connection this call opened is closed (not disposed) when it ends; one that was already open is left open. + /// Like an expressive command, + /// a terminal reads through the later result sets after the first, so a failure in a later statement is thrown; + /// .SingleResult() turns that off. + /// + /// + /// 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 command to execute. + /// The prebuilt to read with; reads as Map<T>() does (before 11.0.0-rc.4 it threw). + /// A deferred query whose results are mapped to . + /// is . + /// + [OverloadResolutionPriority(1)] + public static MappedCommand Map( + this IDbCommand command, + RowMap? map) + { + if (command is null) throw new ArgumentNullException(nameof(command)); + 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(command) + : new MappedCommand(Views.CommandCore(command, map)); + } + + /// + /// The command to execute. + /// The (property name, column name) pairs of a new , built on every call; none reads by column name. + [OverloadResolutionPriority(2)] + public static MappedCommand Map( + this IDbCommand command, + params ReadOnlySpan> mappings) + => mappings.IsEmpty + ? Map(command) + : Map(command, RowMap.From(mappings)); + + /// + /// The command to execute. + /// The one (property name, column name) pair of a new , built on every call. + [OverloadResolutionPriority(3)] + public static MappedCommand Map( + this IDbCommand command, + KeyValuePair mapping) + => Map(command, new RowMap(mapping)); + + /// + [OverloadResolutionPriority(4)] + public static MappedCommand Map( + this IDbCommand command) + { + if (command is null) throw new ArgumentNullException(nameof(command)); + 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 MappedCommand(Views.CommandCore(command, firstColumn)) + : Map(command, RowMap.Default); + } + + /// + /// Locks in a custom projection over a command and returns a deferred . + /// Nothing runs until a terminal is invoked; + /// is applied per row as the reader streams. + /// + /// + /// + /// The command belongs to the caller and is never disposed. + /// The connection is opened only if it is closed, and a connection this call opened is closed (not disposed) when it ends; one that was already open is left open. + /// Like an expressive command, + /// a terminal reads through the later result sets after the first, so a failure in a later statement is thrown; + /// .SingleResult() turns that off. + /// + /// + /// The type each record is projected to. + /// The command to execute. + /// The transform applied to each . + /// A deferred query whose results are produced by . + /// or is . + [OverloadResolutionPriority(-1)] + public static MappedCommand Map( + this IDbCommand command, + Func selector) + { + if (command is null) throw new ArgumentNullException(nameof(command)); + if (selector is null) throw new ArgumentNullException(nameof(selector)); + Contract.EndContractBlock(); + + return new MappedCommand( + Views.CommandCore(command, new SelectorSource(selector))); + } +} + +// Builds the core a view runs over. +// A caller's reader is read from where it stands and is never read through, +// so it takes the single-result path of the core, +// which never calls NextResult. +// A command takes the same path as an expressive command. +static class Views +{ + internal static MappedQueryCore ReaderCore( + IDataReader reader, + IRowSource source) + => new(new ReaderExecutor(reader), source, 0, -1, true); + + internal static MappedQueryCore CommandCore( + IDbCommand command, + IRowSource source) + => new(new CommandExecutor(command), source, 0, -1, false); +} + +// The two executors let MappedQueryCore run over a reader or a raw command. +// The core only uses the two state forms; the rest are derived from them. +abstract class ViewExecutor : IExecuteReader +{ + public CancellationToken CancellationToken => default; + + public abstract TResult ExecuteReader( + TState state, + Func transform, + CommandBehavior behavior = CommandBehavior.Default); + + public abstract ValueTask ExecuteReaderAsync( + TState state, + Func> transform, + CommandBehavior behavior, + CancellationToken cancellationToken); + + public void ExecuteReader( + Action handler, + CommandBehavior behavior = CommandBehavior.Default) + { + Debug.Assert(handler is not null); + ExecuteReader(handler, static (reader, h) => + { + h(reader); + return false; + }, behavior); + } + + public T ExecuteReader( + Func transform, + CommandBehavior behavior = CommandBehavior.Default) + { + Debug.Assert(transform is not null); + return ExecuteReader( + transform, + static (reader, f) => f(reader), + behavior); + } + + public ValueTask ExecuteReaderAsync( + Func handler, + CommandBehavior behavior = CommandBehavior.Default) + { + Debug.Assert(handler is not null); + return ValueTaskHelpers.Discard(ExecuteReaderAsync( + handler, + static async (reader, h) => + { + await h(reader).ConfigureAwait(false); + return false; + }, + behavior)); + } + + public ValueTask ExecuteReaderAsync( + Func> transform, + CommandBehavior behavior = CommandBehavior.Default) + { + Debug.Assert(transform is not null); + return ExecuteReaderAsync( + transform, + static (reader, f) => f(reader), + behavior); + } + + public ValueTask ExecuteReaderAsync( + TState state, + Func> transform, + CommandBehavior behavior = CommandBehavior.Default) + => ExecuteReaderAsync(state, transform, behavior, default); +} + +// The caller's reader: handed to the transform as it is. +// It is never opened, closed or disposed here, +// and NextResult is never called on it. +sealed class ReaderExecutor(IDataReader reader) : ViewExecutor +{ + public override TResult ExecuteReader( + TState state, + Func transform, + CommandBehavior behavior = CommandBehavior.Default) + => 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 transform(reader, state); + } +} + +// The caller's command, run as an expressive command runs: +// the connection is opened only if it is closed +// and closed again (not disposed) only if it was closed when the call began. +// The command is never disposed. +sealed class CommandExecutor(IDbCommand command) : ViewExecutor +{ + public override TResult ExecuteReader( + TState state, + Func transform, + CommandBehavior behavior = CommandBehavior.Default) + { + IDbConnection? connection = command.Connection; + ConnectionState before + = connection?.State ?? ConnectionState.Closed; + try + { + connection?.EnsureOpen(); + using IDataReader reader = command.ExecuteReader( + before == ConnectionState.Closed + ? behavior | CommandBehavior.CloseConnection + : behavior); + return transform(reader, state); + } + finally + { + if (before == ConnectionState.Closed) connection?.Close(); + } + } + + public override async ValueTask ExecuteReaderAsync( + TState state, + Func> transform, + CommandBehavior behavior, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + IDbConnection? connection = command.Connection; + ConnectionState before + = connection?.State ?? ConnectionState.Closed; + try + { + // An open connection (the usual case) needs no awaiting at all. + if (connection is not null + && (before & (ConnectionState.Open | ConnectionState.Broken)) + != ConnectionState.Open) + { + await connection.EnsureOpenAsync(cancellationToken).ConfigureAwait(false); + } + + CommandBehavior b = before == ConnectionState.Closed + ? behavior | CommandBehavior.CloseConnection + : behavior; + + if (command is DbCommand dbCommand) + { +#if NETSTANDARD2_0 + using DbDataReader dbReader = await dbCommand + .ExecuteReaderAsync(b, cancellationToken) + .ConfigureAwait(false); +#else + DbDataReader dbReader = await dbCommand + .ExecuteReaderAsync(b, cancellationToken) + .ConfigureAwait(false); + await using var dbReaderDisposal = dbReader.ConfigureAwait(false); +#endif + return await transform(dbReader, state).ConfigureAwait(false); + } + + using IDataReader reader = command.ExecuteReader(b); + return await transform(reader, state).ConfigureAwait(false); + } + finally + { + if (before == ConnectionState.Closed) connection?.Close(); + } + } +} + +/// +/// A deferred, type-locked read over a data reader the caller owns. +/// Terminals are methods here, with more as extensions on . +/// +/// The type each record is mapped to. +/// +/// +/// The reader belongs to the caller. +/// A terminal never opens, closes or disposes it, +/// and never calls on it: +/// it reads the rows of the current result set from the reader's current position and stops, +/// leaving the reader open and positioned after that set's last row. +/// A caller who wants the next result set calls NextResult and maps again. +/// The asynchronous terminals read the rows with Read(), +/// as an expressive command does by default. +/// +/// +/// Skip and Take return the types of ; +/// the SingleResult they carry changes nothing here, since a reader view never reads through later result sets. +/// +/// +/// +/// +public readonly record struct MappedReader : IMappedQuery +{ + readonly MappedQueryCore _core; + + internal MappedReader(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); +} + +/// +/// A deferred, type-locked query over a raw . +/// Terminals are methods here, with more as extensions on . +/// +/// The type each record is mapped to. +/// +/// +/// The command belongs to the caller and is never disposed. +/// Each terminal executes it again. +/// The connection is opened only if it is closed, +/// and a connection the terminal opened is closed (Close, not Dispose) when it ends; +/// one that was already open is left open. +/// The asynchronous terminals open and execute asynchronously when the command is a , +/// then read the rows with Read(), as an expressive command does by default. +/// +/// +/// A terminal returns the rows of the first result set only, +/// then reads through the later sets without reading their rows, +/// so a failure in a later statement is thrown as the provider raised it; +/// turns that off. +/// See for what a read-through does and does not do. +/// +/// +/// +/// +public readonly record struct MappedCommand : IMappedQuery +{ + readonly MappedQueryCore _core; + + internal MappedCommand(MappedQueryCore core) + => _core = core; + + /// + public MappedQueryWithSkip Skip(int count) + => new(_core.WithSkip(count)); + + /// + public MappedQueryWithTake Take(int count) + => new(_core.WithTake(count)); + + /// + public MappedCommand SingleResult() + => new(_core.WithSingleResult()); + + /// + 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 be2cc4a..b93b9e3 100644 --- a/Source/Core/PublicAPI.Unshipped.txt +++ b/Source/Core/PublicAPI.Unshipped.txt @@ -71,6 +71,63 @@ static Open.Database.Extensions.Core.QueryResultExtensions.DequeueAs(this Sys static Open.Database.Extensions.Core.QueryResultExtensions.DequeueAsMappedDictionaries(this Open.Database.Extensions.Core.QueryResult!>! source) -> System.Collections.Generic.IEnumerable!>! static Open.Database.Extensions.Core.QueryResultExtensions.DequeueAsMappedDictionaries(this System.Threading.Tasks.Task!>!>! source) -> System.Threading.Tasks.ValueTask!>!> static Open.Database.Extensions.Core.QueryResultExtensions.DequeueAsMappedDictionaries(this System.Threading.Tasks.ValueTask!>!> source) -> System.Threading.Tasks.ValueTask!>!> +*REMOVED*static Open.Database.Extensions.CoreExtensions.Map(this Open.Database.Extensions.IExecuteReader! command, Open.Database.Extensions.RowMap! map) -> Open.Database.Extensions.MappedQuery +static Open.Database.Extensions.CoreExtensions.Map(this Open.Database.Extensions.IExecuteReader! command, Open.Database.Extensions.RowMap? map) -> Open.Database.Extensions.MappedQuery +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDataReader! reader) -> Open.Database.Extensions.MappedReader +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDataReader! reader, Open.Database.Extensions.RowMap? map) -> Open.Database.Extensions.MappedReader +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDataReader! reader, System.Collections.Generic.KeyValuePair mapping) -> Open.Database.Extensions.MappedReader +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDataReader! reader, System.Func! selector) -> Open.Database.Extensions.MappedReader +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDataReader! reader, params System.ReadOnlySpan> mappings) -> Open.Database.Extensions.MappedReader +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbCommand! command) -> Open.Database.Extensions.MappedCommand +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbCommand! command, Open.Database.Extensions.RowMap? map) -> Open.Database.Extensions.MappedCommand +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 +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 +Open.Database.Extensions.MappedReader.MappedReader() -> void +Open.Database.Extensions.MappedReader.Read(System.Action!>! handler) -> void +Open.Database.Extensions.MappedReader.Read(System.Func!, TResult>! handler) -> TResult +Open.Database.Extensions.MappedReader.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedReader.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedReader.SingleOrDefault() -> T? +Open.Database.Extensions.MappedReader.SingleOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedReader.Skip(int count) -> Open.Database.Extensions.MappedQueryWithSkip +Open.Database.Extensions.MappedReader.Take(int count) -> Open.Database.Extensions.MappedQueryWithTake +Open.Database.Extensions.MappedReader.ToArray() -> T[]! +Open.Database.Extensions.MappedReader.ToArrayAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedReader.ToDictionary(System.Func! keySelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary! +Open.Database.Extensions.MappedReader.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.MappedReader.ToHashSet(System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.HashSet! +Open.Database.Extensions.MappedReader.ToHashSetAsync(System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedReader.ToImmutableArray() -> System.Collections.Immutable.ImmutableArray +Open.Database.Extensions.MappedReader.ToImmutableArrayAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> +Open.Database.Extensions.MappedReader.ToList() -> System.Collections.Generic.List! +Open.Database.Extensions.MappedReader.ToListAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedCommand +Open.Database.Extensions.MappedCommand.FirstOrDefault() -> T? +Open.Database.Extensions.MappedCommand.FirstOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.MappedCommand() -> void +Open.Database.Extensions.MappedCommand.Read(System.Action!>! handler) -> void +Open.Database.Extensions.MappedCommand.Read(System.Func!, TResult>! handler) -> TResult +Open.Database.Extensions.MappedCommand.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.SingleOrDefault() -> T? +Open.Database.Extensions.MappedCommand.SingleOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.SingleResult() -> Open.Database.Extensions.MappedCommand +Open.Database.Extensions.MappedCommand.Skip(int count) -> Open.Database.Extensions.MappedQueryWithSkip +Open.Database.Extensions.MappedCommand.Take(int count) -> Open.Database.Extensions.MappedQueryWithTake +Open.Database.Extensions.MappedCommand.ToArray() -> T[]! +Open.Database.Extensions.MappedCommand.ToArrayAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.ToDictionary(System.Func! keySelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary! +Open.Database.Extensions.MappedCommand.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.MappedCommand.ToHashSet(System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.HashSet! +Open.Database.Extensions.MappedCommand.ToHashSetAsync(System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedCommand.ToImmutableArray() -> System.Collections.Immutable.ImmutableArray +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!> *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 3b663bb..52154e6 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/DeprecationTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/DeprecationTests.cs @@ -101,14 +101,14 @@ public static void Results_AndMapToList_GiveTheSameRows() } [Fact] - public static void ANullMap_IsAcceptedByResults_ButNotByMap() + public static void ANullMap_IsAcceptedByResults_AndByMap() { FakeExecuteReader command = Command(1); #pragma warning disable CS0618 // Obsolete in 11.0, removed in 12: kept covered until then. Assert.Single(command.Results((RowMap?)null)); #pragma warning restore CS0618 - Assert.Throws(() => command.Map((RowMap)null!)); + Assert.Single(command.Map((RowMap?)null).ToList()); } // ---- group 2 ---- @@ -240,13 +240,13 @@ public static void RetrieveThenDequeueAs_AndMapToList_GiveTheSameRows() } [Fact] - public static void ANullMap_IsAcceptedByDequeueAs_ButNotByMap() + public static void ANullMap_IsAcceptedByDequeueAs_AndByMap() { FakeExecuteReader command = Command(1); #pragma warning disable CS0618 // Obsolete in 11.0, removed in 12: kept covered until then. Assert.Single(command.Retrieve().DequeueAs(null)); #pragma warning restore CS0618 - Assert.Throws(() => command.Map((RowMap)null!)); + Assert.Single(command.Map((RowMap?)null).ToList()); } } diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewBindingTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewBindingTests.cs new file mode 100644 index 0000000..702bf7c --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewBindingTests.cs @@ -0,0 +1,102 @@ +#nullable enable + +namespace Open.Database.Extensions.Tests; + +// Compile-only: every call form of the new Map on IDataReader and +// IDbCommand binds to the type it returns, +// and the calls that existed bind as they did. +// Each call is pinned to the type it returns, +// so a change of binding breaks the build. +// +// A type that is both an IExecuteReader and an IDataReader (or IDbCommand) +// would make reader.Map() ambiguous (CS0121). +// None exists in this library, and none of the expressive commands +// implements IDbCommand or IDataReader (checked by hand; +// a test cannot assert a compile error). +// The reader extensions have no Map of their own, +// so reader.Map(selector) has nothing else to bind to. +[ExcludeFromCodeCoverage] +public static class MappedViewBindingTests +{ + sealed record Item(int Id); + + static void Pin(T _) { } + + static int Id(IDataRecord r) + => r.GetInt32(0); + + static readonly Action< + IDataReader, + IDbCommand, + System.Data.Common.DbCommand, + ExpressiveDbCommand, + IExecuteReader, + RowMap> Table = static (reader, command, dbCommand, expressive, exec, map) => + { + // The reader. + Pin>(reader.Map()); + Pin>(reader.Map(map)); + Pin>(reader.Map((RowMap?)null)); + Pin>(reader.Map(null)); + Pin>(reader.Map()); + Pin>(reader.Map(Id)); + Pin>(reader.Map(r => r.GetInt32(0))); + Pin>( + reader.Map(new KeyValuePair("Id", "N"))); + Pin>( + reader.Map( + new KeyValuePair("Id", "N"), + new KeyValuePair("Id", "M"))); + Pin>(reader.Map([])); + + // The command, and a DbCommand, which is also an IDbCommand. + Pin>(command.Map()); + Pin>(command.Map(map)); + Pin>(command.Map((RowMap?)null)); + Pin>(command.Map(null)); + Pin>(command.Map()); + Pin>(command.Map(Id)); + Pin>( + command.Map(new KeyValuePair("Id", "N"))); + Pin>( + command.Map( + new KeyValuePair("Id", "N"), + new KeyValuePair("Id", "M"))); + Pin>(dbCommand.Map()); + Pin>(dbCommand.Map(Id)); + + // What existed still binds as it did. + Pin>(exec.Map()); + Pin>(exec.Map(map)); + Pin>(exec.Map((RowMap?)null)); + Pin>(exec.Map(null)); + Pin>(exec.Map(Id)); + Pin>(expressive.Map()); + Pin>(expressive.Map(map)); + Pin>(expressive.Map(r => r.GetInt32(0))); + }; + + [Fact] + public static void TheTableCompiles() + => Assert.NotNull(Table); + + // A bare null: which overload takes it is pinned by what it does. + // It reads like the default map, by column name. + [Fact] + public static void ABareNull_ReadsLikeTheDefaultMap() + { + static IDataReader Rd() => ReaderFixtures.Reader([1, "a", null]); + + Assert.Equal(1, Rd().Map(null).FirstOrDefault()!.Id); + Assert.Equal( + 1, + new FakeExecuteReader(() => ReaderFixtures.Reader([1, "a", null])) + .Map(null) + .FirstOrDefault()!.Id); + Assert.Equal( + 1, + new FakeDbCommand(new FakeDbConnection(), () => ReaderFixtures.Reader([1, "a", null])) + .Map(null) + .FirstOrDefault()!.Id); + } +} diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs new file mode 100644 index 0000000..8e1da06 --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs @@ -0,0 +1,439 @@ +using System.Data.Common; +using static Open.Database.Extensions.Tests.ReaderFixtures; + +#nullable enable + +namespace Open.Database.Extensions.Tests; + +// The reader view never opens, closes or disposes the caller's reader +// and never calls NextResult on it. +// The command view opens a closed connection and closes (not disposes) +// what it opened, leaves an open one open, +// and reads through the later result sets unless SingleResult() was called. +[ExcludeFromCodeCoverage] +public static class MappedViewOwnershipTests +{ + sealed record Row(int Id, string? Name); + + static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Counts what the library does to a reader it does not own. + sealed class CountingReader(object?[][] rows) + : TypedReader(Columns, rows) + { + public int NextResults { get; private set; } + public int Closes { get; private set; } + public int Disposes { get; private set; } + + public override bool NextResult() + { + NextResults++; + return false; + } + + public override Task NextResultAsync(CancellationToken cancellationToken) + { + NextResults++; + return Task.FromResult(false); + } + + public override void Close() + => Closes++; + + protected override void Dispose(bool disposing) + { + if (disposing) Disposes++; + base.Dispose(disposing); + } + + public void AssertUntouched() + { + Assert.Equal(0, NextResults); + Assert.Equal(0, Closes); + Assert.Equal(0, Disposes); + } + } + + public static TheoryData Terminals + => [ + "ToList", "ToArray", "FirstOrDefault", "SingleOrDefault", + "ToDictionary", "ToHashSet", "Read", "Skip", "Take"]; + + // SingleOrDefault throws on a second row, as on a MappedQuery, + // so it reads a one-row set. + static object?[][] RowsFor(string terminal) + => Rows(terminal == "SingleOrDefault" ? 1 : 3); + + static void Sync(MappedReader q, string terminal) + { + switch (terminal) + { + case "ToList": q.ToList(); break; + case "ToArray": q.ToArray(); break; + case "FirstOrDefault": q.FirstOrDefault(); break; + case "SingleOrDefault": q.SingleOrDefault(); break; + case "ToDictionary": q.ToDictionary(r => r.Id); break; + case "ToHashSet": q.ToHashSet(); break; + case "Read": q.Read(items => items.Count()); break; + case "Skip": q.Skip(1).ToList(); break; + case "Take": q.Take(1).ToList(); break; + default: throw new ArgumentOutOfRangeException(nameof(terminal)); + } + } + + static async Task Async(MappedReader q, string terminal) + { + switch (terminal) + { + case "ToList": await q.ToListAsync(Ct); break; + case "ToArray": await q.ToArrayAsync(Ct); break; + case "FirstOrDefault": await q.FirstOrDefaultAsync(Ct); break; + case "SingleOrDefault": await q.SingleOrDefaultAsync(Ct); break; + case "ToDictionary": await q.ToDictionaryAsync(r => r.Id, null, Ct); break; + case "ToHashSet": await q.ToHashSetAsync(null, Ct); break; + case "Read": + await q.ReadAsync( + async (items, t) => + { + await foreach (Row _ in items.WithCancellation(t)) { } + }, + Ct); + break; + case "Skip": await q.Skip(1).ToListAsync(Ct); break; + case "Take": await q.Take(1).ToListAsync(Ct); break; + default: throw new ArgumentOutOfRangeException(nameof(terminal)); + } + } + + [Theory, MemberData(nameof(Terminals))] + public static void TheReaderView_NeverCallsNextResultCloseOrDispose( + string terminal) + { + using var reader = new CountingReader(RowsFor(terminal)); + Sync(reader.Map(), terminal); + reader.AssertUntouched(); + } + + [Theory, MemberData(nameof(Terminals))] + public static async Task TheReaderView_NeverCallsNextResultCloseOrDispose_Async( + string terminal) + { + using var reader = new CountingReader(RowsFor(terminal)); + await Async(reader.Map(), terminal); + reader.AssertUntouched(); + } + + [Fact] + public static void TheReaderView_ReadsFromTheCurrentPosition() + { + using var reader = new CountingReader(Rows(3)); + Assert.True(reader.Read()); + + Assert.Equal([2, 3], reader.Map().ToList().Select(r => r.Id)); + reader.AssertUntouched(); + } + + [Fact] + public static void TheReaderView_DoesNotReadThroughALaterSet_EvenWhenItFails() + { + var reader = new MultiSetReader(Rows(2), 2, failAtCall: 1); + + Assert.Equal(2, reader.Map().ToList().Count); + Assert.Equal(0, reader.NextResultCalls); + } + + [Fact] + public static async Task TheReaderView_DoesNotReadThroughALaterSet_Async() + { + var reader = new MultiSetReader(Rows(2), 2, failAtCall: 1); + + Assert.Equal(2, (await reader.Map().ToListAsync(Ct)).Count); + Assert.Equal(0, reader.NextResultCalls); + } + + [Fact] + public static void TheReaderView_WorksOnAReaderThatIsNotADbDataReader() + { + IDataReader reader = FakeReader.Create(["Id", "Name"], [1, "a"], [2, null]); + + Assert.Equal(2, reader.Map().ToList().Count); + reader.DidNotReceive().NextResult(); + reader.DidNotReceive().Close(); + reader.DidNotReceive().Dispose(); + } + + [Fact] + public static async Task TheReaderView_WorksOnAReaderThatIsNotADbDataReader_Async() + { + IDataReader reader = FakeReader.Create(["Id", "Name"], [1, "a"], [2, null]); + + Assert.Equal(2, (await reader.Map().ToListAsync(Ct)).Count); + reader.DidNotReceive().NextResult(); + reader.DidNotReceive().Dispose(); + } + + // ---- the command view ---- + + static (FakeDbConnection Connection, FakeDbCommand Command) Command( + ConnectionState state = ConnectionState.Closed, + Func? reader = null) + { + var connection = new FakeDbConnection(); + connection.SetState(state); + return (connection, new FakeDbCommand(connection, reader ?? (() => Reader(Rows(2))))); + } + + [Fact] + public static void AClosedConnection_IsOpenedThenClosed() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + + Assert.Equal(2, command.Map().ToList().Count); + + Assert.Equal(["open", "close"], connection.Log); + Assert.Equal(ConnectionState.Closed, connection.State); + } + + [Fact] + public static async Task AClosedConnection_IsOpenedThenClosed_Async() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + + Assert.Equal(2, (await command.Map().ToListAsync(Ct)).Count); + + Assert.Equal(["open", "close"], connection.Log); + } + + [Fact] + public static void AnOpenConnection_IsLeftOpen() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(ConnectionState.Open); + + Assert.Equal(2, command.Map().ToList().Count); + Assert.Equal(ConnectionState.Open, connection.State); + Assert.Empty(connection.Log); + } + + [Fact] + public static async Task AnOpenConnection_IsLeftOpen_Async() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(ConnectionState.Open); + + Assert.Equal(2, (await command.Map().ToListAsync(Ct)).Count); + Assert.Equal(ConnectionState.Open, connection.State); + Assert.Empty(connection.Log); + } + + [Fact] + public static void AConnectionTheViewOpened_IsClosedWhenTheHandlerThrows() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + + Assert.Throws( + () => command.Map().Read(_ => throw new InvalidOperationException())); + + Assert.Equal(["open", "close"], connection.Log); + } + + [Fact] + public static async Task AConnectionTheViewOpened_IsClosedWhenTheHandlerThrows_Async() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + + await Assert.ThrowsAsync( + async () => await command.Map().ReadAsync( + (_, _) => throw new InvalidOperationException(), + Ct)); + + Assert.Equal(["open", "close"], connection.Log); + } + + [Fact] + public static void TheConnectionAndTheCommand_AreNeverDisposed() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + command.Map().ToList(); + + // Both are still usable: a second terminal runs on the same pair. + Assert.Equal(2, command.Map().ToList().Count); + Assert.Equal(["open", "close", "open", "close"], connection.Log); + } + + [Fact] + public static void ACommandWithoutAReaderThatIsADbCommand_RunsSynchronously() + { + var probe = new ConnectionProbe + { + ReaderFactory = () => FakeReader.Create(["Id", "Name"], [1, "a"]), + }; + + Assert.Single(probe.Command.Map().ToList()); + + Assert.Equal("Open,ExecuteReader,DisposeReader,Close", probe.Log); + Assert.Equal( + CommandBehavior.SequentialAccess | CommandBehavior.CloseConnection, + probe.Behaviors.Single()); + } + + [Fact] + public static async Task ACommandThatIsNotADbCommand_RunsAsyncTerminalsToo() + { + var probe = new ConnectionProbe(ConnectionState.Open) + { + ReaderFactory = () => FakeReader.Create(["Id", "Name"], [1, "a"]), + }; + + Assert.Single(await probe.Command.Map().ToListAsync(Ct)); + + Assert.Equal("ExecuteReader,DisposeReader", probe.Log); + Assert.Equal(CommandBehavior.SequentialAccess, probe.Behaviors.Single()); + } + + // ---- behaviors and read-through ---- + + [Fact] + public static void TheBehaviors_AreThoseOfAnExpressiveCommandsMappedQuery() + { + (_, FakeDbCommand closed) = Command(); + closed.Map().ToList(); + closed.Map().SingleResult().ToList(); + closed.Map().SingleResult().FirstOrDefault(); + closed.Map(r => r.GetInt32(0)).ToList(); + closed.Map(r => r.GetInt32(0)).SingleResult().ToList(); + + Assert.Equal( + [ + CommandBehavior.SequentialAccess | CommandBehavior.CloseConnection, + CommandBehavior.SingleResult | CommandBehavior.SequentialAccess + | CommandBehavior.CloseConnection, + CommandBehavior.SingleResult | CommandBehavior.SequentialAccess + | CommandBehavior.SingleRow | CommandBehavior.CloseConnection, + CommandBehavior.CloseConnection, + CommandBehavior.SingleResult | CommandBehavior.CloseConnection, + ], + closed.Behaviors); + + (_, FakeDbCommand open) = Command(ConnectionState.Open); + open.Map().ToList(); + open.Map(r => r.GetInt32(0)).ToList(); + + Assert.Equal( + [CommandBehavior.SequentialAccess, CommandBehavior.Default], + open.Behaviors); + } + + [Fact] + public static void TheCommandView_ReadsThroughTheLaterSets() + { + MultiSetReader? reader = null; + (_, FakeDbCommand command) = Command( + reader: () => reader = new MultiSetReader(Rows(1), 2)); + + command.Map().ToList(); + + Assert.Equal(3, reader!.NextResultCalls); + Assert.Equal(0, reader.ReadsAfterNextResult); + } + + [Fact] + public static async Task TheCommandView_ReadsThroughTheLaterSets_Async() + { + MultiSetReader? reader = null; + (_, FakeDbCommand command) = Command( + reader: () => reader = new MultiSetReader(Rows(1), 2)); + + await command.Map().ToListAsync(Ct); + + Assert.Equal(3, reader!.NextResultCalls); + Assert.Equal(0, reader.ReadsAfterNextResult); + } + + [Fact] + public static void TheCommandView_ThrowsALaterFailure_AndClosesTheConnection() + { + (FakeDbConnection connection, FakeDbCommand command) = Command( + reader: () => new MultiSetReader(Rows(1), 2, failAtCall: 2)); + + Assert.Throws(() => command.Map().ToList()); + + Assert.Equal(["open", "close"], connection.Log); + } + + [Fact] + public static async Task TheCommandView_ThrowsALaterFailure_Async() + { + (FakeDbConnection connection, FakeDbCommand command) = Command( + reader: () => new MultiSetReader(Rows(1), 2, failAtCall: 2)); + + await Assert.ThrowsAsync( + async () => await command.Map().ToListAsync(Ct)); + + Assert.Equal(["open", "close"], connection.Log); + } + + [Fact] + public static void SingleResult_ReadsNothingThrough() + { + MultiSetReader? reader = null; + (_, FakeDbCommand command) = Command( + reader: () => reader = new MultiSetReader(Rows(1), 2, failAtCall: 1)); + + Assert.Single(command.Map().SingleResult().ToList()); + Assert.Equal(0, reader!.NextResultCalls); + } + + [Fact] + public static async Task SingleResult_ReadsNothingThrough_Async() + { + MultiSetReader? reader = null; + (_, FakeDbCommand command) = Command( + reader: () => reader = new MultiSetReader(Rows(1), 2, failAtCall: 1)); + + Assert.Single(await command.Map().SingleResult().ToListAsync(Ct)); + Assert.Equal(0, reader!.NextResultCalls); + } + + [Fact] + public static async Task ACanceledToken_ThrowsBeforeAnythingOpens() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await command.Map().ToListAsync(cts.Token)); + await Assert.ThrowsAnyAsync( + async () => await Reader(Rows(1)).Map().ToListAsync(cts.Token)); + + Assert.Empty(connection.Log); + } + + // SingleResult(), reached through Skip or Take, is a no-op on a reader view: + // it already reads only the current result set. + [Fact] + public static void SingleResultThroughSkipAndTake_IsANoOp_OnAReaderView() + { + using var reader = new CountingReader(Rows(3)); + + Assert.Equal( + [2, 3], + reader.Map().Skip(1).SingleResult().ToList().Select(r => r.Id)); + reader.AssertUntouched(); + + using var other = new CountingReader(Rows(3)); + Assert.Single(other.Map().Take(1).SingleResult().ToList()); + Assert.Single(other.Map().Skip(1).Take(1).SingleResult().ToList()); + other.AssertUntouched(); + } + + [Fact] + public static async Task SingleResultThroughSkipAndTake_IsANoOp_OnAReaderView_Async() + { + using var reader = new CountingReader(Rows(3)); + + Assert.Equal( + 2, + (await reader.Map().Skip(1).SingleResult().ToListAsync(Ct)).Count); + reader.AssertUntouched(); + } +} diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs new file mode 100644 index 0000000..fefbce6 --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs @@ -0,0 +1,647 @@ +using System.Collections; +using System.Data.Common; +using static Open.Database.Extensions.Tests.ReaderFixtures; + +#nullable enable + +namespace Open.Database.Extensions.Tests; + +// reader.Map() and command.Map() give the same results as +// Map() on an expressive command, terminal by terminal, +// in their synchronous and asynchronous forms. +[ExcludeFromCodeCoverage] +public static class MappedViewParityTests +{ + internal sealed record Person + { + public required int Id { get; init; } + public string? Name { get; init; } + public string? Note { get; init; } + } + + static CancellationToken Ct => TestContext.Current.CancellationToken; + + static readonly string[] Terminals = + [ + "ToList", "ToArray", "ToImmutableArray", "FirstOrDefault", + "SingleOrDefault", "ToDictionary", "ToHashSet", "Read", "ReadResult", + "Skip", "Take", "SkipTake", + ]; + + static readonly string[] Sets = ["three", "nulls", "one", "empty"]; + + public static TheoryData Cases + { + get + { + TheoryData data = []; + foreach (string set in Sets) + { + foreach (string terminal in Terminals) + { + data.Add(set, terminal); + } + } + + return data; + } + } + + static object?[][] Data(string set) + => set switch + { + "three" => Rows(3), + "nulls" => TwoRows, + "one" => Rows(1), + "empty" => [], + _ => throw new ArgumentOutOfRangeException(nameof(set)), + }; + + static MappedQuery Baseline(object?[][] rows) + => new FakeExecuteReader(() => Reader(rows)).Map(); + + static MappedReader ReaderView(object?[][] rows) + => Reader(rows).Map(); + + static MappedCommand CommandView(object?[][] rows) + => new FakeDbCommand(new FakeDbConnection(), () => Reader(rows)) + .Map(); + + static string Capture(Func run) + { + try + { + return Show(run()); + } + catch (Exception ex) + { + return ex.GetType().Name; + } + } + + static string Show(object? value) + => value switch + { + null => "null", + Person p => $"{p.Id}/{p.Name ?? "~"}/{p.Note ?? "~"}", + IDictionary d => string.Join( + ",", + d.Keys.Cast().Select(k => $"{k}:{Show(d[k])}")), + IEnumerable e => "[" + string.Join( + ",", + e.Cast().Select(Show)) + "]", + _ => value.ToString() ?? "", + }; + + [Theory, MemberData(nameof(Cases))] + public static void EveryTerminal_GivesTheSameResultAsAMappedQuery( + string set, + string terminal) + { + object?[][] rows = Data(set); + string expected = Run(Baseline(rows), terminal); + + Assert.Equal(expected, Run(ReaderView(rows), terminal)); + Assert.Equal(expected, Run(CommandView(rows), terminal)); + } + + [Theory, MemberData(nameof(Cases))] + public static async Task EveryAsyncTerminal_GivesTheSameResultAsAMappedQuery( + string set, + string terminal) + { + object?[][] rows = Data(set); + string expected = await RunAsync(Baseline(rows), terminal, Ct); + + Assert.Equal(expected, await RunAsync(ReaderView(rows), terminal, Ct)); + Assert.Equal(expected, await RunAsync(CommandView(rows), terminal, Ct)); + } + + [Fact] + public static void TheBaselineIsNotEmpty() + { + Assert.Equal("[1/n1/~,2/n2/~,3/n3/~]", Run(Baseline(Rows(3)), "ToList")); + Assert.Equal("[1/a/n1,2/~/n2]", Run(ReaderView(TwoRows), "ToList")); + Assert.Equal("null", Run(CommandView([]), "FirstOrDefault")); + Assert.Equal("[2/n2/~]", Run(ReaderView(Rows(3)), "SkipTake")); + } + + // A single value reads the first column, as Map() on a command does. + [Fact] + public static void ASingleValue_ReadsColumnZero() + { + ImmutableArray columns = [("V", typeof(string))]; + object?[][] rows = [["a"], [null], ["c"]]; + static TypedReader Rd(ImmutableArray c, object?[][] r) + => TypedReader.Create(c, r); + string?[] expected = ["a", null, "c"]; + + Assert.Equal(expected, Rd(columns, rows).Map().ToList()); + Assert.Equal( + expected, + new FakeDbCommand(new FakeDbConnection(), () => Rd(columns, rows)) + .Map() + .ToList()); + Assert.Equal( + expected, + new FakeExecuteReader(() => Rd(columns, rows)) + .Map() + .ToList()); + + ImmutableArray ints = [("N", typeof(int))]; + Assert.Equal( + [1, 2], + Rd(ints, [[1], [2]]).Map().ToArray()); + Assert.Equal( + 2, + Rd(ints, [[1], [2]]).Map().Skip(1).FirstOrDefault()); + } + + [Fact] + public static async Task ASingleValue_ReadsColumnZero_Async() + { + ImmutableArray columns = [("V", typeof(string))]; + object?[][] rows = [["a"], [null]]; + + string?[] expected = ["a", null]; + Assert.Equal( + expected, + await TypedReader.Create(columns, rows).Map().ToListAsync(Ct)); + Assert.Equal( + expected, + await new FakeDbCommand( + new FakeDbConnection(), + () => TypedReader.Create(columns, rows)) + .Map() + .ToListAsync(Ct)); + } + + // The selector and the RowMap overloads, on both views. + [Fact] + public static async Task TheOtherOverloads_MapOnBothViews() + { + static TypedReader Rd() => Reader(Rows(2)); + static FakeDbCommand Cmd() + => new(new FakeDbConnection(), () => Reader(Rows(2))); + RowMap map = new( + new KeyValuePair(nameof(Person.Note), "Name"), + new KeyValuePair(nameof(Person.Name), null)); + + Assert.Equal([1, 2], Rd().Map(r => r.GetInt32(0)).ToList()); + Assert.Equal([1, 2], Cmd().Map(r => r.GetInt32(0)).ToList()); + Assert.Equal([1, 2], await Cmd().Map(r => r.GetInt32(0)).ToListAsync(Ct)); + + Assert.Equal("n1", Rd().Map(map).FirstOrDefault()!.Note); + Assert.Equal("n1", Cmd().Map(map).FirstOrDefault()!.Note); + Assert.Equal( + "n2", + Rd().Map(new KeyValuePair("Note", null)) + .Skip(1) + .FirstOrDefault()!.Name); + Assert.Equal( + "n1", + Cmd().Map( + new KeyValuePair("Note", "Name"), + new KeyValuePair("Name", "Note")) + .FirstOrDefault()!.Note); + + // A null map reads like the default one. + Assert.Equal("n1", Rd().Map((RowMap?)null).FirstOrDefault()!.Name); + Assert.Equal("n1", Cmd().Map((RowMap?)null).FirstOrDefault()!.Name); + Assert.Equal( + "n1", + new FakeExecuteReader(() => Reader(Rows(1))) + .Map((RowMap?)null) + .FirstOrDefault()!.Name); + } + + [Fact] + public static void ANullReceiverOrSelector_Throws() + { + IDataReader? reader = null; + IDbCommand? command = null; + RowMap map = RowMap.Default; + + Assert.Throws(() => reader!.Map()); + Assert.Throws(() => reader!.Map(map)); + Assert.Throws( + () => reader!.Map(new KeyValuePair("Id", "Id"))); + Assert.Throws(() => reader!.Map(r => 1)); + Assert.Throws(() => command!.Map()); + Assert.Throws(() => command!.Map(map)); + Assert.Throws(() => command!.Map(r => 1)); + Assert.Throws( + () => Reader(Rows(1)).Map((Func)null!)); + Assert.Throws( + () => new FakeDbCommand(new FakeDbConnection(), () => Reader(Rows(1))) + .Map((Func)null!)); + } + + // A default struct of the whole family says it was not created by + // Map(): InvalidOperationException, never NullReferenceException. + [Fact] + public static void ADefaultStruct_ThrowsInvalidOperation_OnEveryMember() + { + static void Throws(Action use) + { + InvalidOperationException ex + = Assert.Throws(use); + Assert.Contains("Map()", ex.Message); + } + + // An async terminal reports it synchronously or as a faulted task. + static void Fails(Func> use) + { + InvalidOperationException ex = Assert.Throws( + () => use().AsTask().GetAwaiter().GetResult()); + Assert.Contains("Map()", ex.Message); + } + + static void FailsPlain(Func use) + { + InvalidOperationException ex = Assert.Throws( + () => use().AsTask().GetAwaiter().GetResult()); + Assert.Contains("Map()", ex.Message); + } + + static ValueTask H1(IAsyncEnumerable items, CancellationToken token) + => default; + + static ValueTask H2(IAsyncEnumerable items, CancellationToken token) + => new(0); + + static void All(TQuery q, Action more) + where TQuery : IMappedQuery + { + Throws(() => q.Read(_ => { })); + Throws(() => q.Read(items => items.Count())); + FailsPlain(() => q.ReadAsync(H1, default)); + Fails(() => q.ReadAsync(H2, default)); + more(q); + } + + Throws(() => default(MappedQuery).ToList()); + All(default(MappedQuery), q => + { + Throws(() => q.ToList()); + Throws(() => q.ToArray()); + Throws(() => q.FirstOrDefault()); + Throws(() => q.SingleOrDefault()); + Throws(() => q.ToImmutableArray()); + Throws(() => q.ToDictionary(i => i)); + Throws(() => q.ToHashSet()); + Fails(() => q.ToListAsync()); + Fails(() => q.ToArrayAsync()); + Fails(() => q.FirstOrDefaultAsync()); + Fails(() => q.SingleOrDefaultAsync()); + Fails(() => q.ToImmutableArrayAsync()); + Fails(() => q.ToDictionaryAsync(i => i)); + Fails(() => q.ToHashSetAsync()); + Throws(() => q.Skip(1)); + Throws(() => q.Take(1)); + Throws(() => q.SingleResult()); + }); + All(default(MappedQueryWithSkip), q => + { + Throws(() => q.ToList()); + Fails(() => q.ToListAsync()); + Throws(() => q.Take(1)); + Throws(() => q.SingleResult()); + }); + All(default(MappedQueryWithTake), q => + { + Throws(() => q.ToList()); + Fails(() => q.FirstOrDefaultAsync()); + Throws(() => q.SingleResult()); + }); + All(default(MappedReader), q => + { + Throws(() => q.ToList()); + Throws(() => q.ToArray()); + Throws(() => q.FirstOrDefault()); + Throws(() => q.SingleOrDefault()); + Throws(() => q.ToImmutableArray()); + Throws(() => q.ToDictionary(i => i)); + Throws(() => q.ToHashSet()); + Fails(() => q.ToListAsync()); + Fails(() => q.ToArrayAsync()); + Fails(() => q.FirstOrDefaultAsync()); + Fails(() => q.SingleOrDefaultAsync()); + Fails(() => q.ToImmutableArrayAsync()); + Fails(() => q.ToDictionaryAsync(i => i)); + Fails(() => q.ToHashSetAsync()); + Throws(() => q.Skip(1)); + Throws(() => q.Take(1)); + }); + All(default(MappedCommand), q => + { + Throws(() => q.ToList()); + Throws(() => q.ToArray()); + Throws(() => q.FirstOrDefault()); + Throws(() => q.SingleOrDefault()); + Throws(() => q.ToImmutableArray()); + Throws(() => q.ToDictionary(i => i)); + Throws(() => q.ToHashSet()); + Fails(() => q.ToListAsync()); + Fails(() => q.ToArrayAsync()); + Fails(() => q.FirstOrDefaultAsync()); + Fails(() => q.SingleOrDefaultAsync()); + Fails(() => q.ToImmutableArrayAsync()); + Fails(() => q.ToDictionaryAsync(i => i)); + Fails(() => q.ToHashSetAsync()); + Throws(() => q.Skip(1)); + Throws(() => q.Take(1)); + Throws(() => q.SingleResult()); + }); + + MappedReader reader = default; + MappedCommand command = default; + Assert.Equal(reader, new MappedReader()); + Assert.Equal(command, new MappedCommand()); + } + + // No map, in every spelling, reads exactly as Map() does. + [Fact] + public static void NoMap_ReadsAsTheNoArgumentOverloadDoes() + { + ImmutableArray ints = [("N", typeof(int))]; + static TypedReader Ints(ImmutableArray c) + => TypedReader.Create(c, [1], [2]); + static TypedReader People() => Reader(Rows(2)); + static FakeDbCommand Cmd(Func reader) + => new(new FakeDbConnection(), reader); + + // A single value reads column 0. + Assert.Equal([1, 2], Ints(ints).Map(null).ToList()); + Assert.Equal([1, 2], Ints(ints).Map([]).ToList()); + Assert.Equal([1, 2], Ints(ints).Map((RowMap?)null).ToList()); + Assert.Equal([1, 2], Cmd(() => Ints(ints)).Map(null).ToList()); + Assert.Equal([1, 2], Cmd(() => Ints(ints)).Map([]).ToList()); + Assert.Equal( + [1, 2], + Cmd(() => Ints(ints)).Map((RowMap?)null).ToList()); + Assert.Equal( + [1, 2], + new FakeExecuteReader(() => Ints(ints)).Map(null).ToList()); + Assert.Equal( + [1, 2], + new FakeExecuteReader(() => Ints(ints)).Map([]).ToList()); + Assert.Equal( + [1, 2], + new FakeExecuteReader(() => Ints(ints)) + .Map((RowMap?)null) + .ToList()); + + // A row model reads by name. + string expected = Show(People().Map().ToList()); + Assert.Equal(expected, Show(People().Map(null).ToList())); + Assert.Equal(expected, Show(People().Map([]).ToList())); + Assert.Equal(expected, Show(Cmd(People).Map(null).ToList())); + Assert.Equal(expected, Show(Cmd(People).Map([]).ToList())); + Assert.Equal( + expected, + Show(new FakeExecuteReader(People).Map(null).ToList())); + Assert.Equal( + expected, + Show(new FakeExecuteReader(People).Map([]).ToList())); + } + + // ---- the generated terminals, once per type ---- + + static string Run(MappedQuery q, string terminal) + => Capture(() => terminal switch + { + "ToList" => q.ToList(), + "ToArray" => q.ToArray(), + "ToImmutableArray" => q.ToImmutableArray(), + "FirstOrDefault" => q.FirstOrDefault(), + "SingleOrDefault" => q.SingleOrDefault(), + "ToDictionary" => q.ToDictionary(p => p.Id), + "ToHashSet" => q.ToHashSet(), + "Read" => ReadAll(q), + "ReadResult" => q.Read(items => items.ToList()), + "Skip" => q.Skip(1).ToList(), + "Take" => q.Take(1).ToList(), + "SkipTake" => q.Skip(1).Take(1).ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }); + + static List ReadAll(MappedQuery q) + { + List list = []; + q.Read(items => list.AddRange(items)); + return list; + } + + static async Task RunAsync( + MappedQuery q, + string terminal, + CancellationToken ct) + { + try + { + object? result = terminal switch + { + "ToList" => await q.ToListAsync(ct), + "ToArray" => await q.ToArrayAsync(ct), + "ToImmutableArray" => await q.ToImmutableArrayAsync(ct), + "FirstOrDefault" => await q.FirstOrDefaultAsync(ct), + "SingleOrDefault" => await q.SingleOrDefaultAsync(ct), + "ToDictionary" => await q.ToDictionaryAsync(p => p.Id, null, ct), + "ToHashSet" => await q.ToHashSetAsync(null, ct), + "Read" => await ReadAllAsync(q, ct), + "ReadResult" => await q.ReadAsync( + async (items, token) => + { + List list = []; + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + return list; + }, + ct), + "Skip" => await q.Skip(1).ToListAsync(ct), + "Take" => await q.Take(1).ToListAsync(ct), + "SkipTake" => await q.Skip(1).Take(1).ToListAsync(ct), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }; + return Show(result); + } + catch (Exception ex) + { + return ex.GetType().Name; + } + } + + static async Task> ReadAllAsync( + MappedQuery q, + CancellationToken ct) + { + List list = []; + await q.ReadAsync( + async (items, token) => + { + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + }, + ct); + return list; + } + + static string Run(MappedReader q, string terminal) + => Capture(() => terminal switch + { + "ToList" => q.ToList(), + "ToArray" => q.ToArray(), + "ToImmutableArray" => q.ToImmutableArray(), + "FirstOrDefault" => q.FirstOrDefault(), + "SingleOrDefault" => q.SingleOrDefault(), + "ToDictionary" => q.ToDictionary(p => p.Id), + "ToHashSet" => q.ToHashSet(), + "Read" => ReadAll(q), + "ReadResult" => q.Read(items => items.ToList()), + "Skip" => q.Skip(1).ToList(), + "Take" => q.Take(1).ToList(), + "SkipTake" => q.Skip(1).Take(1).ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }); + + static List ReadAll(MappedReader q) + { + List list = []; + q.Read(items => list.AddRange(items)); + return list; + } + + static async Task RunAsync( + MappedReader q, + string terminal, + CancellationToken ct) + { + try + { + object? result = terminal switch + { + "ToList" => await q.ToListAsync(ct), + "ToArray" => await q.ToArrayAsync(ct), + "ToImmutableArray" => await q.ToImmutableArrayAsync(ct), + "FirstOrDefault" => await q.FirstOrDefaultAsync(ct), + "SingleOrDefault" => await q.SingleOrDefaultAsync(ct), + "ToDictionary" => await q.ToDictionaryAsync(p => p.Id, null, ct), + "ToHashSet" => await q.ToHashSetAsync(null, ct), + "Read" => await ReadAllAsync(q, ct), + "ReadResult" => await q.ReadAsync( + async (items, token) => + { + List list = []; + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + return list; + }, + ct), + "Skip" => await q.Skip(1).ToListAsync(ct), + "Take" => await q.Take(1).ToListAsync(ct), + "SkipTake" => await q.Skip(1).Take(1).ToListAsync(ct), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }; + return Show(result); + } + catch (Exception ex) + { + return ex.GetType().Name; + } + } + + static async Task> ReadAllAsync( + MappedReader q, + CancellationToken ct) + { + List list = []; + await q.ReadAsync( + async (items, token) => + { + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + }, + ct); + return list; + } + + static string Run(MappedCommand q, string terminal) + => Capture(() => terminal switch + { + "ToList" => q.ToList(), + "ToArray" => q.ToArray(), + "ToImmutableArray" => q.ToImmutableArray(), + "FirstOrDefault" => q.FirstOrDefault(), + "SingleOrDefault" => q.SingleOrDefault(), + "ToDictionary" => q.ToDictionary(p => p.Id), + "ToHashSet" => q.ToHashSet(), + "Read" => ReadAll(q), + "ReadResult" => q.Read(items => items.ToList()), + "Skip" => q.Skip(1).ToList(), + "Take" => q.Take(1).ToList(), + "SkipTake" => q.Skip(1).Take(1).ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }); + + static List ReadAll(MappedCommand q) + { + List list = []; + q.Read(items => list.AddRange(items)); + return list; + } + + static async Task RunAsync( + MappedCommand q, + string terminal, + CancellationToken ct) + { + try + { + object? result = terminal switch + { + "ToList" => await q.ToListAsync(ct), + "ToArray" => await q.ToArrayAsync(ct), + "ToImmutableArray" => await q.ToImmutableArrayAsync(ct), + "FirstOrDefault" => await q.FirstOrDefaultAsync(ct), + "SingleOrDefault" => await q.SingleOrDefaultAsync(ct), + "ToDictionary" => await q.ToDictionaryAsync(p => p.Id, null, ct), + "ToHashSet" => await q.ToHashSetAsync(null, ct), + "Read" => await ReadAllAsync(q, ct), + "ReadResult" => await q.ReadAsync( + async (items, token) => + { + List list = []; + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + return list; + }, + ct), + "Skip" => await q.Skip(1).ToListAsync(ct), + "Take" => await q.Take(1).ToListAsync(ct), + "SkipTake" => await q.Skip(1).Take(1).ToListAsync(ct), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }; + return Show(result); + } + catch (Exception ex) + { + return ex.GetType().Name; + } + } + + static async Task> ReadAllAsync( + MappedCommand q, + CancellationToken ct) + { + List list = []; + await q.ReadAsync( + async (items, token) => + { + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + }, + ct); + return list; + } +} diff --git a/docsite/articles/mapping.md b/docsite/articles/mapping.md index 78abb53..f1f7cbf 100644 --- a/docsite/articles/mapping.md +++ b/docsite/articles/mapping.md @@ -232,6 +232,18 @@ Every terminal has an `Async` twin that takes a `CancellationToken`: [!code-csharp[](../samples/Mapping.cs#map_dictionary_async)] +## Mapping a reader or a command you already have + +`Map()` also works on a plain `IDataReader` and on a plain `IDbCommand`. `reader.Map()` returns a `MappedReader` and `command.Map()` returns a `MappedCommand`. Both have the same overloads as the expressive command's `Map` (no argument, a `RowMap`, pairs, a selector) and the same terminals, synchronous and asynchronous, including `Skip`, `Take` and `Read`. The rules for mapping `T` are the ones above. + +On a reader, the reader is yours. A terminal never opens, closes or disposes it, and never calls `NextResult` on it: it reads the current result set from where the reader stands and stops. To map the next result set, call `NextResult` yourself and map again. + +[!code-csharp[](../samples/Mapping.cs#map_reader_view)] + +On a command, the terminal opens the connection only if it is closed, and closes it again (`Close`, not `Dispose`) only if it opened it, as an expressive command does. Like a `Map` on an expressive command, it reads through the later result sets, so a failure in a later statement is thrown, and `.SingleResult()` turns that off. The command is yours and is never disposed. The asynchronous terminals open and execute asynchronously when the command is a `DbCommand`. + +[!code-csharp[](../samples/Mapping.cs#map_command_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 dc16af7..f503c8f 100644 --- a/docsite/articles/whats-new-11.md +++ b/docsite/articles/whats-new-11.md @@ -12,6 +12,14 @@ Rebuild consumers of rc.3. Both changes are source-compatible for every call exc - `ExpressiveDbCommand.RetrieveAsync` takes a trailing `CancellationToken cancellationToken = default` on the three overloads that can: `RetrieveAsync(CancellationToken)`, `RetrieveAsync(IEnumerable, CancellationToken)` and `RetrieveAsync(IEnumerable, bool, CancellationToken)`. The token is used together with the command's own token. A `params` list must come last, so the two `params` overloads (`RetrieveAsync(int, params IEnumerable)` and `RetrieveAsync(string, params IEnumerable)`) are unchanged and take no token: pass the ordinals or names as a collection to the `IEnumerable` overload to cancel. A call that passed no token binds to the same overload as before, but the signatures are new, so rebuild. - Three parameters are renamed to say what they hold. This breaks only a caller that passes the argument by name: `CreateTextCommand(SqlTransaction, ...)` in `SqlClient` and `MSSqlClient` takes `commandText` (it was `procedureName`), `CreateStoredProcedureCommand(IDbConnection, ...)` in Core takes `procedureName` (it was `commandText`), and the type parameter `TConn` of the `Using` and `UsingAsync` connection helpers is now `TConnection`. The type-parameter rename breaks no caller. +## Changes since rc.3 + +- `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. +- A `default` `MappedQuery` (or `MappedQueryWithSkip`, `MappedQueryWithTake`) now throws `InvalidOperationException` when used, where it threw `NullReferenceException`. +- The `Map` documentation now says how columns match by default: exactly, else ignoring case, else ignoring case and underscores (`author_id` feeds `AuthorId`). + ## Changes since rc.2 - A `Map` terminal now reads through the later result sets after the first, so a failure in a later statement is thrown, where rc.2 returned the rows and lost it. This is a behavior change from rc.2. `Map` still returns only the first set's rows. `.SingleResult()` after `Map` stops after the first set and does not see a later failure. A later failure can follow a write that already committed, so a caller that retries can repeat it. The older command methods, Channel and Dataflow do not read through yet. diff --git a/docsite/samples/Mapping.cs b/docsite/samples/Mapping.cs index b00814d..ac05e6d 100644 --- a/docsite/samples/Mapping.cs +++ b/docsite/samples/Mapping.cs @@ -274,6 +274,38 @@ public static (Post? First, Post? Single) FirstAndSingle(SqliteConnection connec return (first, single); } + public static async Task MapReaderView( + SqliteConnection connection) + { + #region map_reader_view + // The reader is yours: Map never closes or disposes it, + // and never moves it to the next result set. + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "select * from posts order by id"; + using SqliteDataReader reader = await command.ExecuteReaderAsync(); + + Post[] posts = await reader.Map().ToArrayAsync(); + #endregion + + return posts; + } + + public static async Task MapCommandView( + SqliteConnection connection) + { + #region map_command_view + // The connection is opened if it is closed, + // and closed again only if this call opened it. + // The command is yours and is never disposed here. + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "select * from posts where author = 42 order by id"; + + Post[] posts = await command.Map().ToArrayAsync(); + #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 b043116..292e012 100644 --- a/docsite/samples/Program.cs +++ b/docsite/samples/Program.cs @@ -68,6 +68,16 @@ void Check(bool condition, string what) Check(PersonMaps.PersonNamesOnlyMap.Count() == 3 && PersonMaps.PersonNamesOnlyMap.Single(pair => pair.Value is null).Key == nameof(Person.LastName), "an ignored property is enumerated with a null column"); Person[] snake = Mapping.MapSnakeCase(connection); 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[] 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()) +{ + Check(closedConnection.State == ConnectionState.Closed, "a new connection is closed"); + Post[] viaClosed = await Mapping.MapCommandView(closedConnection); + Check(viaClosed.Length == 3 && closedConnection.State == ConnectionState.Closed, "command.Map() opens a closed connection and closes it again"); +} Person? exactFirst = connection.Command("select 'Alan' as first_name, 'Ada' as FirstName").Map().FirstOrDefault(); Person? exactSecond = connection.Command("select 'Ada' as FirstName, 'Alan' as first_name").Map().FirstOrDefault(); Check(exactFirst?.FirstName == "Ada" && exactSecond?.FirstName == "Ada", "an exact column beats an underscore match, in either column order");