Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion Source/Core/Extensions/MappedQueryExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace Open.Database.Extensions;

/// <summary>
/// Terminal operations for <see cref="IMappedQuery{T}"/>: they materialize the mapped rows into a collection or take a single row. <see cref="MappedQuery{T}"/> and its skip and take variants expose the same operations directly.
/// Terminal operations for <see cref="IMappedQuery{T}"/>: they materialize the mapped rows into a collection or take a single row, and <see cref="Select{T, TResult}(IMappedQuery{T}, Func{T, TResult})"/> projects them lazily. <see cref="MappedQuery{T}"/> and its skip and take variants expose the same operations directly.
/// </summary>
/// <remarks>
/// <para>
Expand Down Expand Up @@ -412,4 +412,48 @@ public static ValueTask<HashSet<T>> ToHashSetAsync<T>(this IMappedQuery<T> query
=> query.ReadAsync(
async (items, token) => await items.SingleOrDefaultAsync(token).ConfigureAwait(false),
cancellationToken);

/// <summary>
/// Projects each mapped row with <paramref name="selector"/>, lazily: nothing runs until a terminal of the returned query runs.
/// </summary>
/// <typeparam name="T">The type each row is mapped to.</typeparam>
/// <typeparam name="TResult">The type <paramref name="selector"/> returns.</typeparam>
/// <param name="query">The query to project.</param>
/// <param name="selector">Produces the projected value for each mapped row. It runs once per row, in row order, only while a terminal is reading the rows.</param>
/// <returns>A deferred query of the projected rows. Every terminal in <see cref="MappedQueryExtensions"/> works on it, and so does <c>Select</c> again.</returns>
/// <exception cref="ArgumentNullException"><paramref name="query"/> or <paramref name="selector"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This call executes nothing and does not run <paramref name="selector"/>. The command runs once for each terminal called on the returned query, exactly as it does on <paramref name="query"/>, so a method can return the projected query and let each caller pick the terminal (<c>ToListAsync</c> or <c>FirstOrDefaultAsync</c>, for example). A query read by two terminals executes the command twice.
/// </para>
/// <para>
/// The projection is built on <see cref="IMappedQuery{T}.Read(Action{IEnumerable{T}})"/> and <see cref="IMappedQuery{T}.ReadAsync(Func{IAsyncEnumerable{T}, CancellationToken, ValueTask}, CancellationToken)"/>, so how <paramref name="query"/> opens and releases the connection, reads through later result sets and honors <c>SingleResult()</c> and cancellation is unchanged. <c>Skip</c> and <c>Take</c> apply to the rows of <paramref name="query"/>, before the selector runs: call them before <c>Select</c>. A terminal that stops early, such as <c>FirstOrDefault</c>, runs <paramref name="selector"/> only for the rows it reads.
/// </para>
/// <para>
/// An exception thrown by <paramref name="selector"/> propagates to the caller of the terminal, and the reader and the connection are released as they are when a handler throws. A projection of a projection reads through one reader.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// static IMappedQuery&lt;CustomerName&gt; Names(IDbConnectionFactory&lt;SqlConnection&gt; factory)
/// => factory
/// .Command("SELECT Id, Name FROM Customers")
/// .Map&lt;Customer&gt;()
/// .Select(c => new CustomerName(c.Id, c.Name));
///
/// List&lt;CustomerName&gt; all = await Names(factory).ToListAsync(cancellationToken);
/// CustomerName? first = await Names(factory).FirstOrDefaultAsync(cancellationToken);
/// </code>
/// </example>
/// <seealso cref="IMappedQuery{T}"/>
public static IMappedQuery<TResult> Select<T, TResult>(
this IMappedQuery<T> query,
Func<T, TResult> selector)
{
if (query is null) throw new ArgumentNullException(nameof(query));
if (selector is null) throw new ArgumentNullException(nameof(selector));
Contract.EndContractBlock();

return new ProjectedQuery<T, TResult>(query, selector);
}
}
78 changes: 78 additions & 0 deletions Source/Core/ProjectedQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
namespace Open.Database.Extensions;

// What MappedQueryExtensions.Select returns: a lazy projection of another
// query. It holds the source and the selector and runs nothing itself.
// Each terminal hands the handler a stream of the source's rows, projected one
// at a time as the handler enumerates it, so a projection of a projection
// still reads through one reader.
// A caller sees only IMappedQuery<T>, so this type stays internal.
sealed class ProjectedQuery<TSource, T>(
IMappedQuery<TSource> source,
Func<TSource, T> selector)
: IMappedQuery<T>
{
public void Read(Action<IEnumerable<T>> handler)
{
if (handler is null) throw new ArgumentNullException(nameof(handler));
Contract.EndContractBlock();

source.Read(rows => handler(Project(rows, selector)));
}

public TResult Read<TResult>(Func<IEnumerable<T>, TResult> handler)
{
if (handler is null) throw new ArgumentNullException(nameof(handler));
Contract.EndContractBlock();

return source.Read(rows => handler(Project(rows, selector)));
}

public ValueTask ReadAsync(
Func<IAsyncEnumerable<T>, CancellationToken, ValueTask> handler,
CancellationToken cancellationToken)
{
if (handler is null) throw new ArgumentNullException(nameof(handler));
Contract.EndContractBlock();

return source.ReadAsync(
(rows, token) => handler(ProjectAsync(rows, selector, token), token),
cancellationToken);
}

public ValueTask<TResult> ReadAsync<TResult>(
Func<IAsyncEnumerable<T>, CancellationToken, ValueTask<TResult>> handler,
CancellationToken cancellationToken)
{
if (handler is null) throw new ArgumentNullException(nameof(handler));
Contract.EndContractBlock();

return source.ReadAsync(
(rows, token) => handler(ProjectAsync(rows, selector, token), token),
cancellationToken);
}

// The foreach disposes the source's enumerator when this one is disposed,
// so a handler that stops early still releases the reader.
static IEnumerable<T> Project(
IEnumerable<TSource> rows,
Func<TSource, T> selector)
{
foreach (TSource row in rows)
{
yield return selector(row);
}
}

static async IAsyncEnumerable<T> ProjectAsync(
IAsyncEnumerable<TSource> rows,
Func<TSource, T> selector,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (TSource row in rows
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return selector(row);
}
}
}
1 change: 1 addition & 0 deletions Source/Core/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,4 @@ static Open.Database.Extensions.ConnectionExtensions.UsingAsync<TConnection>(thi
Open.Database.Extensions.ExpressiveDbCommandBase<TConnection, TCommand, TReader, TDbType, TThis>.RetrieveAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Open.Database.Extensions.Core.QueryResultQueue<object![]!>!>
Open.Database.Extensions.ExpressiveDbCommandBase<TConnection, TCommand, TReader, TDbType, TThis>.RetrieveAsync(System.Collections.Generic.IEnumerable<int>! ordinals, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Open.Database.Extensions.Core.QueryResultQueue<object![]!>!>
Open.Database.Extensions.ExpressiveDbCommandBase<TConnection, TCommand, TReader, TDbType, TThis>.RetrieveAsync(System.Collections.Generic.IEnumerable<string!>! columnNames, bool normalizeColumnOrder = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Open.Database.Extensions.Core.QueryResultQueue<object![]!>!>
static Open.Database.Extensions.MappedQueryExtensions.Select<T, TResult>(this Open.Database.Extensions.IMappedQuery<T>! query, System.Func<T, TResult>! selector) -> Open.Database.Extensions.IMappedQuery<TResult>!
Loading
Loading