diff --git a/Source/Core/Extensions/MappedQueryExtensions.cs b/Source/Core/Extensions/MappedQueryExtensions.cs
index 6bcfc76..ca45046 100644
--- a/Source/Core/Extensions/MappedQueryExtensions.cs
+++ b/Source/Core/Extensions/MappedQueryExtensions.cs
@@ -1,7 +1,7 @@
namespace Open.Database.Extensions;
///
-/// Terminal operations for : they materialize the mapped rows into a collection or take a single row. and its skip and take variants expose the same operations directly.
+/// Terminal operations for : they materialize the mapped rows into a collection or take a single row, and projects them lazily. and its skip and take variants expose the same operations directly.
///
///
///
@@ -412,4 +412,48 @@ public static ValueTask> ToHashSetAsync(this IMappedQuery query
=> query.ReadAsync(
async (items, token) => await items.SingleOrDefaultAsync(token).ConfigureAwait(false),
cancellationToken);
+
+ ///
+ /// Projects each mapped row with , lazily: nothing runs until a terminal of the returned query runs.
+ ///
+ /// The type each row is mapped to.
+ /// The type returns.
+ /// The query to project.
+ /// Produces the projected value for each mapped row. It runs once per row, in row order, only while a terminal is reading the rows.
+ /// A deferred query of the projected rows. Every terminal in works on it, and so does Select again.
+ /// or is .
+ ///
+ ///
+ /// This call executes nothing and does not run . The command runs once for each terminal called on the returned query, exactly as it does on , so a method can return the projected query and let each caller pick the terminal (ToListAsync or FirstOrDefaultAsync, for example). A query read by two terminals executes the command twice.
+ ///
+ ///
+ /// The projection is built on and , so how opens and releases the connection, reads through later result sets and honors SingleResult() and cancellation is unchanged. Skip and Take apply to the rows of , before the selector runs: call them before Select. A terminal that stops early, such as FirstOrDefault, runs only for the rows it reads.
+ ///
+ ///
+ /// An exception thrown by 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.
+ ///
+ ///
+ ///
+ ///
+ /// static IMappedQuery<CustomerName> Names(IDbConnectionFactory<SqlConnection> factory)
+ /// => factory
+ /// .Command("SELECT Id, Name FROM Customers")
+ /// .Map<Customer>()
+ /// .Select(c => new CustomerName(c.Id, c.Name));
+ ///
+ /// List<CustomerName> all = await Names(factory).ToListAsync(cancellationToken);
+ /// CustomerName? first = await Names(factory).FirstOrDefaultAsync(cancellationToken);
+ ///
+ ///
+ ///
+ public static IMappedQuery Select(
+ this IMappedQuery query,
+ Func selector)
+ {
+ if (query is null) throw new ArgumentNullException(nameof(query));
+ if (selector is null) throw new ArgumentNullException(nameof(selector));
+ Contract.EndContractBlock();
+
+ return new ProjectedQuery(query, selector);
+ }
}
diff --git a/Source/Core/ProjectedQuery.cs b/Source/Core/ProjectedQuery.cs
new file mode 100644
index 0000000..a9377fc
--- /dev/null
+++ b/Source/Core/ProjectedQuery.cs
@@ -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, so this type stays internal.
+sealed class ProjectedQuery(
+ IMappedQuery source,
+ Func selector)
+ : IMappedQuery
+{
+ public void Read(Action> handler)
+ {
+ if (handler is null) throw new ArgumentNullException(nameof(handler));
+ Contract.EndContractBlock();
+
+ source.Read(rows => handler(Project(rows, selector)));
+ }
+
+ public TResult Read(Func, 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, 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 ReadAsync(
+ Func, 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);
+ }
+
+ // The foreach disposes the source's enumerator when this one is disposed,
+ // so a handler that stops early still releases the reader.
+ static IEnumerable Project(
+ IEnumerable rows,
+ Func selector)
+ {
+ foreach (TSource row in rows)
+ {
+ yield return selector(row);
+ }
+ }
+
+ static async IAsyncEnumerable ProjectAsync(
+ IAsyncEnumerable rows,
+ Func selector,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (TSource row in rows
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ yield return selector(row);
+ }
+ }
+}
diff --git a/Source/Core/PublicAPI.Unshipped.txt b/Source/Core/PublicAPI.Unshipped.txt
index 637bd32..1b4b04a 100644
--- a/Source/Core/PublicAPI.Unshipped.txt
+++ b/Source/Core/PublicAPI.Unshipped.txt
@@ -189,3 +189,4 @@ static Open.Database.Extensions.ConnectionExtensions.UsingAsync(thi
Open.Database.Extensions.ExpressiveDbCommandBase.RetrieveAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
Open.Database.Extensions.ExpressiveDbCommandBase.RetrieveAsync(System.Collections.Generic.IEnumerable! ordinals, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
Open.Database.Extensions.ExpressiveDbCommandBase.RetrieveAsync(System.Collections.Generic.IEnumerable! columnNames, bool normalizeColumnOrder = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Open.Database.Extensions.MappedQueryExtensions.Select(this Open.Database.Extensions.IMappedQuery! query, System.Func! selector) -> Open.Database.Extensions.IMappedQuery!
diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedQuerySelectTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedQuerySelectTests.cs
new file mode 100644
index 0000000..75d2473
--- /dev/null
+++ b/Tests/Open.Database.Extensions.Core.Tests/MappedQuerySelectTests.cs
@@ -0,0 +1,1219 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using static Open.Database.Extensions.Tests.ReaderFixtures;
+
+#nullable enable
+
+namespace Open.Database.Extensions.Tests;
+
+// query.Select(selector) is a lazy projection of an IMappedQuery:
+// building it runs nothing, each terminal on it runs the command once,
+// and the selector runs once per row, in row order, only while the rows are read.
+// Every level that has a Map() (expressive command, raw command,
+// reader view, command view, table view) gets it.
+[ExcludeFromCodeCoverage]
+public static class MappedQuerySelectTests
+{
+ sealed record Person
+ {
+ public required int Id { get; init; }
+ public string? Name { get; init; }
+ }
+
+ sealed record Label(int Id, string Text);
+
+ static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ #region Rigs: one way to reach a mapped query per level
+
+ // A mapped query, and a count of the work done against it:
+ // readers created for a command, rows read for a reader view.
+ // A table has nothing to count.
+ sealed class Rig : IDisposable
+ {
+ public required IMappedQuery Query { get; init; }
+ public Func? Work { get; init; }
+ public DataTable? Table { get; init; }
+
+ public void Dispose() => Table?.Dispose();
+ }
+
+ public static TheoryData Levels
+ => ["expressive", "execute", "reader", "command", "table"];
+
+ // The levels whose query can be read more than once.
+ public static TheoryData Repeatable
+ => ["expressive", "execute", "command", "table"];
+
+ static DataTable Table(int count)
+ {
+ DataTable table = new();
+ table.Columns.Add("Id", typeof(int));
+ table.Columns.Add("Name", typeof(string));
+ for (int i = 1; i <= count; i++)
+ {
+ table.Rows.Add(i, "n" + i);
+ }
+
+ return table;
+ }
+
+ static DbTally Tracked(int rows)
+ => new(new DbScript
+ {
+ Rows = [.. Enumerable.Range(1, rows).Select(i => (object?[])[i, "n" + i])],
+ });
+
+ static Rig Make(string level, int rows = 3)
+ {
+ switch (level)
+ {
+ case "expressive":
+ DbTally tally = Tracked(rows);
+ return new()
+ {
+ Query = tally.Factory.Command("select").Map(),
+ Work = () => tally.Readers.Count,
+ };
+
+ case "execute":
+ FakeExecuteReader execute = new(() => Reader(Rows(rows)));
+ return new()
+ {
+ Query = execute.Map(),
+ Work = () => execute.ExecuteCount,
+ };
+
+ case "reader":
+ TrackedReader reader = TrackedRows(rows);
+ return new()
+ {
+ Query = reader.Map(),
+ Work = () => reader.Reads,
+ };
+
+ case "command":
+ FakeDbCommand command = new(
+ new FakeDbConnection(),
+ () => Reader(Rows(rows)));
+ return new()
+ {
+ Query = command.Map(),
+ Work = () => command.Behaviors.Count,
+ };
+
+ case "table":
+ DataTable table = Table(rows);
+ return new() { Query = table.Map(), Table = table };
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(level));
+ }
+ }
+
+ // The selector the tests use: it notes which row it was given.
+ static Func Project(List? seen = null)
+ => p =>
+ {
+ seen?.Add(p.Id);
+ return $"{p.Id}:{p.Name}";
+ };
+
+ const string All = "1:n1,2:n2,3:n3";
+
+ static string Flat(object? value)
+ => value switch
+ {
+ null => "null",
+ string s => s,
+ IEnumerable items => string.Join(",", items),
+ _ => throw new ArgumentOutOfRangeException(nameof(value)),
+ };
+
+ #endregion
+
+ #region Nothing runs until a terminal runs
+
+ [Theory, MemberData(nameof(Levels))]
+ public static void BuildingAProjection_RunsNothing(string level)
+ {
+ using Rig rig = Make(level);
+ List seen = [];
+
+ IMappedQuery query = rig.Query.Select(Project(seen));
+ IMappedQuery chained = query.Select(s => s + "!");
+
+ Assert.Empty(seen);
+ if (rig.Work is { } work) Assert.Equal(0, work());
+
+ Assert.Equal("1:n1!,2:n2!,3:n3!", Flat(chained.ToList()));
+ Assert.Equal([1, 2, 3], seen);
+ if (rig.Work is { } done) Assert.True(done() > 0);
+ }
+
+ #endregion
+
+ #region Every terminal
+
+ static readonly string[] Terminals =
+ [
+ "ToList", "ToArray", "ToImmutableArray", "ToHashSet", "ToDictionary",
+ "FirstOrDefault", "Read", "ReadAction",
+ ];
+
+ static readonly string[] AsyncTerminals =
+ [
+ "ToListAsync", "ToArrayAsync", "ToImmutableArrayAsync",
+ "ToHashSetAsync", "ToDictionaryAsync", "FirstOrDefaultAsync",
+ "ReadAsync", "ReadAsyncAction",
+ ];
+
+ static TheoryData Cases(string[] terminals)
+ {
+ TheoryData data = [];
+ foreach (string level in new[] { "expressive", "execute", "reader", "command", "table" })
+ {
+ foreach (string terminal in terminals)
+ {
+ data.Add(level, terminal);
+ }
+ }
+
+ return data;
+ }
+
+ public static TheoryData SyncCases => Cases(Terminals);
+
+ public static TheoryData AsyncCases
+ => Cases(AsyncTerminals);
+
+ static string Expected(string terminal)
+ => terminal.StartsWith("FirstOrDefault", StringComparison.Ordinal)
+ ? "1:n1"
+ : All;
+
+ static object? Run(IMappedQuery query, string terminal)
+ => terminal switch
+ {
+ "ToList" => query.ToList(),
+ "ToArray" => query.ToArray(),
+ "ToImmutableArray" => query.ToImmutableArray(),
+ "ToHashSet" => query.ToHashSet(),
+ "ToDictionary" => query.ToDictionary(s => s).Keys,
+ "FirstOrDefault" => query.FirstOrDefault(),
+ "Read" => query.Read(items => items.ToList()),
+ "ReadAction" => ReadInto(query),
+ _ => throw new ArgumentOutOfRangeException(nameof(terminal)),
+ };
+
+ static List ReadInto(IMappedQuery query)
+ {
+ List got = [];
+ query.Read(items => got.AddRange(items));
+ return got;
+ }
+
+ static async ValueTask