diff --git a/Source/Core/Core/RowReader.cs b/Source/Core/Core/RowReader.cs index dc0c34e..cbc2605 100644 --- a/Source/Core/Core/RowReader.cs +++ b/Source/Core/Core/RowReader.cs @@ -224,6 +224,10 @@ static bool SkipRows(DbDataReader reader, int skip) static InvalidOperationException MoreThanOne() => new("Sequence contains more than one element."); + // For the throwing terminals (First, Single) when the query returned no row; thrown after the reader is cleaned up. + internal static InvalidOperationException NoRows(string terminal) + => new($"Sequence contains no elements: the query returned no rows, and {terminal} needs one."); + #endregion #region Asynchronous @@ -399,6 +403,13 @@ internal readonly struct DictionarySink(Dictionary dictionary, public void Add(T item) => Dictionary.Add(keySelector(item), item); } +internal readonly struct DictionarySink(Dictionary dictionary, Func keySelector, Func valueSelector) : IRowSink + where TKey : notnull +{ + public Dictionary Dictionary { get; } = dictionary; + public void Add(T item) => Dictionary.Add(keySelector(item), valueSelector(item)); +} + internal readonly struct HashSetSink(HashSet set) : IRowSink { public HashSet Set { get; } = set; diff --git a/Source/Core/Extensions/IExecuteReader.cs b/Source/Core/Extensions/IExecuteReader.cs index 118e343..409d84d 100644 --- a/Source/Core/Extensions/IExecuteReader.cs +++ b/Source/Core/Extensions/IExecuteReader.cs @@ -559,6 +559,39 @@ public static ValueTask> ToImmutableArrayAsync( CancellationToken cancellationToken = default) => command.Map().SingleOrDefaultAsync(cancellationToken); + /// + /// Reads the first row, asynchronously; throws when there is none. + /// Equivalent to command.Map<T>().FirstAsync(cancellationToken). + /// + /// + /// The command to read from. + /// A token combined with the command's own. + /// The first row. + /// + /// There is no row. + /// + public static ValueTask FirstAsync( + this IExecuteReader command, + CancellationToken cancellationToken = default) + => command.Map().FirstAsync(cancellationToken); + + /// + /// Reads the only row, asynchronously; throws when there is none + /// or more than one. + /// Equivalent to command.Map<T>().SingleAsync(cancellationToken). + /// + /// + /// The command to read from. + /// A token combined with the command's own. + /// The only row. + /// + /// There is no row, or more than one. + /// + public static ValueTask SingleAsync( + this IExecuteReader command, + CancellationToken cancellationToken = default) + => command.Map().SingleAsync(cancellationToken); + /// /// Reads every row into a hash set, asynchronously. /// Equivalent to command.Map<T>().ToHashSetAsync(comparer, cancellationToken). diff --git a/Source/Core/Extensions/MappedQueryExtensions.cs b/Source/Core/Extensions/MappedQueryExtensions.cs index ca45046..d2b2b6c 100644 --- a/Source/Core/Extensions/MappedQueryExtensions.cs +++ b/Source/Core/Extensions/MappedQueryExtensions.cs @@ -286,6 +286,93 @@ public static ValueTask> ToDictionaryAsync( cancellationToken); } + /// + /// Reads every mapped row into a new , + /// keyed by , with the value + /// returns for the row. + /// + /// The type each row is mapped to. + /// The type of the dictionary key. + /// The type of the dictionary value. + /// The query to execute. + /// Produces the key for each mapped row. + /// Produces the value for each mapped row. + /// The comparer for the keys, or to use the default comparer. + /// A dictionary of all the rows. It is empty if there are no rows. + /// or is . + /// Two rows produce the same key. + /// + /// + /// The dictionary is built in one pass: each selector is called once per row, key first, and no list of the rows is kept. + /// A duplicate key throws, as it does for and for LINQ's ToDictionary. + /// + /// + /// The selectors run while the reader is open, so keep them quick and do not touch the connection from inside them. + /// + /// + /// + public static Dictionary ToDictionary( + this IMappedQuery query, + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null) + where TKey : notnull + { + if (query is null) throw new ArgumentNullException(nameof(query)); + if (keySelector is null) throw new ArgumentNullException(nameof(keySelector)); + if (valueSelector is null) throw new ArgumentNullException(nameof(valueSelector)); + Contract.EndContractBlock(); + + return query.Read( + items => items.ToDictionary(keySelector, valueSelector, comparer)); + } + + /// + /// Asynchronously reads every mapped row into a new , + /// keyed by , with the value + /// returns for the row. + /// + /// The type each row is mapped to. + /// The type of the dictionary key. + /// The type of the dictionary value. + /// The query to execute. + /// Produces the key for each mapped row. + /// Produces the value for each mapped row. + /// The comparer for the keys, or to use the default comparer. + /// Cancels the call, together with the cancellation token set on the underlying command: either one cancels it, whether it is opening the connection, looking up stored-procedure parameter names, executing or reading. + /// A task that produces a dictionary of all the rows. + /// or is . + /// Two rows produce the same key. + /// The operation was canceled. + /// + /// + /// The dictionary is built in one pass: each selector is called once per row, key first, and no list of the rows is kept. + /// A duplicate key throws, as it does for . + /// + /// + /// The comparer comes before the token, as it does there: pass the token by name, + /// ToDictionaryAsync(keySelector, valueSelector, cancellationToken: ct). + /// + /// + /// + public static ValueTask> ToDictionaryAsync( + this IMappedQuery query, + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + where TKey : notnull + { + if (query is null) throw new ArgumentNullException(nameof(query)); + if (keySelector is null) throw new ArgumentNullException(nameof(keySelector)); + if (valueSelector is null) throw new ArgumentNullException(nameof(valueSelector)); + Contract.EndContractBlock(); + + return query.ReadAsync( + async (items, token) => await items.ToDictionaryAsync(keySelector, valueSelector, comparer, token).ConfigureAwait(false), + cancellationToken); + } + /// /// Reads every mapped row into a new . /// @@ -359,7 +446,13 @@ public static ValueTask> ToHashSetAsync(this IMappedQuery query /// The query to execute. /// The first row, or if the query returns no rows. /// + /// /// Rows after the first are not mapped. Add an ORDER BY to the SQL when "first" must be deterministic. + /// + /// + /// Called through , the types of this library behave exactly as their own members do, including how the later result sets are read through. + /// Another implementation of is read through its Read handler: it stops at the row, so it cannot read through the later result sets. + /// /// /// public static T? FirstOrDefault(this IMappedQuery query) @@ -367,6 +460,11 @@ public static ValueTask> ToHashSetAsync(this IMappedQuery query if (query is null) throw new ArgumentNullException(nameof(query)); Contract.EndContractBlock(); + if (query is IMappedTerminals terminals) + { + return terminals.FirstOrDefault(); + } + return query.Read(items => items.FirstOrDefault()); } @@ -379,13 +477,29 @@ public static ValueTask> ToHashSetAsync(this IMappedQuery query /// A task that produces the first row, or if the query returns no rows. /// The operation was canceled. /// + /// /// Rows after the first are not mapped. Add an ORDER BY to the SQL when "first" must be deterministic. + /// + /// + /// Called through , the types of this library behave exactly as their own members do, including how the later result sets are read through. + /// Another implementation of is read through its Read handler: it stops at the row, so it cannot read through the later result sets. + /// /// /// public static ValueTask FirstOrDefaultAsync(this IMappedQuery query, CancellationToken cancellationToken = default) - => query.ReadAsync( + { + if (query is null) throw new ArgumentNullException(nameof(query)); + Contract.EndContractBlock(); + + if (query is IMappedTerminals terminals) + { + return terminals.FirstOrDefaultAsync(cancellationToken); + } + + return query.ReadAsync( async (items, token) => await items.FirstOrDefaultAsync(token).ConfigureAwait(false), cancellationToken); + } /// /// Reads the only mapped row. @@ -394,9 +508,25 @@ public static ValueTask> ToHashSetAsync(this IMappedQuery query /// The query to execute. /// The single row, or if the query returns no rows. /// The query returns more than one row. + /// + /// + /// Called through , the types of this library behave exactly as their own members do, including how the later result sets are read through. + /// Another implementation of is read through its Read handler: it stops at the row, so it cannot read through the later result sets. + /// + /// /// public static T? SingleOrDefault(this IMappedQuery query) - => query.Read(items => items.SingleOrDefault()); + { + if (query is null) throw new ArgumentNullException(nameof(query)); + Contract.EndContractBlock(); + + if (query is IMappedTerminals terminals) + { + return terminals.SingleOrDefault(); + } + + return query.Read(items => items.SingleOrDefault()); + } /// /// Asynchronously reads the only mapped row. @@ -407,11 +537,181 @@ public static ValueTask> ToHashSetAsync(this IMappedQuery query /// A task that produces the single row, or if the query returns no rows. /// The operation was canceled. /// The query returns more than one row. + /// + /// + /// Called through , the types of this library behave exactly as their own members do, including how the later result sets are read through. + /// Another implementation of is read through its Read handler: it stops at the row, so it cannot read through the later result sets. + /// + /// /// public static ValueTask SingleOrDefaultAsync(this IMappedQuery query, CancellationToken cancellationToken = default) - => query.ReadAsync( + { + if (query is null) throw new ArgumentNullException(nameof(query)); + Contract.EndContractBlock(); + + if (query is IMappedTerminals terminals) + { + return terminals.SingleOrDefaultAsync(cancellationToken); + } + + return query.ReadAsync( async (items, token) => await items.SingleOrDefaultAsync(token).ConfigureAwait(false), cancellationToken); + } + + /// + /// Reads the first mapped row; throws if the query returns no rows. + /// + /// The type each row is mapped to. + /// The query to execute. + /// The first row. + /// is . + /// The query returns no rows. + /// + /// + /// Rows after the first are not mapped. Add an ORDER BY to the SQL when "first" must be deterministic. + /// A row whose value is NULL is still a row, and is returned for it. + /// + /// + /// The no-rows error is thrown after the command has been cleaned up: the connection is released and, unless SingleResult() was called, the later result sets have been read through, so a failure in one of them is thrown instead. + /// + /// + /// Called through , the types of this library behave exactly as their own members do, including the no-rows message and how the later result sets are read through. + /// Another implementation of is read through its Read handler: it stops at the row, so it cannot read through the later result sets, and its no-rows error is the message of LINQ, which does not name the terminal. + /// + /// + /// + /// + public static T First(this IMappedQuery query) + { + if (query is null) throw new ArgumentNullException(nameof(query)); + Contract.EndContractBlock(); + + if (query is IMappedTerminals terminals) + { + return terminals.First(); + } + + return query.Read(items => items.First()); + } + + /// + /// Asynchronously reads the first mapped row; throws if the query returns no rows. + /// + /// The type each row is mapped to. + /// The query to execute. + /// Cancels the call, together with the cancellation token set on the underlying command: either one cancels it, whether it is opening the connection, looking up stored-procedure parameter names, executing or reading. + /// A task that produces the first row. + /// is . + /// The query returns no rows. + /// The operation was canceled. + /// + /// + /// Rows after the first are not mapped. Add an ORDER BY to the SQL when "first" must be deterministic. + /// A row whose value is NULL is still a row, and is returned for it. + /// + /// + /// The no-rows error is thrown after the command has been cleaned up: the connection is released and, unless SingleResult() was called, the later result sets have been read through, so a failure in one of them is thrown instead. + /// + /// + /// Called through , the types of this library behave exactly as their own members do, including the no-rows message and how the later result sets are read through. + /// Another implementation of is read through its Read handler: it stops at the row, so it cannot read through the later result sets, and its no-rows error is the message of LINQ, which does not name the terminal. + /// + /// + /// + /// + public static ValueTask FirstAsync(this IMappedQuery query, CancellationToken cancellationToken = default) + { + if (query is null) throw new ArgumentNullException(nameof(query)); + Contract.EndContractBlock(); + + if (query is IMappedTerminals terminals) + { + return terminals.FirstAsync(cancellationToken); + } + + return query.ReadAsync( + async (items, token) => await items.FirstAsync(token).ConfigureAwait(false), + cancellationToken); + } + + /// + /// Reads the only mapped row; throws if the query returns no rows or more than one. + /// + /// The type each row is mapped to. + /// The query to execute. + /// The single row. + /// is . + /// The query returns no rows, or more than one row. + /// + /// + /// A row whose value is NULL is still a row, and is returned for it. + /// The provider is not asked for a single row, because it could then hide the second one. + /// + /// + /// The no-rows error is thrown after the command has been cleaned up: the connection is released and, unless SingleResult() was called, the later result sets have been read through, so a failure in one of them is thrown instead. + /// The more-than-one error is thrown at the second row, before the later result sets are read, so it is never replaced by a later failure. + /// + /// + /// Called through , the types of this library behave exactly as their own members do, including the no-rows message and how the later result sets are read through. + /// Another implementation of is read through its Read handler: it stops at the row, so it cannot read through the later result sets, and its no-rows error is the message of LINQ, which does not name the terminal. + /// + /// + /// + /// + public static T Single(this IMappedQuery query) + { + if (query is null) throw new ArgumentNullException(nameof(query)); + Contract.EndContractBlock(); + + if (query is IMappedTerminals terminals) + { + return terminals.Single(); + } + + return query.Read(items => items.Single()); + } + + /// + /// Asynchronously reads the only mapped row; throws if the query returns no rows or more than one. + /// + /// The type each row is mapped to. + /// The query to execute. + /// Cancels the call, together with the cancellation token set on the underlying command: either one cancels it, whether it is opening the connection, looking up stored-procedure parameter names, executing or reading. + /// A task that produces the single row. + /// is . + /// The query returns no rows, or more than one row. + /// The operation was canceled. + /// + /// + /// A row whose value is NULL is still a row, and is returned for it. + /// The provider is not asked for a single row, because it could then hide the second one. + /// + /// + /// The no-rows error is thrown after the command has been cleaned up: the connection is released and, unless SingleResult() was called, the later result sets have been read through, so a failure in one of them is thrown instead. + /// The more-than-one error is thrown at the second row, before the later result sets are read, so it is never replaced by a later failure. + /// + /// + /// Called through , the types of this library behave exactly as their own members do, including the no-rows message and how the later result sets are read through. + /// Another implementation of is read through its Read handler: it stops at the row, so it cannot read through the later result sets, and its no-rows error is the message of LINQ, which does not name the terminal. + /// + /// + /// + /// + public static ValueTask SingleAsync(this IMappedQuery query, CancellationToken cancellationToken = default) + { + if (query is null) throw new ArgumentNullException(nameof(query)); + Contract.EndContractBlock(); + + if (query is IMappedTerminals terminals) + { + return terminals.SingleAsync(cancellationToken); + } + + return query.ReadAsync( + async (items, token) => await items.SingleAsync(token).ConfigureAwait(false), + cancellationToken); + } /// /// Projects each mapped row with , lazily: nothing runs until a terminal of the returned query runs. diff --git a/Source/Core/IMappedTerminals.cs b/Source/Core/IMappedTerminals.cs new file mode 100644 index 0000000..4d437f2 --- /dev/null +++ b/Source/Core/IMappedTerminals.cs @@ -0,0 +1,37 @@ +namespace Open.Database.Extensions; + +/// +/// The early-stopping terminals that the types of this library implement as members, +/// so that the extensions on can call them +/// instead of running LINQ over the rows. +/// +/// The type each row is mapped to. +/// +/// Internal on purpose: it is not part of the public API. +/// A type of this library is told apart from another implementation of +/// by this interface, and only the library's own types have it. +/// Through they do what the members do: +/// the library's no-rows message, and the read-through of the later result sets. +/// +internal interface IMappedTerminals +{ + T First(); + + T Single(); + + T? FirstOrDefault(); + + T? SingleOrDefault(); + + ValueTask FirstAsync( + CancellationToken cancellationToken = default); + + ValueTask SingleAsync( + CancellationToken cancellationToken = default); + + ValueTask FirstOrDefaultAsync( + CancellationToken cancellationToken = default); + + ValueTask SingleOrDefaultAsync( + CancellationToken cancellationToken = default); +} diff --git a/Source/Core/MappedQuery.cs b/Source/Core/MappedQuery.cs index 0001193..db17ac9 100644 --- a/Source/Core/MappedQuery.cs +++ b/Source/Core/MappedQuery.cs @@ -265,6 +265,36 @@ internal T[] ToArray() => Run((_source, _skip, _take), static (reader, s) => RowReader.TrySingle(s._source, reader, s._skip, s._take, out T? item) ? item : default, Behavior); + // The throwing twins (First, Single) read exactly as the OrDefault forms do. + // They throw for no rows only after the call has cleaned up (the connection + // closed if this call opened it, the later result sets read through), so a + // failure from a later result set is thrown instead. + internal T First() + { + (bool found, T? row) = Run( + (_source, _skip, _take), + static (reader, s) => RowReader.TryFirst( + s._source, reader, s._skip, s._take, out T? item) + ? (true, item) + : (false, default), + FirstBehavior); + + return found ? row! : throw RowReader.NoRows(nameof(First)); + } + + internal T Single() + { + (bool found, T? row) = Run( + (_source, _skip, _take), + static (reader, s) => RowReader.TrySingle( + s._source, reader, s._skip, s._take, out T? item) + ? (true, item) + : (false, default), + Behavior); + + return found ? row! : throw RowReader.NoRows(nameof(Single)); + } + internal ValueTask> ToListAsync(CancellationToken cancellationToken) { CancellationToken token = Combine(cancellationToken, out CancellationTokenSource? linked); @@ -313,6 +343,56 @@ internal ValueTask ToArrayAsync(CancellationToken cancellationToken) }, Behavior, linked, token); } + internal async ValueTask FirstAsync(CancellationToken cancellationToken) + { + CancellationToken token = Combine( + cancellationToken, out CancellationTokenSource? linked); + + (bool found, T? row) = await RunAsync( + (_source, _skip, _take, UseAsyncRead, token), + static async (reader, s) => + { + if (reader is DbDataReader db) + { + return await RowReader.TryFirstAsync( + s._source, db, s._skip, s._take, + s.UseAsyncRead, s.token).ConfigureAwait(false); + } + + bool has = RowReader.TryFirst( + s._source, reader, s._skip, s._take, out T? item); + return (has, item); + }, + FirstBehavior, linked, token).ConfigureAwait(false); + + return found ? row! : throw RowReader.NoRows(nameof(FirstAsync)); + } + + internal async ValueTask SingleAsync(CancellationToken cancellationToken) + { + CancellationToken token = Combine( + cancellationToken, out CancellationTokenSource? linked); + + (bool found, T? row) = await RunAsync( + (_source, _skip, _take, UseAsyncRead, token), + static async (reader, s) => + { + if (reader is DbDataReader db) + { + return await RowReader.TrySingleAsync( + s._source, db, s._skip, s._take, + s.UseAsyncRead, s.token).ConfigureAwait(false); + } + + bool has = RowReader.TrySingle( + s._source, reader, s._skip, s._take, out T? item); + return (has, item); + }, + Behavior, linked, token).ConfigureAwait(false); + + return found ? row! : throw RowReader.NoRows(nameof(SingleAsync)); + } + internal ImmutableArray ToImmutableArray() { T[] array = ToArray(); @@ -365,6 +445,60 @@ internal ValueTask> ToDictionaryAsync(Func ke }, Behavior, linked, token); } + internal Dictionary ToDictionary( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer) + where TKey : notnull + { + if (keySelector is null) throw new ArgumentNullException(nameof(keySelector)); + if (valueSelector is null) throw new ArgumentNullException(nameof(valueSelector)); + Contract.EndContractBlock(); + + return Run( + (_source, _skip, _take, keySelector, valueSelector, comparer), + static (reader, s) => + { + var sink = new DictionarySink( + [with(s.comparer)], s.keySelector, s.valueSelector); + RowReader.Fill(s._source, reader, s._skip, s._take, ref sink); + return sink.Dictionary; + }, + Behavior); + } + + internal ValueTask> ToDictionaryAsync( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer, + CancellationToken cancellationToken) + where TKey : notnull + { + if (keySelector is null) throw new ArgumentNullException(nameof(keySelector)); + if (valueSelector is null) throw new ArgumentNullException(nameof(valueSelector)); + Contract.EndContractBlock(); + + CancellationToken token = Combine( + cancellationToken, out CancellationTokenSource? linked); + return RunAsync( + (_source, _skip, _take, keySelector, valueSelector, comparer, UseAsyncRead, token), + static async (reader, s) => + { + var sink = new DictionarySink( + [with(s.comparer)], s.keySelector, s.valueSelector); + if (reader is DbDataReader db) + { + return (await RowReader.FillAsync( + s._source, db, s._skip, s._take, + s.UseAsyncRead, sink, s.token).ConfigureAwait(false)).Dictionary; + } + + RowReader.Fill(s._source, reader, s._skip, s._take, ref sink); + return sink.Dictionary; + }, + Behavior, linked, token); + } + internal HashSet ToHashSet(IEqualityComparer? comparer) => Run((_source, _skip, _take, comparer), static (reader, s) => { @@ -392,7 +526,8 @@ internal ValueTask> ToHashSetAsync(IEqualityComparer? comparer, Ca /// A deferred, type-locked query over any . Terminals are methods here, with more as extensions on . /// The type each record is mapped to. -public readonly record struct MappedQuery : IMappedQuery +public readonly record struct MappedQuery + : IMappedQuery, IMappedTerminals { readonly MappedQueryCore _core; internal MappedQuery(MappedQueryCore core) => _core = core; @@ -410,7 +545,7 @@ internal ValueTask> ToHashSetAsync(IEqualityComparer? comparer, Ca /// A later failure can follow a write that already committed, so a caller that retries can repeat it, and the rows read are lost when it throws. /// This call asserts nothing: it does not check that there is only one result set. It stops after the first, and a later failure goes unseen. /// A read-through also does not happen when the caller stops reading early or the mapping, the selector or the caller's handler throws. - /// FirstOrDefault with nothing skipped also asks the provider for a single row. + /// FirstOrDefault and First with nothing skipped also ask the provider for a single row. /// command.SingleResult().Map<T>() (see ) is the same as command.Map<T>().SingleResult(). /// /// The same query, without the read-through. @@ -441,6 +576,20 @@ internal ValueTask> ToHashSetAsync(IEqualityComparer? comparer, Ca public T? SingleOrDefault() => _core.SingleOrDefault(); /// public ValueTask SingleOrDefaultAsync(CancellationToken cancellationToken = default) => _core.SingleOrDefaultAsync(cancellationToken); + /// + public T First() + => _core.First(); + /// + public ValueTask FirstAsync( + CancellationToken cancellationToken = default) + => _core.FirstAsync(cancellationToken); + /// + public T Single() + => _core.Single(); + /// + public ValueTask SingleAsync( + CancellationToken cancellationToken = default) + => _core.SingleAsync(cancellationToken); /// public ImmutableArray ToImmutableArray() => _core.ToImmutableArray(); /// @@ -449,6 +598,22 @@ internal ValueTask> ToHashSetAsync(IEqualityComparer? comparer, Ca 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 Dictionary ToDictionary( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null) + where TKey : notnull + => _core.ToDictionary(keySelector, valueSelector, comparer); + /// + public ValueTask> ToDictionaryAsync( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + where TKey : notnull + => _core.ToDictionaryAsync( + keySelector, valueSelector, comparer, cancellationToken); /// public HashSet ToHashSet(IEqualityComparer? comparer = null) => _core.ToHashSet(comparer); /// @@ -457,7 +622,8 @@ internal ValueTask> ToHashSetAsync(IEqualityComparer? comparer, Ca /// A with a pending skip; can still be limited with . /// The type each record is mapped to. -public readonly record struct MappedQueryWithSkip : IMappedQuery +public readonly record struct MappedQueryWithSkip + : IMappedQuery, IMappedTerminals { readonly MappedQueryCore _core; internal MappedQueryWithSkip(MappedQueryCore core) => _core = core; @@ -493,6 +659,20 @@ internal ValueTask> ToHashSetAsync(IEqualityComparer? comparer, Ca public T? SingleOrDefault() => _core.SingleOrDefault(); /// public ValueTask SingleOrDefaultAsync(CancellationToken cancellationToken = default) => _core.SingleOrDefaultAsync(cancellationToken); + /// + public T First() + => _core.First(); + /// + public ValueTask FirstAsync( + CancellationToken cancellationToken = default) + => _core.FirstAsync(cancellationToken); + /// + public T Single() + => _core.Single(); + /// + public ValueTask SingleAsync( + CancellationToken cancellationToken = default) + => _core.SingleAsync(cancellationToken); /// public ImmutableArray ToImmutableArray() => _core.ToImmutableArray(); /// @@ -501,6 +681,22 @@ internal ValueTask> ToHashSetAsync(IEqualityComparer? comparer, Ca 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 Dictionary ToDictionary( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null) + where TKey : notnull + => _core.ToDictionary(keySelector, valueSelector, comparer); + /// + public ValueTask> ToDictionaryAsync( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + where TKey : notnull + => _core.ToDictionaryAsync( + keySelector, valueSelector, comparer, cancellationToken); /// public HashSet ToHashSet(IEqualityComparer? comparer = null) => _core.ToHashSet(comparer); /// @@ -509,7 +705,8 @@ internal ValueTask> ToHashSetAsync(IEqualityComparer? comparer, Ca /// A fully-configured with a take (and any preceding skip); terminal-only. /// The type each record is mapped to. -public readonly record struct MappedQueryWithTake : IMappedQuery +public readonly record struct MappedQueryWithTake + : IMappedQuery, IMappedTerminals { readonly MappedQueryCore _core; internal MappedQueryWithTake(MappedQueryCore core) => _core = core; @@ -542,6 +739,20 @@ internal ValueTask> ToHashSetAsync(IEqualityComparer? comparer, Ca public T? SingleOrDefault() => _core.SingleOrDefault(); /// public ValueTask SingleOrDefaultAsync(CancellationToken cancellationToken = default) => _core.SingleOrDefaultAsync(cancellationToken); + /// + public T First() + => _core.First(); + /// + public ValueTask FirstAsync( + CancellationToken cancellationToken = default) + => _core.FirstAsync(cancellationToken); + /// + public T Single() + => _core.Single(); + /// + public ValueTask SingleAsync( + CancellationToken cancellationToken = default) + => _core.SingleAsync(cancellationToken); /// public ImmutableArray ToImmutableArray() => _core.ToImmutableArray(); /// @@ -550,6 +761,22 @@ internal ValueTask> ToHashSetAsync(IEqualityComparer? comparer, Ca 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 Dictionary ToDictionary( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null) + where TKey : notnull + => _core.ToDictionary(keySelector, valueSelector, comparer); + /// + public ValueTask> ToDictionaryAsync( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + where TKey : notnull + => _core.ToDictionaryAsync( + keySelector, valueSelector, comparer, cancellationToken); /// public HashSet ToHashSet(IEqualityComparer? comparer = null) => _core.ToHashSet(comparer); /// diff --git a/Source/Core/MappedViews.cs b/Source/Core/MappedViews.cs index f74eb3d..80def5b 100644 --- a/Source/Core/MappedViews.cs +++ b/Source/Core/MappedViews.cs @@ -605,7 +605,8 @@ async ValueTask ReadAsync( /// /// /// -public readonly record struct MappedReader : IMappedQuery +public readonly record struct MappedReader + : IMappedQuery, IMappedTerminals { readonly MappedQueryCore _core; @@ -680,6 +681,24 @@ public ValueTask ToArrayAsync( CancellationToken cancellationToken = default) => _core.SingleOrDefaultAsync(cancellationToken); + /// + public T First() + => _core.First(); + + /// + public ValueTask FirstAsync( + CancellationToken cancellationToken = default) + => _core.FirstAsync(cancellationToken); + + /// + public T Single() + => _core.Single(); + + /// + public ValueTask SingleAsync( + CancellationToken cancellationToken = default) + => _core.SingleAsync(cancellationToken); + /// public ImmutableArray ToImmutableArray() => _core.ToImmutableArray(); @@ -704,6 +723,24 @@ public ValueTask> ToDictionaryAsync( where TKey : notnull => _core.ToDictionaryAsync(keySelector, comparer, cancellationToken); + /// + public Dictionary ToDictionary( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null) + where TKey : notnull + => _core.ToDictionary(keySelector, valueSelector, comparer); + + /// + public ValueTask> ToDictionaryAsync( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + where TKey : notnull + => _core.ToDictionaryAsync( + keySelector, valueSelector, comparer, cancellationToken); + /// public HashSet ToHashSet( IEqualityComparer? comparer = null) @@ -741,7 +778,8 @@ public ValueTask> ToHashSetAsync( /// /// /// -public readonly record struct MappedCommand : IMappedQuery +public readonly record struct MappedCommand + : IMappedQuery, IMappedTerminals { readonly MappedQueryCore _core; @@ -820,6 +858,24 @@ public ValueTask ToArrayAsync( CancellationToken cancellationToken = default) => _core.SingleOrDefaultAsync(cancellationToken); + /// + public T First() + => _core.First(); + + /// + public ValueTask FirstAsync( + CancellationToken cancellationToken = default) + => _core.FirstAsync(cancellationToken); + + /// + public T Single() + => _core.Single(); + + /// + public ValueTask SingleAsync( + CancellationToken cancellationToken = default) + => _core.SingleAsync(cancellationToken); + /// public ImmutableArray ToImmutableArray() => _core.ToImmutableArray(); @@ -844,6 +900,24 @@ public ValueTask> ToDictionaryAsync( where TKey : notnull => _core.ToDictionaryAsync(keySelector, comparer, cancellationToken); + /// + public Dictionary ToDictionary( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null) + where TKey : notnull + => _core.ToDictionary(keySelector, valueSelector, comparer); + + /// + public ValueTask> ToDictionaryAsync( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + where TKey : notnull + => _core.ToDictionaryAsync( + keySelector, valueSelector, comparer, cancellationToken); + /// public HashSet ToHashSet( IEqualityComparer? comparer = null) @@ -875,7 +949,8 @@ public ValueTask> ToHashSetAsync( /// /// /// -public readonly record struct MappedTable : IMappedQuery +public readonly record struct MappedTable + : IMappedQuery, IMappedTerminals { readonly MappedQueryCore _core; @@ -950,6 +1025,24 @@ public ValueTask ToArrayAsync( CancellationToken cancellationToken = default) => _core.SingleOrDefaultAsync(cancellationToken); + /// + public T First() + => _core.First(); + + /// + public ValueTask FirstAsync( + CancellationToken cancellationToken = default) + => _core.FirstAsync(cancellationToken); + + /// + public T Single() + => _core.Single(); + + /// + public ValueTask SingleAsync( + CancellationToken cancellationToken = default) + => _core.SingleAsync(cancellationToken); + /// public ImmutableArray ToImmutableArray() => _core.ToImmutableArray(); @@ -974,6 +1067,24 @@ public ValueTask> ToDictionaryAsync( where TKey : notnull => _core.ToDictionaryAsync(keySelector, comparer, cancellationToken); + /// + public Dictionary ToDictionary( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null) + where TKey : notnull + => _core.ToDictionary(keySelector, valueSelector, comparer); + + /// + public ValueTask> ToDictionaryAsync( + Func keySelector, + Func valueSelector, + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + where TKey : notnull + => _core.ToDictionaryAsync( + keySelector, valueSelector, comparer, cancellationToken); + /// public HashSet ToHashSet( IEqualityComparer? comparer = null) diff --git a/Source/Core/PublicAPI.Unshipped.txt b/Source/Core/PublicAPI.Unshipped.txt index bb4b01c..e20dc32 100644 --- a/Source/Core/PublicAPI.Unshipped.txt +++ b/Source/Core/PublicAPI.Unshipped.txt @@ -192,3 +192,47 @@ Open.Database.Extensions.ExpressiveDbCommandBase(this Open.Database.Extensions.IMappedQuery! query, System.Func! selector) -> Open.Database.Extensions.IMappedQuery! Open.Database.Extensions.RowMap.Convert(string! member, string! column, System.Func! convert) -> Open.Database.Extensions.RowMap! Open.Database.Extensions.RowMap.Convert(string! member, System.Func! convert) -> Open.Database.Extensions.RowMap! +Open.Database.Extensions.MappedQuery.First() -> T +Open.Database.Extensions.MappedQuery.FirstAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedQuery.Single() -> T +Open.Database.Extensions.MappedQuery.SingleAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedQuery.ToDictionary(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary! +Open.Database.Extensions.MappedQuery.ToDictionaryAsync(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedQueryWithSkip.First() -> T +Open.Database.Extensions.MappedQueryWithSkip.FirstAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedQueryWithSkip.Single() -> T +Open.Database.Extensions.MappedQueryWithSkip.SingleAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedQueryWithSkip.ToDictionary(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary! +Open.Database.Extensions.MappedQueryWithSkip.ToDictionaryAsync(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedQueryWithTake.First() -> T +Open.Database.Extensions.MappedQueryWithTake.FirstAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedQueryWithTake.Single() -> T +Open.Database.Extensions.MappedQueryWithTake.SingleAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedQueryWithTake.ToDictionary(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary! +Open.Database.Extensions.MappedQueryWithTake.ToDictionaryAsync(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedReader.First() -> T +Open.Database.Extensions.MappedReader.FirstAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedReader.Single() -> T +Open.Database.Extensions.MappedReader.SingleAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedReader.ToDictionary(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary! +Open.Database.Extensions.MappedReader.ToDictionaryAsync(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedCommand.First() -> T +Open.Database.Extensions.MappedCommand.FirstAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.Single() -> T +Open.Database.Extensions.MappedCommand.SingleAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.ToDictionary(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary! +Open.Database.Extensions.MappedCommand.ToDictionaryAsync(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedTable.First() -> T +Open.Database.Extensions.MappedTable.FirstAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedTable.Single() -> T +Open.Database.Extensions.MappedTable.SingleAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedTable.ToDictionary(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary! +Open.Database.Extensions.MappedTable.ToDictionaryAsync(System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Open.Database.Extensions.MappedQueryExtensions.First(this Open.Database.Extensions.IMappedQuery! query) -> T +static Open.Database.Extensions.MappedQueryExtensions.FirstAsync(this Open.Database.Extensions.IMappedQuery! query, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Open.Database.Extensions.MappedQueryExtensions.Single(this Open.Database.Extensions.IMappedQuery! query) -> T +static Open.Database.Extensions.MappedQueryExtensions.SingleAsync(this Open.Database.Extensions.IMappedQuery! query, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Open.Database.Extensions.MappedQueryExtensions.ToDictionary(this Open.Database.Extensions.IMappedQuery! query, System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary! +static Open.Database.Extensions.MappedQueryExtensions.ToDictionaryAsync(this Open.Database.Extensions.IMappedQuery! query, System.Func! keySelector, System.Func! valueSelector, System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +static Open.Database.Extensions.IExecuteReaderExtensions.FirstAsync(this Open.Database.Extensions.IExecuteReader! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static Open.Database.Extensions.IExecuteReaderExtensions.SingleAsync(this Open.Database.Extensions.IExecuteReader! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedQueryDictionaryValuesTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedQueryDictionaryValuesTests.cs new file mode 100644 index 0000000..b16f173 --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedQueryDictionaryValuesTests.cs @@ -0,0 +1,637 @@ +using System.Data; +using static Open.Database.Extensions.Tests.ReaderFixtures; + +#nullable enable + +namespace Open.Database.Extensions.Tests; + +// ToDictionary(keySelector, valueSelector, comparer) builds the dictionary in +// one pass over the rows, keeping the value the selector returns and not the +// row, with the same duplicate-key behavior as the key-only ToDictionary. +[ExcludeFromCodeCoverage] +public static class MappedQueryDictionaryValuesTests +{ + sealed record Person + { + public required int Id { get; init; } + public string? Name { get; init; } + } + + static CancellationToken Ct => TestContext.Current.CancellationToken; + + const int LaterSets = 2; // a full read-through is three calls of NextResult + + static FakeExecuteReader Command(int rows) + => new(() => Reader(Rows(rows))); + + static FakeExecuteReader People() + => new(() => Reader(TwoRows)); // 1/"a", 2/null + + 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; + } + + // ---- every level: expressive command, reader, command, table ---- + + public static TheoryData Levels + => ["command", "reader", "commandView", "table"]; + + static Dictionary Sync( + string level, + int rows, + Func key, + Func value, + IEqualityComparer? comparer = null) + => level switch + { + "command" => + Command(rows).Map() + .ToDictionary(key, value, comparer), + "reader" => + Reader(Rows(rows)).Map() + .ToDictionary(key, value, comparer), + "commandView" => + new FakeDbCommand( + new FakeDbConnection(), + () => Reader(Rows(rows))).Map() + .ToDictionary(key, value, comparer), + "table" => + Table(rows).Map() + .ToDictionary(key, value, comparer), + _ => + throw new ArgumentOutOfRangeException(nameof(level)), + }; + + static async Task> Async( + string level, + int rows, + Func key, + Func value, + IEqualityComparer? comparer = null) + => level switch + { + "command" => + await Command(rows).Map() + .ToDictionaryAsync(key, value, comparer, Ct), + "reader" => + await Reader(Rows(rows)).Map() + .ToDictionaryAsync(key, value, comparer, Ct), + "commandView" => + await new FakeDbCommand( + new FakeDbConnection(), + () => Reader(Rows(rows))).Map() + .ToDictionaryAsync(key, value, comparer, Ct), + "table" => + await Table(rows).Map() + .ToDictionaryAsync(key, value, comparer, Ct), + _ => + throw new ArgumentOutOfRangeException(nameof(level)), + }; + + static void AssertThreeRows(Dictionary actual) + => Assert.Equal( + new Dictionary { [1] = "n1", [2] = "n2", [3] = "n3" }, + actual); + + [Theory, MemberData(nameof(Levels))] + public static async Task KeySelectorAndValueSelector_BuildTheDictionary( + string level) + { + AssertThreeRows(Sync(level, 3, p => p.Id, p => p.Name)); + AssertThreeRows(await Async(level, 3, p => p.Id, p => p.Name)); + } + + [Theory, MemberData(nameof(Levels))] + public static async Task NoRows_GiveAnEmptyDictionary(string level) + { + Assert.Empty(Sync(level, 0, p => p.Id, p => p.Name)); + Assert.Empty(await Async(level, 0, p => p.Id, p => p.Name)); + } + + [Theory, MemberData(nameof(Levels))] + public static async Task ADuplicateKey_Throws_AsItDoesWithoutAValueSelector( + string level) + { + Assert.Throws( + () => Sync(level, 3, _ => 1, p => p.Name)); + await Assert.ThrowsAsync( + async () => await Async(level, 3, _ => 1, p => p.Name)); + + // The key-only form it mirrors. + Assert.Throws( + () => Command(3).Map().ToDictionary(_ => 1)); + await Assert.ThrowsAsync( + async () => await Command(3).Map() + .ToDictionaryAsync(_ => 1, null, Ct)); + } + + [Theory, MemberData(nameof(Levels))] + public static async Task ANullSelector_IsRefused_BeforeAnythingRuns( + string level) + { + Assert.Equal( + "keySelector", + Assert.Throws( + () => Sync(level, 1, null!, p => p.Name)).ParamName); + Assert.Equal( + "valueSelector", + Assert.Throws( + () => Sync(level, 1, p => p.Id, null!)).ParamName); + Assert.Equal( + "keySelector", + (await Assert.ThrowsAsync( + async () => await Async(level, 1, null!, p => p.Name))).ParamName); + Assert.Equal( + "valueSelector", + (await Assert.ThrowsAsync( + async () => await Async(level, 1, p => p.Id, null!))).ParamName); + } + + [Fact] + public static async Task ANullSelector_NeverExecutesTheCommand() + { + FakeExecuteReader command = Command(1); + + Assert.Throws( + () => command.Map().ToDictionary(null!, p => p.Name!)); + Assert.Throws( + () => command.Map().ToDictionary(p => p.Id, null!)); + await Assert.ThrowsAsync( + async () => await command.Map() + .ToDictionaryAsync(null!, p => p.Name!, null, Ct)); + await Assert.ThrowsAsync( + async () => await command.Map() + .ToDictionaryAsync(p => p.Id, null!, null, Ct)); + + Assert.Equal(0, command.ExecuteCount); + } + + // ---- what the selectors see ---- + + [Fact] + public static async Task EachSelector_SeesEachRowOnce_KeyFirst() + { + List log = []; + int Key(Person p) + { + log.Add("k" + p.Id); + return p.Id; + } + + string? Value(Person p) + { + log.Add("v" + p.Id); + return p.Name; + } + + AssertThreeRows( + Command(3).Map().ToDictionary(Key, Value)); + Assert.Equal(["k1", "v1", "k2", "v2", "k3", "v3"], log); + + log.Clear(); + AssertThreeRows( + await Command(3).Map().ToDictionaryAsync(Key, Value, null, Ct)); + Assert.Equal(["k1", "v1", "k2", "v2", "k3", "v3"], log); + } + + [Fact] + public static async Task SkippedRows_AreNeverSeenByTheSelectors() + { + List keys = []; + List values = []; + int Key(Person p) + { + keys.Add(p.Id); + return p.Id; + } + + int Value(Person p) + { + values.Add(p.Id); + return p.Id * 10; + } + + Assert.Equal( + new Dictionary { [2] = 20, [3] = 30 }, + Command(5).Map().Skip(1).Take(2).ToDictionary(Key, Value)); + Assert.Equal([2, 3], keys); + Assert.Equal([2, 3], values); + + keys.Clear(); + values.Clear(); + Assert.Equal( + new Dictionary { [4] = 40, [5] = 50 }, + await Command(5).Map().Skip(3).ToDictionaryAsync(Key, Value, null, Ct)); + Assert.Equal([4, 5], keys); + Assert.Equal([4, 5], values); + + Assert.Equal( + new Dictionary { [1] = 10 }, + await Command(5).Map().Take(1).ToDictionaryAsync(Key, Value, null, Ct)); + } + + [Fact] + public static async Task ANullValue_IsKept() + { + Dictionary sync + = People().Map().ToDictionary(p => p.Id, p => p.Name); + Assert.Equal("a", sync[1]); + Assert.True(sync.ContainsKey(2)); + Assert.Null(sync[2]); + + Dictionary coalesced = await People().Map() + .ToDictionaryAsync(p => p.Id, p => p.Name ?? "", null, Ct); + Assert.Equal(new Dictionary { [1] = "a", [2] = "" }, coalesced); + } + + [Fact] + public static async Task ASingleValue_CanBeKeyedAndValued() + { + Assert.Equal( + new Dictionary { [1] = 10, [2] = 20 }, + Command(2).Map().ToDictionary(i => i, i => i * 10L)); + Assert.Equal( + new Dictionary { [1] = 10, [2] = 20 }, + await Command(2).Map() + .ToDictionaryAsync(i => i, i => i * 10L, null, Ct)); + } + + // ---- the comparer ---- + + [Fact] + public static async Task TheComparer_IsUsedForTheKeys() + { + FakeExecuteReader command = new(() => Reader([[1, "a", null], [2, "b", null]])); + + Dictionary sync = command.Map().ToDictionary( + p => p.Name!, p => p.Id, StringComparer.OrdinalIgnoreCase); + Assert.Equal(1, sync["A"]); + Assert.Equal(2, sync["B"]); + + Dictionary viaAsync = await command.Map() + .ToDictionaryAsync( + p => p.Name!, p => p.Id, StringComparer.OrdinalIgnoreCase, Ct); + Assert.Equal(1, viaAsync["A"]); + + // Without it the lookup is case-sensitive. + Assert.False( + command.Map().ToDictionary(p => p.Name!, p => p.Id) + .ContainsKey("A")); + } + + [Fact] + public static async Task ADuplicateUnderTheComparer_Throws() + { + FakeExecuteReader command = new(() => Reader([[1, "a", null], [2, "A", null]])); + + // Distinct keys by default, one key under the comparer. + Assert.Equal( + 2, + command.Map().ToDictionary(p => p.Name!, p => p.Id).Count); + Assert.Throws( + () => command.Map().ToDictionary( + p => p.Name!, p => p.Id, StringComparer.OrdinalIgnoreCase)); + await Assert.ThrowsAsync( + async () => await command.Map().ToDictionaryAsync( + p => p.Name!, p => p.Id, StringComparer.OrdinalIgnoreCase, Ct)); + } + + // ---- later result sets, failures and cancellation ---- + + static FakeExecuteReader Sets( + int rows, + out Func reader, + int? failAt = null) + { + MultiSetReader? last = null; + reader = () => last ?? throw new InvalidOperationException("never executed"); + return new FakeExecuteReader( + () => last = new MultiSetReader(Rows(rows), LaterSets, failAt)); + } + + [Fact] + public static async Task TheLaterResultSets_AreReadThrough_AndALaterFailureIsThrown() + { + FakeExecuteReader ok = Sets(2, out Func okReader); + Assert.Equal( + 2, + ok.Map().ToDictionary(p => p.Id, p => p.Name).Count); + Assert.Equal(LaterSets + 1, okReader().NextResultCalls); + Assert.Equal(0, okReader().ReadsAfterNextResult); + + FakeExecuteReader failing = Sets( + 2, out Func failingReader, failAt: 2); + Assert.Throws( + () => failing.Map().ToDictionary(p => p.Id, p => p.Name)); + Assert.Equal(2, failingReader().NextResultCalls); + + FakeExecuteReader asyncOk = Sets(2, out Func asyncOkReader); + Assert.Equal( + 2, + (await asyncOk.Map() + .ToDictionaryAsync(p => p.Id, p => p.Name, null, Ct)).Count); + Assert.Equal(LaterSets + 1, asyncOkReader().NextResultCalls); + + FakeExecuteReader asyncFailing = Sets( + 2, out Func asyncFailingReader, failAt: 1); + await Assert.ThrowsAsync( + async () => await asyncFailing.Map() + .ToDictionaryAsync(p => p.Id, p => p.Name, null, Ct)); + Assert.Equal(1, asyncFailingReader().NextResultCalls); + } + + [Fact] + public static async Task SingleResult_ReadsNothingThrough() + { + FakeExecuteReader command = Sets( + 2, out Func reader, failAt: 1); + + Assert.Equal( + 2, + command.Map().SingleResult() + .ToDictionary(p => p.Id, p => p.Name).Count); + Assert.Equal( + 2, + (await command.Map().SingleResult() + .ToDictionaryAsync(p => p.Id, p => p.Name, null, Ct)).Count); + + Assert.Equal(0, reader().NextResultCalls); + } + + [Fact] + public static async Task ASelectorThatThrows_IsNotReplaced_AndNothingIsReadThrough() + { + FakeExecuteReader command = Sets( + 3, out Func reader, failAt: 1); + + Assert.Equal( + "mine", + Assert.Throws( + () => command.Map().ToDictionary( + p => p.Id, _ => throw new FormatException("mine"))).Message); + Assert.Equal( + "mine", + (await Assert.ThrowsAsync( + async () => await command.Map() + .ToDictionaryAsync( + p => p.Id, + _ => throw new FormatException("mine"), + null, + Ct))).Message); + + Assert.Equal(0, reader().NextResultCalls); + } + + // A key selector that throws on a later row: its exception is thrown as it + // is, the dictionary built so far is dropped, the later result sets are not + // read through, and the connection the call opened is closed again. + [Fact] + public static async Task AKeySelectorThatThrows_IsNotReplaced_AndLeavesNoLeak() + { + static int Key(Person person) + => person.Id == 2 ? throw new FormatException("mine") : person.Id; + + FakeExecuteReader command = Sets( + 3, out Func reader, failAt: 1); + + Assert.Equal( + "mine", + Assert.Throws( + () => command.Map() + .ToDictionary(Key, p => p.Name)).Message); + Assert.Equal( + "mine", + (await Assert.ThrowsAsync( + async () => await command.Map() + .ToDictionaryAsync( + Key, p => p.Name, null, Ct))).Message); + Assert.Equal(0, reader().NextResultCalls); + + // The connection the call opened is closed again. + var connection = new FakeDbConnection(); + var closed = new FakeDbCommand(connection, () => Reader(Rows(3))); + + Assert.Throws( + () => closed.Map() + .ToDictionary(Key, p => p.Name)); + await Assert.ThrowsAsync( + async () => await closed.Map() + .ToDictionaryAsync(Key, p => p.Name, null, Ct)); + + Assert.Equal(["open", "close", "open", "close"], connection.Log); + Assert.Equal(ConnectionState.Closed, connection.State); + + // A connection that was open is left open. + var openConnection = new FakeDbConnection(); + openConnection.SetState(ConnectionState.Open); + var opened = new FakeDbCommand(openConnection, () => Reader(Rows(3))); + + Assert.Throws( + () => opened.Map() + .ToDictionary(Key, p => p.Name)); + await Assert.ThrowsAsync( + async () => await opened.Map() + .ToDictionaryAsync(Key, p => p.Name, null, Ct)); + + Assert.Empty(openConnection.Log); + Assert.Equal(ConnectionState.Open, openConnection.State); + } + + // The value-selector overloads throw what the key-only form throws for a + // duplicate key, sync and async, and the connection the call opened is + // closed again. + [Fact] + public static async Task ADuplicateKey_WithValueSelectors_ThrowsLikeTheKeyOnlyForm_AndLeavesNoLeak() + { + var connection = new FakeDbConnection(); + var command = new FakeDbCommand(connection, () => Reader(Rows(3))); + IEqualityComparer comparer = EqualityComparer.Default; + + string keyOnly = Assert.Throws( + () => command.Map().ToDictionary(_ => 1)).Message; + + Assert.Equal( + keyOnly, + Assert.Throws( + () => command.Map() + .ToDictionary(_ => 1, p => p.Name)).Message); + Assert.Equal( + keyOnly, + Assert.Throws( + () => command.Map() + .ToDictionary(_ => 1, p => p.Name, comparer)).Message); + + Assert.Equal( + keyOnly, + (await Assert.ThrowsAsync( + async () => await command.Map() + .ToDictionaryAsync(_ => 1, null, Ct))).Message); + Assert.Equal( + keyOnly, + (await Assert.ThrowsAsync( + async () => await command.Map() + .ToDictionaryAsync(_ => 1, p => p.Name, null, Ct))).Message); + Assert.Equal( + keyOnly, + (await Assert.ThrowsAsync( + async () => await command.Map() + .ToDictionaryAsync( + _ => 1, p => p.Name, comparer, Ct))).Message); + + // Six calls: each opened the connection and closed it again. + Assert.Equal(6, connection.OpenCalls); + Assert.Equal(ConnectionState.Closed, connection.State); + Assert.Equal(12, connection.Log.Count); + Assert.All( + connection.Log.Chunk(2), + pair => Assert.Equal(["open", "close"], pair)); + } + + [Fact] + public static async Task ACanceledToken_Cancels_AndTheCallsTokenReachesTheReader() + { + using var canceled = new CancellationTokenSource(); + await canceled.CancelAsync(); + await Assert.ThrowsAnyAsync( + async () => await Command(1).Map() + .ToDictionaryAsync(p => p.Id, p => p.Name, null, canceled.Token)); + + FakeExecuteReader onCommand = Command(1); + onCommand.CancellationToken = canceled.Token; + await Assert.ThrowsAnyAsync( + async () => await onCommand.Map() + .ToDictionaryAsync(p => p.Id, p => p.Name, null, Ct)); + + using var live = new CancellationTokenSource(); + FakeExecuteReader command = Command(1); + await command.Map() + .ToDictionaryAsync(p => p.Id, p => p.Name, null, live.Token); + Assert.Equal([live.Token], command.ReaderTokens); + } + + // ---- a reader that is not a DbDataReader ---- + + [Fact] + public static async Task APlainIDataReader_IsReadTheSameWay() + { + static IDataReader Plain() + { + IDataReader reader = Substitute.For(); + reader.Read().Returns(true, true, false); + reader.GetInt32(0).Returns(1, 2); + return reader; + } + + var command = new PlainReaderCommand(Plain); + var expected = new Dictionary { [1] = "v1", [2] = "v2" }; + + Assert.Equal( + expected, + command.Map(r => r.GetInt32(0)).ToDictionary(i => i, i => "v" + i)); + Assert.Equal( + expected, + await command.Map(r => r.GetInt32(0)) + .ToDictionaryAsync(i => i, i => "v" + i, null, Ct)); + } + + // ---- through IMappedQuery (the extensions, for generic code) ---- + + [Fact] + public static async Task ThroughTheInterface_TheExtensionsDoTheSame() + { + static IMappedQuery Q(int rows) => Command(rows).Map(); + + AssertThreeRows(Q(3).ToDictionary(p => p.Id, p => p.Name)); + AssertThreeRows( + await Q(3).ToDictionaryAsync(p => p.Id, p => p.Name, null, Ct)); + Assert.Empty(Q(0).ToDictionary(p => p.Id, p => p.Name)); + Assert.Empty( + await Q(0).ToDictionaryAsync(p => p.Id, p => p.Name, null, Ct)); + + Assert.Throws( + () => Q(3).ToDictionary(_ => 1, p => p.Name)); + await Assert.ThrowsAsync( + async () => await Q(3).ToDictionaryAsync(_ => 1, p => p.Name, null, Ct)); + } + + [Fact] + public static async Task ThroughTheInterface_ANullArgumentIsRefused() + { + IMappedQuery query = null!; + IMappedQuery real = Command(1).Map(); + + Assert.Equal( + "query", + Assert.Throws( + () => query.ToDictionary(p => p.Id, p => p.Name)).ParamName); + Assert.Equal( + "keySelector", + Assert.Throws( + () => real.ToDictionary(null!, p => p.Name!)).ParamName); + Assert.Equal( + "valueSelector", + Assert.Throws( + () => real.ToDictionary(p => p.Id, null!)).ParamName); + Assert.Equal( + "query", + (await Assert.ThrowsAsync( + async () => await query.ToDictionaryAsync( + p => p.Id, p => p.Name, null, Ct))).ParamName); + Assert.Equal( + "valueSelector", + (await Assert.ThrowsAsync( + async () => await real.ToDictionaryAsync( + p => p.Id, null!, null, Ct))).ParamName); + } + + // ---- overload resolution (compile-only) ---- + + // The key-only forms, with and without a comparer, still bind to + // themselves, and the value forms are found for every shape of call. +#pragma warning disable xUnit1051 // The point is the forms that pass no token. + [Fact] + public static async Task TheNewOverloads_DoNotMakeNeighbouringCallsAmbiguous() + { + MappedQuery q = People().Map(); + IEqualityComparer comparer = EqualityComparer.Default; + + Dictionary keyOnly = q.ToDictionary(p => p.Id); + keyOnly = q.ToDictionary(p => p.Id, null); + keyOnly = q.ToDictionary(p => p.Id, comparer); + keyOnly = await q.ToDictionaryAsync(p => p.Id, null, Ct); + keyOnly = await q.ToDictionaryAsync(p => p.Id, comparer, Ct); + keyOnly = await q.ToDictionaryAsync(p => p.Id, cancellationToken: Ct); + Assert.Equal(2, keyOnly.Count); + + Dictionary values = q.ToDictionary(p => p.Id, p => p.Name); + values = q.ToDictionary(p => p.Id, p => p.Name, null); + values = q.ToDictionary(p => p.Id, p => p.Name, comparer); + values = q.ToDictionary(p => p.Id, p => p.Name, comparer: comparer); + values = await q.ToDictionaryAsync(p => p.Id, p => p.Name); + values = await q.ToDictionaryAsync(p => p.Id, p => p.Name, null, Ct); + values = await q.ToDictionaryAsync(p => p.Id, p => p.Name, comparer, Ct); + values = await q.ToDictionaryAsync( + p => p.Id, p => p.Name, cancellationToken: Ct); + values = await q.Skip(0).ToDictionaryAsync(p => p.Id, p => p.Name); + values = await q.Skip(0).Take(2).ToDictionaryAsync( + p => p.Id, p => p.Name, cancellationToken: Ct); + Assert.Equal(2, values.Count); + + IMappedQuery boxed = q; + keyOnly = boxed.ToDictionary(p => p.Id); + keyOnly = boxed.ToDictionary(p => p.Id, null); + values = boxed.ToDictionary(p => p.Id, p => p.Name); + values = await boxed.ToDictionaryAsync(p => p.Id, p => p.Name, null, Ct); + Assert.Equal(2, values.Count); + } +#pragma warning restore xUnit1051 +} diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedQueryFirstSingleTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedQueryFirstSingleTests.cs new file mode 100644 index 0000000..26b3f3b --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedQueryFirstSingleTests.cs @@ -0,0 +1,986 @@ +using System.Data; +using static Open.Database.Extensions.Tests.ReaderFixtures; + +#nullable enable + +namespace Open.Database.Extensions.Tests; + +// First() and Single() are the throwing twins of FirstOrDefault() and +// SingleOrDefault(): the same single pass over the rows, and an +// InvalidOperationException instead of null for no rows (for Single also for +// more than one row). The error for no rows is thrown after the call has +// cleaned up, so the later result sets are read through first. +[ExcludeFromCodeCoverage] +public static class MappedQueryFirstSingleTests +{ + sealed record Person + { + public required int Id { get; init; } + public string? Name { get; init; } + } + + static CancellationToken Ct => TestContext.Current.CancellationToken; + + const int LaterSets = 2; // a full read-through is three calls of NextResult + + static FakeExecuteReader Command(int rows) + => new(() => Reader(Rows(rows))); + + // A selector query asks the provider for exactly the flags the caller's + // calls give it (a generated mapping adds SequentialAccess). + static MappedQuery Query(FakeExecuteReader command) + => command.Map(r => r.GetInt32(0)); + + static FakeExecuteReader Sets( + int rows, + out Func reader, + int? failAt = null) + { + MultiSetReader? last = null; + reader = () => last ?? throw new InvalidOperationException("never executed"); + return new FakeExecuteReader( + () => last = new MultiSetReader(Rows(rows), LaterSets, failAt)); + } + + static FakeExecuteReader One(Type column, object? cell) + => new(() => TypedReader.Create( + [("v", column)], + [[cell]])); + + 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; + } + + // ---- every level: expressive command, reader, command, table ---- + + sealed record Terminals( + Func First, + Func Single, + Func> FirstAsync, + Func> SingleAsync, + Func FirstOrDefault, + Func SingleOrDefault, + Func> FirstOrDefaultAsync, + Func> SingleOrDefaultAsync); + + static Terminals Of(MappedQuery q) + => new( + q.First, q.Single, q.FirstAsync, q.SingleAsync, + q.FirstOrDefault, q.SingleOrDefault, + q.FirstOrDefaultAsync, q.SingleOrDefaultAsync); + + static Terminals Of(MappedQueryWithSkip q) + => new( + q.First, q.Single, q.FirstAsync, q.SingleAsync, + q.FirstOrDefault, q.SingleOrDefault, + q.FirstOrDefaultAsync, q.SingleOrDefaultAsync); + + static Terminals Of(MappedQueryWithTake q) + => new( + q.First, q.Single, q.FirstAsync, q.SingleAsync, + q.FirstOrDefault, q.SingleOrDefault, + q.FirstOrDefaultAsync, q.SingleOrDefaultAsync); + + static Terminals Of(MappedReader q) + => new( + q.First, q.Single, q.FirstAsync, q.SingleAsync, + q.FirstOrDefault, q.SingleOrDefault, + q.FirstOrDefaultAsync, q.SingleOrDefaultAsync); + + static Terminals Of(MappedCommand q) + => new( + q.First, q.Single, q.FirstAsync, q.SingleAsync, + q.FirstOrDefault, q.SingleOrDefault, + q.FirstOrDefaultAsync, q.SingleOrDefaultAsync); + + static Terminals Of(MappedTable q) + => new( + q.First, q.Single, q.FirstAsync, q.SingleAsync, + q.FirstOrDefault, q.SingleOrDefault, + q.FirstOrDefaultAsync, q.SingleOrDefaultAsync); + + // The same terminals through the extensions on the interface. + // Not an overload of Of: a struct would bind to it by mistake. + static Terminals OfBoxed(IMappedQuery q) + => new( + q.First, q.Single, q.FirstAsync, q.SingleAsync, + q.FirstOrDefault, q.SingleOrDefault, + q.FirstOrDefaultAsync, q.SingleOrDefaultAsync); + + // A new source every call: a reader view is spent once it has been read. + static Terminals At(string level, int rows) + => level switch + { + "command" => Of(Command(rows).Map()), + "reader" => Of(Reader(Rows(rows)).Map()), + "commandView" => Of( + new FakeDbCommand( + new FakeDbConnection(), + () => Reader(Rows(rows))).Map()), + "table" => Of(Table(rows).Map()), + _ => throw new ArgumentOutOfRangeException(nameof(level)), + }; + + public static TheoryData Levels + => ["command", "reader", "commandView", "table"]; + + static void AssertNoRows(InvalidOperationException ex, string terminal) + { + Assert.Contains("no elements", ex.Message); + Assert.Contains("no rows", ex.Message); + Assert.Contains(terminal, ex.Message); + } + + static void AssertMoreThanOne(InvalidOperationException ex) + => Assert.Contains("more than one", ex.Message); + + [Theory, MemberData(nameof(Levels))] + public static async Task OneRow_FirstAndSingleReturnIt(string level) + { + Assert.Equal(1, At(level, 1).First().Id); + Assert.Equal(1, At(level, 1).Single().Id); + Assert.Equal("n1", (await At(level, 1).FirstAsync(Ct)).Name); + Assert.Equal("n1", (await At(level, 1).SingleAsync(Ct)).Name); + } + + [Theory, MemberData(nameof(Levels))] + public static async Task TwoRows_FirstReturnsTheFirst_SingleThrows( + string level) + { + Assert.Equal(1, At(level, 2).First().Id); + Assert.Equal(1, (await At(level, 2).FirstAsync(Ct)).Id); + + AssertMoreThanOne(Assert.Throws( + () => At(level, 2).Single())); + AssertMoreThanOne(await Assert.ThrowsAsync( + async () => await At(level, 2).SingleAsync(Ct))); + } + + [Theory, MemberData(nameof(Levels))] + public static async Task NoRows_BothThrow_AndTheMessageNamesTheTerminal( + string level) + { + AssertNoRows( + Assert.Throws( + () => At(level, 0).First()), + "First"); + AssertNoRows( + Assert.Throws( + () => At(level, 0).Single()), + "Single"); + AssertNoRows( + await Assert.ThrowsAsync( + async () => await At(level, 0).FirstAsync(Ct)), + "FirstAsync"); + AssertNoRows( + await Assert.ThrowsAsync( + async () => await At(level, 0).SingleAsync(Ct)), + "SingleAsync"); + } + + // ---- Skip and Take ---- + + [Fact] + public static void WithSkipAndTake_FirstAndSingleSeeOnlyTheWindow() + { + Assert.Equal(1, Command(3).Map().Take(1).Single()); // no second row is looked for + Assert.Equal(2, Command(3).Map().Skip(1).First()); + Assert.Equal(2, Command(3).Map().Skip(1).Take(1).Single()); + Assert.Equal(2, Command(3).Map().Skip(1).Take(2).First()); + Assert.Equal(3, Command(3).Map().Skip(2).Single()); + + AssertMoreThanOne(Assert.Throws( + () => Command(3).Map().Skip(1).Single())); + AssertMoreThanOne(Assert.Throws( + () => Command(3).Map().Take(2).Single())); + AssertNoRows( + Assert.Throws( + () => Command(3).Map().Skip(3).First()), + "First"); + AssertNoRows( + Assert.Throws( + () => Command(3).Map().Skip(3).Single()), + "Single"); + AssertNoRows( + Assert.Throws( + () => Command(3).Map().Take(0).First()), + "First"); + AssertNoRows( + Assert.Throws( + () => Command(3).Map().Take(0).Single()), + "Single"); + } + + [Fact] + public static async Task WithSkipAndTake_FirstAndSingleSeeOnlyTheWindow_Async() + { + Assert.Equal(1, await Command(3).Map().Take(1).SingleAsync(Ct)); + Assert.Equal(2, await Command(3).Map().Skip(1).FirstAsync(Ct)); + Assert.Equal( + 2, + await Command(3).Map().Skip(1).Take(1).SingleAsync(Ct)); + + AssertMoreThanOne(await Assert.ThrowsAsync( + async () => await Command(3).Map().Skip(1).SingleAsync(Ct))); + AssertNoRows( + await Assert.ThrowsAsync( + async () => await Command(3).Map().Skip(3).FirstAsync(Ct)), + "FirstAsync"); + AssertNoRows( + await Assert.ThrowsAsync( + async () => await Command(3).Map().Take(0).SingleAsync(Ct)), + "SingleAsync"); + } + + [Fact] + public static void First_ReadsOneRow_AndSingleReadsASecondOnlyToCheck() + { + TypedReader? reader = null; + FakeExecuteReader command = new(() => reader = Reader(Rows(3))); + + Assert.Equal(1, command.Map().First()); + Assert.Equal(1, reader!.RowsRead); + + Assert.Throws( + () => command.Map().Single()); + Assert.Equal(2, reader.RowsRead); + + Assert.Equal(1, command.Map().Take(1).Single()); + Assert.Equal(1, reader.RowsRead); + } + + // ---- a single value, and NULL ---- + + [Fact] + public static async Task ASingleValue_IsTheFirstColumn() + { + Assert.Equal(5, One(typeof(int), 5).Map().First()); + Assert.Equal(5, One(typeof(int), 5).Map().Single()); + Assert.Equal(5, await One(typeof(int), 5).Map().FirstAsync(Ct)); + Assert.Equal(5, await One(typeof(int), 5).Map().SingleAsync(Ct)); + Assert.Equal(1, Command(3).Map().First()); // Id is the first column + } + + [Fact] + public static async Task ANullValue_IsARow_NotNoRows() + { + Assert.Equal(0, One(typeof(int), null).Map().First()); + Assert.Equal(0, One(typeof(int), null).Map().Single()); + Assert.Null(One(typeof(int), null).Map().First()); + Assert.Null(One(typeof(int), null).Map().Single()); + Assert.Null(One(typeof(string), null).Map().First()); + Assert.Null(One(typeof(string), null).Map().Single()); + + Assert.Equal( + 0, + await One(typeof(int), null).Map().FirstAsync(Ct)); + Assert.Null( + await One(typeof(int), null).Map().SingleAsync(Ct)); + Assert.Null( + await One(typeof(string), null).Map().FirstAsync(Ct)); + Assert.Null( + await One(typeof(string), null).Map().SingleAsync(Ct)); + } + + // ---- cancellation ---- + + [Fact] + public static async Task ACanceledToken_Cancels_FirstAsyncAndSingleAsync() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await Command(1).Map().FirstAsync(cts.Token)); + await Assert.ThrowsAnyAsync( + async () => await Command(1).Map().SingleAsync(cts.Token)); + } + + [Fact] + public static async Task TheCommandsOwnToken_Cancels_Too() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + FakeExecuteReader command = Command(1); + command.CancellationToken = cts.Token; + + await Assert.ThrowsAnyAsync( + async () => await command.Map().FirstAsync(Ct)); + await Assert.ThrowsAnyAsync( + async () => await command.Map().SingleAsync(Ct)); + } + + [Fact] + public static async Task TheCallsToken_ReachesTheReader() + { + using var cts = new CancellationTokenSource(); + FakeExecuteReader command = Command(1); + + await command.Map().FirstAsync(cts.Token); + await command.Map().SingleAsync(cts.Token); + + Assert.Equal([cts.Token, cts.Token], command.ReaderTokens); + } + + // ---- later result sets ---- + + static int Run(MappedQuery query, string terminal) + => terminal == "First" ? query.First() : query.Single(); + + static async Task RunAsync( + MappedQuery query, + string terminal, + CancellationToken token) + => terminal == "First" + ? await query.FirstAsync(token) + : await query.SingleAsync(token); + + [Theory] + [InlineData("First")] + [InlineData("Single")] + public static async Task ARow_ReadsThroughTheLaterSets_ThenIsReturned( + string terminal) + { + FakeExecuteReader sync = Sets(1, out Func syncReader); + Assert.Equal(1, Run(Query(sync), terminal)); + Assert.Equal(LaterSets + 1, syncReader().NextResultCalls); + Assert.Equal(0, syncReader().ReadsAfterNextResult); + + FakeExecuteReader viaAsync = Sets(1, out Func asyncReader); + Assert.Equal(1, await RunAsync(Query(viaAsync), terminal, Ct)); + Assert.Equal(LaterSets + 1, asyncReader().NextResultCalls); + Assert.Equal(LaterSets + 1, asyncReader().AsyncCalls); + Assert.Equal(0, asyncReader().ReadsAfterNextResult); + } + + [Theory] + [InlineData("First")] + [InlineData("Single")] + public static async Task ALaterFailure_AfterARow_IsThrown(string terminal) + { + FakeExecuteReader sync = Sets( + 1, out Func syncReader, failAt: 2); + Assert.Throws(() => Run(Query(sync), terminal)); + Assert.Equal(2, syncReader().NextResultCalls); + + FakeExecuteReader viaAsync = Sets( + 1, out Func asyncReader, failAt: 1); + await Assert.ThrowsAsync( + async () => await RunAsync(Query(viaAsync), terminal, Ct)); + Assert.Equal(1, asyncReader().NextResultCalls); + } + + [Theory] + [InlineData("First")] + [InlineData("Single")] + public static async Task NoRows_ReadsThrough_ThenThrowsNoRows( + string terminal) + { + FakeExecuteReader sync = Sets(0, out Func syncReader); + Assert.Throws( + () => Run(Query(sync), terminal)); + Assert.Equal(LaterSets + 1, syncReader().NextResultCalls); + + FakeExecuteReader viaAsync = Sets(0, out Func asyncReader); + await Assert.ThrowsAsync( + async () => await RunAsync(Query(viaAsync), terminal, Ct)); + Assert.Equal(LaterSets + 1, asyncReader().NextResultCalls); + } + + // The no-rows error waits for the cleanup, so a later failure comes first. + [Theory] + [InlineData("First")] + [InlineData("Single")] + public static async Task NoRows_WithALaterFailure_ThrowsTheLaterFailure( + string terminal) + { + FakeExecuteReader sync = Sets( + 0, out Func syncReader, failAt: 2); + Assert.Throws(() => Run(Query(sync), terminal)); + Assert.Equal(2, syncReader().NextResultCalls); + + FakeExecuteReader viaAsync = Sets( + 0, out Func asyncReader, failAt: 1); + await Assert.ThrowsAsync( + async () => await RunAsync(Query(viaAsync), terminal, Ct)); + Assert.Equal(1, asyncReader().NextResultCalls); + } + + // Single's own error is thrown at the second row, before any read-through. + [Fact] + public static async Task Single_WithTwoRows_ThrowsMoreThanOne_NeverReplacedByALaterFailure() + { + FakeExecuteReader sync = Sets( + 2, out Func syncReader, failAt: 1); + AssertMoreThanOne(Assert.Throws( + () => Query(sync).Single())); + Assert.Equal(0, syncReader().NextResultCalls); + + FakeExecuteReader viaAsync = Sets( + 2, out Func asyncReader, failAt: 1); + AssertMoreThanOne(await Assert.ThrowsAsync( + async () => await Query(viaAsync).SingleAsync(Ct))); + Assert.Equal(0, asyncReader().NextResultCalls); + } + + [Theory] + [InlineData("First")] + [InlineData("Single")] + public static async Task SingleResult_ReadsNothingThrough(string terminal) + { + // One row and a failing later set: the row is returned. + FakeExecuteReader row = Sets( + 1, out Func rowReader, failAt: 1); + Assert.Equal(1, Run(Query(row).SingleResult(), terminal)); + Assert.Equal(0, rowReader().NextResultCalls); + + // No row: no-rows is the only error there can be. + FakeExecuteReader none = Sets( + 0, out Func noneReader, failAt: 1); + Assert.Throws( + () => Run(Query(none).SingleResult(), terminal)); + Assert.Equal(0, noneReader().NextResultCalls); + + FakeExecuteReader viaAsync = Sets( + 0, out Func asyncReader, failAt: 1); + await Assert.ThrowsAsync( + async () => await RunAsync( + Query(viaAsync).SingleResult(), terminal, Ct)); + Assert.Equal(0, asyncReader().NextResultCalls); + } + + [Fact] + public static async Task OnlyFirst_AsksTheProviderForOneRow_AndOnlyAfterSingleResult() + { + const CommandBehavior Result = CommandBehavior.SingleResult; + const CommandBehavior ResultAndRow + = CommandBehavior.SingleResult | CommandBehavior.SingleRow; + FakeExecuteReader command = Command(3); + + Query(command).First(); // nothing says one result set + Query(command).Skip(2).Single(); + Query(command).Take(1).Single(); + Query(command).SingleResult().First(); // nothing skipped: SingleRow + Query(command).Skip(1).SingleResult().First(); // a row is skipped first + Query(command).SingleResult().Take(1).Single(); // never SingleRow: it could hide a second row + await Query(command).SingleResult().FirstAsync(Ct); + await Query(command).SingleResult().Take(1).SingleAsync(Ct); + + Assert.Equal( + [ + CommandBehavior.Default, + CommandBehavior.Default, + CommandBehavior.Default, + ResultAndRow, + Result, + Result, + ResultAndRow, + Result, + ], + command.Behaviors); + } + + // ---- connection ownership: the throw leaves the connection as found ---- + + [Fact] + public static async Task TheCommandView_ClosesTheConnectionItOpened_WhenThereIsNoRow() + { + var connection = new FakeDbConnection(); + var command = new FakeDbCommand(connection, () => Reader(Rows(0))); + + Assert.Throws( + () => command.Map().First()); + Assert.Equal(["open", "close"], connection.Log); + Assert.Equal(ConnectionState.Closed, connection.State); + + await Assert.ThrowsAsync( + async () => await command.Map().SingleAsync(Ct)); + Assert.Equal(["open", "close", "open", "close"], connection.Log); + } + + [Fact] + public static async Task TheCommandView_LeavesAnOpenConnectionOpen_WhenThereIsNoRow() + { + var connection = new FakeDbConnection(); + connection.SetState(ConnectionState.Open); + var command = new FakeDbCommand(connection, () => Reader(Rows(0))); + + Assert.Throws( + () => command.Map().Single()); + await Assert.ThrowsAsync( + async () => await command.Map().FirstAsync(Ct)); + + Assert.Empty(connection.Log); + Assert.Equal(ConnectionState.Open, connection.State); + } + + [Fact] + public static async Task TheCommandView_ClosesTheConnectionItOpened_WhenSingleFindsTwo() + { + var connection = new FakeDbConnection(); + var command = new FakeDbCommand(connection, () => Reader(Rows(2))); + + Assert.Throws( + () => command.Map().Single()); + await Assert.ThrowsAsync( + async () => await command.Map().SingleAsync(Ct)); + + Assert.Equal(["open", "close", "open", "close"], connection.Log); + } + + // ---- a reader that is not a DbDataReader ---- + + static IDataReader Plain(params bool[] reads) + { + IDataReader reader = Substitute.For(); + reader.Read().Returns(reads[0], reads[1..]); + reader.NextResult().Returns(true, true, false); + return reader; + } + + [Fact] + public static async Task APlainIDataReader_IsReadTheSameWay() + { + Assert.Equal( + 7, + new PlainReaderCommand(() => Plain(true, false)) + .Map(_ => 7).First()); + Assert.Equal( + 7, + new PlainReaderCommand(() => Plain(true, false)) + .Map(_ => 7).Single()); + Assert.Equal( + 7, + await new PlainReaderCommand(() => Plain(true, true, false)) + .Map(_ => 7).FirstAsync(Ct)); + Assert.Equal( + 7, + await new PlainReaderCommand(() => Plain(true, false)) + .Map(_ => 7).SingleAsync(Ct)); + + Assert.Throws( + () => new PlainReaderCommand(() => Plain(false)) + .Map(_ => 7).First()); + Assert.Throws( + () => new PlainReaderCommand(() => Plain(true, true, false)) + .Map(_ => 7).Single()); + await Assert.ThrowsAsync( + async () => await new PlainReaderCommand(() => Plain(false)) + .Map(_ => 7).SingleAsync(Ct)); + await Assert.ThrowsAsync( + async () => await new PlainReaderCommand(() => Plain(true, true, false)) + .Map(_ => 7).SingleAsync(Ct)); + } + + [Fact] + public static void APlainIDataReader_NoRow_ReadsThroughBeforeItThrows() + { + IDataReader reader = Plain(false); + + Assert.Throws( + () => new PlainReaderCommand(() => reader).Map(_ => 7).First()); + + reader.Received(3).NextResult(); + } + + // ---- through IMappedQuery (the extensions, for generic code) ---- + + [Fact] + public static async Task ThroughTheInterface_TheExtensionsDoTheSame() + { + static IMappedQuery Q(int rows) => Command(rows).Map(); + + Assert.Equal(1, Q(2).First()); + Assert.Equal(1, Q(1).Single()); + Assert.Equal(1, await Q(2).FirstAsync(Ct)); + Assert.Equal(1, await Q(1).SingleAsync(Ct)); + + Assert.Throws(() => Q(0).First()); + Assert.Throws(() => Q(0).Single()); + Assert.Throws(() => Q(2).Single()); + await Assert.ThrowsAsync( + async () => await Q(0).FirstAsync(Ct)); + await Assert.ThrowsAsync( + async () => await Q(0).SingleAsync(Ct)); + await Assert.ThrowsAsync( + async () => await Q(2).SingleAsync(Ct)); + } + + [Fact] + public static async Task ThroughTheInterface_ANullQueryIsRefused() + { + IMappedQuery query = null!; + + static void Refused(Action call) + => Assert.Equal( + "query", + Assert.Throws(call).ParamName); + + static async Task RefusedAsync(Func call) + => Assert.Equal( + "query", + (await Assert.ThrowsAsync(call)).ParamName); + + Refused(() => query.First()); + Refused(() => query.Single()); + Refused(() => query.FirstOrDefault()); + Refused(() => query.SingleOrDefault()); + await RefusedAsync(async () => await query.FirstAsync(Ct)); + await RefusedAsync(async () => await query.SingleAsync(Ct)); + await RefusedAsync(async () => await query.FirstOrDefaultAsync(Ct)); + await RefusedAsync(async () => await query.SingleOrDefaultAsync(Ct)); + } + + // ---- through IMappedQuery: the six library types, eight terminals ---- + + // The extensions on the interface are the same call as the members for + // the library types: the same no-rows message, and the later + // result sets are read through. Only another implementation of + // IMappedQuery falls back to LINQ over its Read handler. + + public static TheoryData InterfaceLevels + => ["query", "skip", "take", "reader", "commandView", "table"]; + + // The levels that read through the later result sets. + public static TheoryData ReadThroughLevels + => ["query", "skip", "take", "commandView"]; + + static MultiSetReader SetsReader(int rows, int? failAt) + => new(Rows(rows), LaterSets, failAt); + + static MappedQuery QueryOf(int rows, int? failAt) + => Sets(rows, out _, failAt).Map(); + + static MappedCommand CommandOf(int rows, int? failAt) + => new FakeDbCommand( + new FakeDbConnection(), + () => SetsReader(rows, failAt)).Map(); + + // The query as its own type, for the members. + static Terminals MembersAt(string level, int rows, int? failAt) + => level switch + { + "query" => Of(QueryOf(rows, failAt)), + "skip" => Of(QueryOf(rows, failAt).Skip(0)), + "take" => Of(QueryOf(rows, failAt).Take(10)), + "reader" => Of(SetsReader(rows, failAt).Map()), + "commandView" => Of(CommandOf(rows, failAt)), + "table" => Of(Table(rows).Map()), + _ => throw new ArgumentOutOfRangeException(nameof(level)), + }; + + // The same query as an IMappedQuery, for the extensions. + static IMappedQuery BoxedAt(string level, int rows, int? failAt) + => level switch + { + "query" => QueryOf(rows, failAt), + "skip" => QueryOf(rows, failAt).Skip(0), + "take" => QueryOf(rows, failAt).Take(10), + "reader" => SetsReader(rows, failAt).Map(), + "commandView" => CommandOf(rows, failAt), + "table" => Table(rows).Map(), + _ => throw new ArgumentOutOfRangeException(nameof(level)), + }; + + static string Describe(object? result) + => result is Person person ? $"row {person.Id}" : "null"; + + static string Outcome(Func run) + { + try + { + return Describe(run()); + } + catch (Exception ex) + { + return $"{ex.GetType().Name}: {ex.Message}"; + } + } + + static async Task OutcomeAsync(Func> run) + { + try + { + return Describe(await run()); + } + catch (Exception ex) + { + return $"{ex.GetType().Name}: {ex.Message}"; + } + } + + // What each of the eight terminals did, in the order of + // First, Single, FirstOrDefault, SingleOrDefault and their async twins. + // A new source for every call: a reader view is spent once it is read. + static async Task Outcomes(Func fresh) + => [ + Outcome(() => fresh().First()), + Outcome(() => fresh().Single()), + Outcome(() => fresh().FirstOrDefault()), + Outcome(() => fresh().SingleOrDefault()), + await OutcomeAsync(() => fresh().FirstAsync(Ct)), + await OutcomeAsync(() => fresh().SingleAsync(Ct)), + await OutcomeAsync(() => fresh().FirstOrDefaultAsync(Ct)), + await OutcomeAsync(() => fresh().SingleOrDefaultAsync(Ct)), + ]; + + // Asserts the interface did what the members did, and returns it. + static async Task SameAsTheMembers( + string level, + int rows, + int? failAt = null) + { + string[] members = await Outcomes( + () => MembersAt(level, rows, failAt)); + string[] boxed = await Outcomes( + () => OfBoxed(BoxedAt(level, rows, failAt))); + + Assert.Equal(members, boxed); + return boxed; + } + + [Theory, MemberData(nameof(InterfaceLevels))] + public static async Task ThroughTheInterface_EveryTerminalDoesWhatTheMembersDo( + string level) + { + await SameAsTheMembers(level, 0); + await SameAsTheMembers(level, 1); + await SameAsTheMembers(level, 2); + await SameAsTheMembers(level, 1, failAt: 1); + await SameAsTheMembers(level, 1, failAt: 2); + await SameAsTheMembers(level, 0, failAt: 1); + await SameAsTheMembers(level, 2, failAt: 1); + } + + [Theory, MemberData(nameof(InterfaceLevels))] + public static async Task ThroughTheInterface_NoRows_ThrowTheLibraryMessage_NamingTheTerminal( + string level) + { + string[] outcomes = await SameAsTheMembers(level, 0); + + Assert.StartsWith("InvalidOperationException: ", outcomes[0]); + Assert.Contains("no rows, and First needs one", outcomes[0]); + Assert.Contains("no rows, and Single needs one", outcomes[1]); + Assert.Equal("null", outcomes[2]); + Assert.Equal("null", outcomes[3]); + Assert.Contains("no rows, and FirstAsync needs one", outcomes[4]); + Assert.Contains("no rows, and SingleAsync needs one", outcomes[5]); + Assert.Equal("null", outcomes[6]); + Assert.Equal("null", outcomes[7]); + } + + [Theory, MemberData(nameof(InterfaceLevels))] + public static async Task ThroughTheInterface_Rows_AreFirstSingleOrTooMany( + string level) + { + Assert.All( + await SameAsTheMembers(level, 1), + o => Assert.Equal("row 1", o)); + + string[] two = await SameAsTheMembers(level, 2); + Assert.Equal("row 1", two[0]); + Assert.Contains("more than one", two[1]); + Assert.Equal("row 1", two[2]); + Assert.Contains("more than one", two[3]); + Assert.Equal("row 1", two[4]); + Assert.Contains("more than one", two[5]); + Assert.Equal("row 1", two[6]); + Assert.Contains("more than one", two[7]); + } + + // The failure of a later result set is thrown through the interface too: + // the terminals stop at the row, then read the later sets through. + [Theory, MemberData(nameof(ReadThroughLevels))] + public static async Task ThroughTheInterface_ALaterFailure_IsThrown_ForEveryTerminal( + string level) + { + foreach (int rows in new[] { 1, 0 }) + { + foreach (int failAt in new[] { 1, 2 }) + { + Assert.All( + await SameAsTheMembers(level, rows, failAt), + o => Assert.StartsWith("LaterSetFailure: ", o)); + } + } + } + + // A reader view is never read through, and a table has one result set. + [Theory] + [InlineData("reader")] + [InlineData("table")] + public static async Task ThroughTheInterface_AReaderViewOrATable_IsNotReadThrough( + string level) + => Assert.All( + await SameAsTheMembers(level, 1, failAt: 1), + o => Assert.Equal("row 1", o)); + + // The later sets are walked in full after the row, as for the members. + [Fact] + public static async Task ThroughTheInterface_ReadsThroughTheLaterSets_ThenReturns() + { + FakeExecuteReader sync = Sets(1, out Func syncReader); + IMappedQuery boxed = sync.Map(); + Assert.Equal(1, boxed.First().Id); + Assert.Equal(LaterSets + 1, syncReader().NextResultCalls); + Assert.Equal(0, syncReader().ReadsAfterNextResult); + + FakeExecuteReader viaAsync = Sets(1, out Func asyncReader); + IMappedQuery boxedAsync = viaAsync.Map(); + Assert.Equal(1, (await boxedAsync.SingleAsync(Ct)).Id); + Assert.Equal(LaterSets + 1, asyncReader().NextResultCalls); + Assert.Equal(LaterSets + 1, asyncReader().AsyncCalls); + Assert.Equal(0, asyncReader().ReadsAfterNextResult); + } + + // ---- another implementation of IMappedQuery: the LINQ fallback ---- + + // A tiny list-backed query that is not one of the library types. + sealed class ListQuery(params T[] rows) : IMappedQuery + { + public int Enumerated { get; private set; } + + IEnumerable Items() + { + foreach (T row in rows) + { + Enumerated++; + yield return row; + } + } + + async IAsyncEnumerable ItemsAsync() + { + foreach (T row in Items()) + { + await Task.Yield(); + yield return row; + } + } + + public void Read(Action> handler) + => handler(Items()); + + public TResult Read(Func, TResult> handler) + => handler(Items()); + + public async ValueTask ReadAsync( + Func, CancellationToken, ValueTask> handler, + CancellationToken cancellationToken) + => await handler(ItemsAsync(), cancellationToken); + + public async ValueTask ReadAsync( + Func< + IAsyncEnumerable, + CancellationToken, + ValueTask> handler, + CancellationToken cancellationToken) + => await handler(ItemsAsync(), cancellationToken); + } + + static Person Row(int id) + => new() { Id = id }; + + [Fact] + public static async Task AnotherImplementation_StillWorks_ThroughTheLinqFallback() + { + static IMappedQuery Q(int rows) + => new ListQuery( + [.. Enumerable.Range(1, rows).Select(Row)]); + + Assert.Equal(1, Q(3).First().Id); + Assert.Equal(1, Q(1).Single().Id); + Assert.Equal(1, Q(3).FirstOrDefault()?.Id); + Assert.Equal(1, Q(1).SingleOrDefault()?.Id); + Assert.Equal(1, (await Q(3).FirstAsync(Ct)).Id); + Assert.Equal(1, (await Q(1).SingleAsync(Ct)).Id); + Assert.Equal(1, (await Q(3).FirstOrDefaultAsync(Ct))?.Id); + Assert.Equal(1, (await Q(1).SingleOrDefaultAsync(Ct))?.Id); + + Assert.Null(Q(0).FirstOrDefault()); + Assert.Null(Q(0).SingleOrDefault()); + Assert.Null(await Q(0).FirstOrDefaultAsync(Ct)); + Assert.Null(await Q(0).SingleOrDefaultAsync(Ct)); + + // No rows, and more than one: an InvalidOperationException, but the + // fallback's message is LINQ's and does not name the terminal. + Assert.Throws(() => Q(0).First()); + Assert.Throws(() => Q(0).Single()); + Assert.Throws(() => Q(2).Single()); + Assert.Throws(() => Q(2).SingleOrDefault()); + await Assert.ThrowsAsync( + async () => await Q(0).FirstAsync(Ct)); + await Assert.ThrowsAsync( + async () => await Q(0).SingleAsync(Ct)); + await Assert.ThrowsAsync( + async () => await Q(2).SingleAsync(Ct)); + await Assert.ThrowsAsync( + async () => await Q(2).SingleOrDefaultAsync(Ct)); + } + + [Fact] + public static void AnotherImplementation_FirstStopsAtTheFirstRow() + { + ListQuery three = new(Row(1), Row(2), Row(3)); + + Assert.Equal(1, ((IMappedQuery)three).First().Id); + Assert.Equal(1, three.Enumerated); + } + + // ---- the command shortcuts ---- + + [Fact] + public static async Task TheCommandShortcuts_AreMapThenTheTerminal() + { + Assert.Equal(1, await Command(2).FirstAsync(Ct)); + Assert.Equal(1, await Command(1).SingleAsync(Ct)); + Assert.Equal(1, (await Command(2).FirstAsync(Ct)).Id); + Assert.Equal("n1", (await Command(1).SingleAsync(Ct)).Name); + + await Assert.ThrowsAsync( + async () => await Command(0).FirstAsync(Ct)); + await Assert.ThrowsAsync( + async () => await Command(0).SingleAsync(Ct)); + await Assert.ThrowsAsync( + async () => await Command(2).SingleAsync(Ct)); + } + + // ---- overload resolution (compile-only) ---- + +#pragma warning disable xUnit1051 // The point is the forms that pass no token. + [Fact] + public static async Task TheNewMembers_DoNotMakeNeighbouringCallsAmbiguous() + { + FakeExecuteReader command = Command(2); + + // The transform-era terminals on a command still bind to themselves. + Assert.Equal(1, command.First(r => r.GetInt32(0))); + Assert.Throws( + () => command.Single(r => r.GetInt32(0))); + + // Every form of the new ones. + MappedQuery q = command.Map(); + Assert.Equal(1, q.First()); + Assert.Equal(1, await q.FirstAsync()); + Assert.Equal(1, await q.FirstAsync(Ct)); + Assert.Equal(1, await q.FirstAsync(cancellationToken: Ct)); + Assert.Equal(1, await q.Skip(0).FirstAsync()); + Assert.Equal(1, await q.Skip(0).Take(1).SingleAsync()); + Assert.Equal(1, await command.FirstAsync()); + Assert.Equal(1, await command.FirstAsync(Ct)); + IMappedQuery boxed = q; + Assert.Equal(1, boxed.First()); + Assert.Equal(1, await boxed.FirstAsync()); + Assert.Throws(() => boxed.Single()); + } +#pragma warning restore xUnit1051 +} diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedQueryReadThroughTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedQueryReadThroughTests.cs index 64a204a..be83826 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/MappedQueryReadThroughTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedQueryReadThroughTests.cs @@ -27,7 +27,7 @@ static FakeExecuteReader Command(int rows, out Func reader, int? // ---- every terminal ---- public static TheoryData Terminals - => ["ToList", "ToArray", "ToImmutableArray", "FirstOrDefault", "SingleOrDefault", "ToDictionary", "ToHashSet", "Read", "ReadResult"]; + => ["ToList", "ToArray", "ToImmutableArray", "FirstOrDefault", "SingleOrDefault", "First", "Single", "ToDictionary", "ToDictionaryValues", "ToHashSet", "Read", "ReadResult"]; static void RunSync(MappedQuery query, string terminal) { @@ -38,7 +38,10 @@ static void RunSync(MappedQuery query, string terminal) case "ToImmutableArray": query.ToImmutableArray(); break; case "FirstOrDefault": query.FirstOrDefault(); break; case "SingleOrDefault": query.SingleOrDefault(); break; + case "First": query.First(); break; + case "Single": query.Single(); break; case "ToDictionary": query.ToDictionary(i => i); break; + case "ToDictionaryValues": query.ToDictionary(i => i, i => i * 2); break; case "ToHashSet": query.ToHashSet(); break; case "Read": query.Read(items => { foreach (int _ in items) { } }); break; case "ReadResult": query.Read(items => items.Count()); break; @@ -55,7 +58,10 @@ static async Task RunAsync(MappedQuery query, string terminal, Cancellation case "ToImmutableArray": await query.ToImmutableArrayAsync(token); break; case "FirstOrDefault": await query.FirstOrDefaultAsync(token); break; case "SingleOrDefault": await query.SingleOrDefaultAsync(token); break; + case "First": await query.FirstAsync(token); break; + case "Single": await query.SingleAsync(token); break; case "ToDictionary": await query.ToDictionaryAsync(i => i, null, token); break; + case "ToDictionaryValues": await query.ToDictionaryAsync(i => i, i => i * 2, null, token); break; case "ToHashSet": await query.ToHashSetAsync(null, token); break; case "Read": await query.ReadAsync(async (items, t) => { await foreach (int _ in items.WithCancellation(t)) { } }, token); break; case "ReadResult": await query.ReadAsync(async (items, t) => { int n = 0; await foreach (int _ in items.WithCancellation(t)) n++; return n; }, token); break; diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs index fefbce6..8a15a0f 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs @@ -25,7 +25,7 @@ internal sealed record Person [ "ToList", "ToArray", "ToImmutableArray", "FirstOrDefault", "SingleOrDefault", "ToDictionary", "ToHashSet", "Read", "ReadResult", - "Skip", "Take", "SkipTake", + "Skip", "Take", "SkipTake", "First", "Single", "ToDictionaryValues", ]; static readonly string[] Sets = ["three", "nulls", "one", "empty"]; @@ -416,6 +416,9 @@ static string Run(MappedQuery q, string terminal) "ToImmutableArray" => q.ToImmutableArray(), "FirstOrDefault" => q.FirstOrDefault(), "SingleOrDefault" => q.SingleOrDefault(), + "First" => q.First(), + "Single" => q.Single(), + "ToDictionaryValues" => q.ToDictionary(p => p.Id, p => p.Name), "ToDictionary" => q.ToDictionary(p => p.Id), "ToHashSet" => q.ToHashSet(), "Read" => ReadAll(q), @@ -447,6 +450,10 @@ static async Task RunAsync( "ToImmutableArray" => await q.ToImmutableArrayAsync(ct), "FirstOrDefault" => await q.FirstOrDefaultAsync(ct), "SingleOrDefault" => await q.SingleOrDefaultAsync(ct), + "First" => await q.FirstAsync(ct), + "Single" => await q.SingleAsync(ct), + "ToDictionaryValues" => await q.ToDictionaryAsync( + p => p.Id, p => p.Name, null, ct), "ToDictionary" => await q.ToDictionaryAsync(p => p.Id, null, ct), "ToHashSet" => await q.ToHashSetAsync(null, ct), "Read" => await ReadAllAsync(q, ct), @@ -495,6 +502,9 @@ static string Run(MappedReader q, string terminal) "ToImmutableArray" => q.ToImmutableArray(), "FirstOrDefault" => q.FirstOrDefault(), "SingleOrDefault" => q.SingleOrDefault(), + "First" => q.First(), + "Single" => q.Single(), + "ToDictionaryValues" => q.ToDictionary(p => p.Id, p => p.Name), "ToDictionary" => q.ToDictionary(p => p.Id), "ToHashSet" => q.ToHashSet(), "Read" => ReadAll(q), @@ -526,6 +536,10 @@ static async Task RunAsync( "ToImmutableArray" => await q.ToImmutableArrayAsync(ct), "FirstOrDefault" => await q.FirstOrDefaultAsync(ct), "SingleOrDefault" => await q.SingleOrDefaultAsync(ct), + "First" => await q.FirstAsync(ct), + "Single" => await q.SingleAsync(ct), + "ToDictionaryValues" => await q.ToDictionaryAsync( + p => p.Id, p => p.Name, null, ct), "ToDictionary" => await q.ToDictionaryAsync(p => p.Id, null, ct), "ToHashSet" => await q.ToHashSetAsync(null, ct), "Read" => await ReadAllAsync(q, ct), @@ -574,6 +588,9 @@ static string Run(MappedCommand q, string terminal) "ToImmutableArray" => q.ToImmutableArray(), "FirstOrDefault" => q.FirstOrDefault(), "SingleOrDefault" => q.SingleOrDefault(), + "First" => q.First(), + "Single" => q.Single(), + "ToDictionaryValues" => q.ToDictionary(p => p.Id, p => p.Name), "ToDictionary" => q.ToDictionary(p => p.Id), "ToHashSet" => q.ToHashSet(), "Read" => ReadAll(q), @@ -605,6 +622,10 @@ static async Task RunAsync( "ToImmutableArray" => await q.ToImmutableArrayAsync(ct), "FirstOrDefault" => await q.FirstOrDefaultAsync(ct), "SingleOrDefault" => await q.SingleOrDefaultAsync(ct), + "First" => await q.FirstAsync(ct), + "Single" => await q.SingleAsync(ct), + "ToDictionaryValues" => await q.ToDictionaryAsync( + p => p.Id, p => p.Name, null, ct), "ToDictionary" => await q.ToDictionaryAsync(p => p.Id, null, ct), "ToHashSet" => await q.ToHashSetAsync(null, ct), "Read" => await ReadAllAsync(q, ct), diff --git a/docsite/articles/coming-from-dapper.md b/docsite/articles/coming-from-dapper.md index 8796c89..1e1f220 100644 --- a/docsite/articles/coming-from-dapper.md +++ b/docsite/articles/coming-from-dapper.md @@ -71,7 +71,7 @@ Lines you write, counted from `dapper_first_call` and `ode_first_call`: Dapper 3 What is different: -- **No rows, or several.** Dapper's `QueryFirstOrDefault` returns `default` for no rows and the first row for several. `QuerySingleOrDefault` returns `default` for none and throws for more than one. `QueryFirst` and `QuerySingle` throw when there is no row. The extensions' `FirstOrDefault()` returns `null` for no rows and the first row for several; `SingleOrDefault()` returns `null` for none and throws `InvalidOperationException` for more than one. There is no throwing `First` or `Single`. +- **No rows, or several.** Dapper's `QueryFirstOrDefault` returns `default` for no rows and the first row for several. `QuerySingleOrDefault` returns `default` for none and throws for more than one. `QueryFirst` and `QuerySingle` throw when there is no row. The extensions' `FirstOrDefault()` returns `null` for no rows and the first row for several; `SingleOrDefault()` returns `null` for none and throws `InvalidOperationException` for more than one. `First()` throws `InvalidOperationException` for no rows and returns the first row for several; `Single()` throws for none and for more than one. ### A scalar @@ -273,7 +273,7 @@ With the extensions, use `Map(selector)` or `ExecuteReader(reader => ...)` for t |---|---| | `Query` | `Map().ToList()` (or `ToArray()`) | | `QueryFirstOrDefault`, `QuerySingleOrDefault` | `Map().FirstOrDefault()`, `Map().SingleOrDefault()` | -| `QueryFirst`, `QuerySingle` | No throwing form: call `FirstOrDefault()` or `SingleOrDefault()` and check for `null` | +| `QueryFirst`, `QuerySingle` | `Map().First()`, `Map().Single()` | | `Query` over one column | `Map().ToList()`: the first column, converted like a property (a `long` into `int` is range-checked); a NULL gives 0, so declare `int?` to tell it from 0. `FirstOrdinalResults()` returns the provider's own type instead (`long` for an integer on SQLite) | | `ExecuteScalar` | `ExecuteScalar()`; it converts the value to `T` as `Map()` does | | `Execute` | `ExecuteNonQuery()` | diff --git a/docsite/articles/expressive-commands.md b/docsite/articles/expressive-commands.md index 9604db2..e6fb3ef 100644 --- a/docsite/articles/expressive-commands.md +++ b/docsite/articles/expressive-commands.md @@ -122,7 +122,7 @@ Output values cannot be read back yet. You can send an output parameter (`AddPar | `Execute(command => ...)` | Whatever your function returns, given the raw command with its text, parameters and timeout applied: use it for a call the other terminals do not wrap | | `ExecuteReader(reader => ...)` | Whatever your function returns, given the live reader | | `Map()` then a terminal | Mapped objects: see [Mapping rows to objects](mapping.md) | -| `ToListAsync()`, `ToArrayAsync()`, `ToImmutableArrayAsync()`, `FirstOrDefaultAsync()`, `SingleOrDefaultAsync()`, `ToHashSetAsync()` | `Map()` then that async terminal (for a synchronous read write `Map().ToList()`); a single value such as `int` reads the first column: [One value per row](mapping.md#one-value-per-row) | +| `ToListAsync()`, `ToArrayAsync()`, `ToImmutableArrayAsync()`, `FirstAsync()`, `FirstOrDefaultAsync()`, `SingleAsync()`, `SingleOrDefaultAsync()`, `ToHashSetAsync()` | `Map()` then that async terminal (for a synchronous read write `Map().ToList()`); a single value such as `int` reads the first column: [One value per row](mapping.md#one-value-per-row) | | `ToChannel()`, `ToTargetBlock()` | See [Channels and Dataflow](channels-and-dataflow.md) | The terminals that take the type read one column as easily as a model; the token goes last: diff --git a/docsite/articles/mapping.md b/docsite/articles/mapping.md index 65d31c2..98ac412 100644 --- a/docsite/articles/mapping.md +++ b/docsite/articles/mapping.md @@ -119,7 +119,7 @@ A type that cannot be mapped throws `InvalidOperationException` at the call, bef ## One value per row -On a command, `Map()` reads the first column of each row as one value, and the terminals of `Map()` work as for a row model: `db.Command(sql).Map().ToList()`. The async terminals are also on the command itself, taking the type: `db.Command(sql).ToArrayAsync(ct)` is `db.Command(sql).Map().ToArrayAsync(ct)` (`ToListAsync`, `ToArrayAsync`, `ToImmutableArrayAsync`, `ToHashSetAsync`, `FirstOrDefaultAsync`, `SingleOrDefaultAsync`). For a row model the same terminals map by column name, so `ToListAsync(ct)` is `Map().ToListAsync(ct)`. There is no synchronous shortcut on the command: write `Map().ToList()`. +On a command, `Map()` reads the first column of each row as one value, and the terminals of `Map()` work as for a row model: `db.Command(sql).Map().ToList()`. The async terminals are also on the command itself, taking the type: `db.Command(sql).ToArrayAsync(ct)` is `db.Command(sql).Map().ToArrayAsync(ct)` (`ToListAsync`, `ToArrayAsync`, `ToImmutableArrayAsync`, `ToHashSetAsync`, `FirstAsync`, `FirstOrDefaultAsync`, `SingleAsync`, `SingleOrDefaultAsync`). For a row model the same terminals map by column name, so `ToListAsync(ct)` is `Map().ToListAsync(ct)`. There is no synchronous shortcut on the command: write `Map().ToList()`. [!code-csharp[](../samples/Mapping.cs#map_single_value)] @@ -237,10 +237,18 @@ Some columns are not the type the model wants: a number the model keeps as text, ## Terminals -`ToArray`, `ToList`, `ToImmutableArray`, `ToDictionary`, `ToHashSet`, `FirstOrDefault`, `SingleOrDefault` and `Read`, each with an `Async` twin. There is no throwing `First` or `Single`: the `OrDefault` forms return `null` for no rows. Prefer `ToArray` for results you only read: it allocates one exactly sized array, and an empty result is the shared empty array. Use `ToList` when you add to or remove from the result afterwards. +`ToArray`, `ToList`, `ToImmutableArray`, `ToDictionary`, `ToHashSet`, `First`, `FirstOrDefault`, `Single`, `SingleOrDefault` and `Read`, each with an `Async` twin. `First` and `Single` throw `InvalidOperationException` when the query returns no row (and `Single` when it returns more than one); the `OrDefault` forms return `null` for no rows. A row whose value is `NULL` is still a row. Prefer `ToArray` for results you only read: it allocates one exactly sized array, and an empty result is the shared empty array. Use `ToList` when you add to or remove from the result afterwards. Called through `IMappedQuery` (in generic code), `First`, `FirstOrDefault`, `Single` and `SingleOrDefault` do the same as the members of every `Map` type of the library, the no-rows message and the read-through included; another implementation of the interface gets a LINQ fallback that stops at the row, so it cannot read through the later result sets. [!code-csharp[](../samples/Mapping.cs#map_first_single)] +An insert that returns its new id is one `SingleAsync`: no handler, and no check for the row you expect, because it throws unless there is exactly one. The failure is thrown after the connection is released and the later result sets are read, so a failure in one of them comes first. + +[!code-csharp[](../samples/Mapping.cs#map_single_insert)] + +`ToDictionary` keys the rows by a key selector. Add a value selector to keep something other than the whole row, such as an id-to-text lookup, built in one pass: + +[!code-csharp[](../samples/Mapping.cs#map_dictionary_values)] + `Skip` and `Take` are part of the query: skipped rows are advanced without being mapped, and reading stops once the take is satisfied. Call `Skip` before `Take`, as the types enforce: [!code-csharp[](../samples/Mapping.cs#map_paging)] @@ -293,11 +301,11 @@ A *result* here is one whole result set: what `NextResult` moves between. It is `Map` returns the rows of the first result set only. Later sets are never mapped. After the rows are read, the terminal reads through the later sets without reading their rows, so a failure in a later statement (a stored procedure or a text batch that fails after its first `SELECT`) is thrown to you as the provider raised it, not lost when the reader is disposed. A trailing status set does not throw and adds no rows. -Two cases do not read through, so a later failure goes unseen: `.SingleResult()` (before `Map`, or after it), and a `Read` or an enumeration you stopped early (a `break` out of the rows, `First()` on them) or that ended in an exception of yours (the mapping, your selector, `SingleOrDefault` finding two rows, your handler). Your own exception is never replaced by a later one. `FirstOrDefault` and `Take` are not stopping early: they return their rows and still read through, and the provider discards the first set's unread rows on the way. +Two cases do not read through, so a later failure goes unseen: `.SingleResult()` (before `Map`, or after it), and a `Read` or an enumeration you stopped early (a `break` out of the rows, `First()` on them) or that ended in an exception of yours (the mapping, your selector, `Single` or `SingleOrDefault` finding two rows, your handler). Your own exception is never replaced by a later one. `First`, `FirstOrDefault` and `Take` are not stopping early: they return their rows and still read through (also through `IMappedQuery`), and the provider discards the first set's unread rows on the way. The deprecated `Results` on an `IExecuteReader`, the selector `FirstOrDefault`, `SingleOrDefault`, `Take`, `Skip` and `SkipThenTake`, the `ToList`, `ToArray`, `ToImmutableArray` and `IterateReader` with a transform, `ExpressiveDbCommand.TakeAsync` and `ToListAsync(selector)`, and `Retrieve().DequeueAs()` read only the first result set. `Map` reads through the rest, and `command.SingleResult().Map(...)` restores the old behavior. See [Deprecated in 11.0](whats-new-11.md#deprecated-in-110). -`.SingleResult()` goes before `Map`, on an expressive command and on a raw `IDbCommand` or `DbCommand`: `command.SingleResult().Map()`. It reads as behavior first, then shape. `command.Map().SingleResult()` still works (and so does it on the result of `Skip` and `Take`): the two forms are the same. It asserts nothing: it does not check that there is only one result set. It stops after the first, so a later failure is not seen, and the reader is asked for `SingleResult` (`SingleRow` too for `FirstOrDefault` when nothing is skipped), which lets the provider stop early: +`.SingleResult()` goes before `Map`, on an expressive command and on a raw `IDbCommand` or `DbCommand`: `command.SingleResult().Map()`. It reads as behavior first, then shape. `command.Map().SingleResult()` still works (and so does it on the result of `Skip` and `Take`): the two forms are the same. It asserts nothing: it does not check that there is only one result set. It stops after the first, so a later failure is not seen, and the reader is asked for `SingleResult` (`SingleRow` too for `First` and `FirstOrDefault` when nothing is skipped), which lets the provider stop early: ```csharp var ids = await command.SingleResult().Map().ToListAsync(); @@ -310,4 +318,4 @@ A reader and a `DataTable` have no `.SingleResult()` before `Map`: their views n ## How it works -The mapping from a result-set shape (column names and column types) to `T` is compiled once and cached, so a repeated query does no reflection and no compilation. The reader is opened with `SequentialAccess`, and with `SingleResult` (plus `SingleRow` for `FirstOrDefault` when nothing is skipped) only after `.SingleResult()`; without it the reader is asked for every result set, to read through them. `Map(selector)` does not use `SequentialAccess`, because your function may read columns in any order. +The mapping from a result-set shape (column names and column types) to `T` is compiled once and cached, so a repeated query does no reflection and no compilation. The reader is opened with `SequentialAccess`, and with `SingleResult` (plus `SingleRow` for `First` and `FirstOrDefault` when nothing is skipped) only after `.SingleResult()`; without it the reader is asked for every result set, to read through them. `Map(selector)` does not use `SequentialAccess`, because your function may read columns in any order. diff --git a/docsite/articles/whats-new-11.md b/docsite/articles/whats-new-11.md index 4debf78..54a8053 100644 --- a/docsite/articles/whats-new-11.md +++ b/docsite/articles/whats-new-11.md @@ -12,6 +12,8 @@ Version 11 is a performance release and a tidy-up release. Result mapping is rew - **`ExecuteScalar()` and `ExecuteScalarAsync()` convert the way `Map()` does.** They cast the provider's value exactly, so a `bigint` read as `int`, or a `numeric` read as `long`, threw `InvalidCastException`, which a port from Dapper met only on a real database. Now a value that is already a `T` is returned as it is, an integer converts to another integer type with a range check, and any other value is converted by the same code `Map()` uses. `NULL` and no row still give `default(T)`. A value that cannot be converted, or does not fit, throws `ValueMappingException` with the conversion's error inside. **Behavior change:** a call that threw `InvalidCastException` now converts, or throws that exception. `ExecuteReturn()` and `ExecuteReturnAsync()` convert the same way. The overloads that take a `Func` are unchanged. - `Select(selector)` on an `IMappedQuery` (what `Map()` returns at every level) is a lazy projection that returns an `IMappedQuery`: building it runs nothing, and each terminal you pick runs the command once and the selector once per row. A data class can return the query and let the caller choose `ToListAsync`, `FirstOrDefaultAsync` or any other terminal. See [Query factories](mapping.md#query-factories-select). - A `RowMap` takes a converter for a column that needs a trim, a parse or a format on its way to a member: `map.Convert(nameof(Order.OrderNum), n => n.ToString("D6"))`, or `Convert(member, column, convert)` to name the column as well. It returns a new map, and the delegate runs inside the compiled row reader, so a result maps straight into the type you want, with no row type to copy from. A converter is not called for a NULL cell (the member follows the NULL rules); a `Nullable<>` `TColumn` is called with null. See [Converting a column](mapping.md#converting-a-column). +- `First()` and `Single()` (and `FirstAsync(cancellationToken)`, `SingleAsync(cancellationToken)`) on `Map()` throw `InvalidOperationException` when the query returns no row, and `Single` also when it returns more than one. An insert that returns its new id is one `await command.Map().SingleAsync(cancellationToken)`, with no handler to check the row count. The commands have the shortcuts too: `command.SingleAsync(cancellationToken)`. A `NULL` value is still a row. The error for no rows is thrown after the connection is released and the later result sets are read, so a failure in one of them comes first. See [Terminals](mapping.md#terminals). +- `ToDictionary` and `ToDictionaryAsync` on `Map()` take a value selector: `ToDictionaryAsync(post => post.Id, post => post.Body ?? "")` builds the lookup in one pass, without keeping the rows. A duplicate key throws, as it does without the value selector. ## Breaking in rc.4 @@ -188,7 +190,6 @@ The old calls read only the first result set. `Map` reads through the later ones - `Retrieve()` followed by `DequeueAs()` is deprecated in 11.0 (a two-pass route, slower than `Map()`): use `Map().ToList()`. See [Deprecated in 11.0](#deprecated-in-110). - The unmapped `ToChannel` overloads can still lose the row that was in flight when a read fails while the target is full. The cause is in the underlying channel library. - Output parameters can be sent, but their values cannot be read back after the call. Providers other than SQL Server supply no real parameter names, so `AddParam(search.Author)` uses the last member as written there. -- `MappedQuery` has `FirstOrDefault` and `SingleOrDefault` but no throwing `First` or `Single`: check the result for `null`. - A missing column for a `required` property is reported by `Map` at the first row, so an empty result does not report it. `DataTable.To()` (deprecated), `ToChannel` and the Dataflow methods know the columns before any row and report it even when no row follows. (`DequeueAs()` did too; it is deprecated.) - The synchronous `ToTargetBlock` overloads post each row without waiting: when the target is complete, or bounded and full, or when the internal 1,024-row buffer of the mapped overloads is full, reading stops without an error, and only the number the call returns (the rows read and handed over) shows the shortfall. Use the `Async` overload to feed a bounded target. - `AddParams(source)` sends every public member of `source` that the procedure declares, so pass an object you trust, and add a value that only the server may decide (a tenant, a deleted-rows flag) with `AddParam("name", value)`: a name you pass yourself always wins. diff --git a/docsite/samples/Mapping.cs b/docsite/samples/Mapping.cs index c821ffa..24048f7 100644 --- a/docsite/samples/Mapping.cs +++ b/docsite/samples/Mapping.cs @@ -275,6 +275,23 @@ public static (Post? First, Post? Single) FirstAndSingle(SqliteConnection connec return (first, single); } + public static async Task InsertReturningIdAsync( + IDbConnectionFactory connectionFactory, + CancellationToken cancellationToken) + { + #region map_single_insert + // The insert and a select of the new id in one command (SQLite: last_insert_rowid()). + // SingleAsync throws unless exactly one row comes back, so there is no handler and no check. + long newId = await connectionFactory + .Command("insert into posts (author, title, body, views) values (@author, 'Draft', null, 0); select last_insert_rowid()") + .AddParam("author", 42) + .Map() + .SingleAsync(cancellationToken); + #endregion + + return newId; + } + public static async Task MapReaderView( SqliteConnection connection) { @@ -395,6 +412,22 @@ public static async Task> ByIdAsync(SqliteConnection conne return byId; } + public static async Task> BodiesByIdAsync(SqliteConnection connection, CancellationToken cancellationToken) + { + #region map_dictionary_values + // A key selector and a value selector build the lookup in one pass: no Post is kept, only the text. + Dictionary bodies = await connection + .Command("select * from posts") + .Map() + .ToDictionaryAsync( + post => post.Id, + post => post.Body ?? "", + cancellationToken: cancellationToken); + #endregion + + return bodies; + } + public static async Task MapConverted( SqliteConnection connection, CancellationToken cancellationToken) diff --git a/docsite/samples/Program.cs b/docsite/samples/Program.cs index 78fab3c..eb2a5db 100644 --- a/docsite/samples/Program.cs +++ b/docsite/samples/Program.cs @@ -146,6 +146,12 @@ void Check(bool condition, string what) Check(projected == 0, "Select runs nothing until a terminal runs"); Check(lazyIds.ToList().Count == 5 && projected == 5, "the selector runs once per row"); Check(lazyIds.FirstOrDefault() == 1 && projected == 6, "a terminal that stops early projects only the rows it reads, and runs the command again"); +Dictionary bodiesById = await Mapping.BodiesByIdAsync(connection, CancellationToken.None); +Check(bodiesById.Count == 5 && bodiesById[1] == "Body 1" && bodiesById[2] == "", "ToDictionaryAsync with a value selector keeps only the value (NULL as empty text)"); +Check(connection.Command("select * from posts order by id").Map().First().Id == 1, "First returns the first of several rows"); +Check(Throws(() => connection.Command("select * from posts").Map().Single()), "Single throws for more than one row"); +Check(Throws(() => connection.Command("select * from posts where id = 999").Map().First()), "First throws for no rows"); +Check(Throws(() => connection.Command("select * from posts where id = 999").Map().Single()), "Single throws for no rows"); // Channels and Dataflow. List viaChannel = await ChannelsAndDataflow.ToChannelSample(factory); @@ -214,6 +220,9 @@ void Check(bool condition, string what) long newId = ExpressiveCommands.InsertAndReadId(factory); Check(newId == 6 && factory.Command("select title from posts where id = 6").ExecuteScalar() == "New", "an insert and select last_insert_rowid() in one command return the generated id to ExecuteScalar()"); factory.Command("delete from posts where id = 6").ExecuteNonQuery(); +long draftId = await Mapping.InsertReturningIdAsync(factory, CancellationToken.None); +Check(draftId == 6 && factory.Command("select title from posts where id = 6").ExecuteScalar() == "Draft", "an insert returns its new id, read with SingleAsync"); +factory.Command("delete from posts where id = 6").ExecuteNonQuery(); // Deprecated in 11.0: each old call and the call that replaces it give the same result. Db.Seed(); (string resultsBefore, string resultsAfter) = Deprecated.Results(connection);