diff --git a/Source/Core/Core/RowMap.cs b/Source/Core/Core/RowMap.cs
index 1a14f87..e28aef4 100644
--- a/Source/Core/Core/RowMap.cs
+++ b/Source/Core/Core/RowMap.cs
@@ -21,7 +21,17 @@ namespace Open.Database.Extensions;
/// The pairs are validated when the map is constructed: an unknown or unsettable property or constructor parameter, a member named twice, an empty column name, a column for a required property,
/// or two members reading one column throw .
/// A type that cannot be mapped throws when the map is constructed or is read, which every query method does at the call, even when the result is empty:
-/// an interface, an abstract class, a delegate, a single value such as or , a dictionary or a dynamic object (Dictionary<string, object?>, : they have no properties to set, so use Map(selector)), a type with two or more public constructors and none without parameters, a class with no public constructor and a type with a required public field (a field is never mapped, so it cannot be set).
+/// an interface, an abstract class, a delegate,
+/// a single value such as or
+/// (use Map<T>() on a command to read one column),
+/// a dictionary or a dynamic object
+/// (Dictionary<string, object?>,
+/// :
+/// they have no properties to set, so use Map(selector)),
+/// a type with two or more public constructors and none without parameters,
+/// a class with no public constructor
+/// and a type with a required public field
+/// (a field is never mapped, so it cannot be set).
/// A NULL never overwrites a member the C# type declares non-nullable: a value type other than (an enum counts), or a reference type with nullable annotations on (string, not string?).
/// A property of such a type keeps the value its constructor or initializer gave it, else its default; a constructor parameter receives its declared default value, else .
/// Anything else receives : , a reference type declared nullable (string?, or [AllowNull] on a property), a reference type in code with nullable annotations off, and a member declared as a type parameter.
@@ -622,6 +632,97 @@ Func Build(Layout layout)
cell);
}
+ // The reader of a single value (FirstColumn):
+ // column 0 read exactly as a property of type T is,
+ // with the same typed getter, conversion and NULL rule.
+ // A NULL into a type the C# type declares non-nullable is default(T);
+ // any other NULL is null.
+ // Built from the helpers below, so the two cannot drift apart.
+ internal static Func FirstColumnReader(Type columnType)
+ where TReader : class, IDataRecord
+ {
+ Type readerType = typeof(TReader);
+ MethodInfo isDbNull = readerType.GetMethod(
+ nameof(IDataRecord.IsDBNull),
+ [typeof(int)])!;
+ MethodInfo getValue = readerType.GetMethod(
+ nameof(IDataRecord.GetValue),
+ [typeof(int)])!;
+
+ ParameterExpression r = Expression.Parameter(readerType, "r");
+ ParameterExpression cell = Expression.Variable(typeof(object), "cell");
+ ParameterExpression ex = Expression.Parameter(typeof(Exception), "ex");
+ Expression ordinal = Expression.Constant(0);
+ var slot = new Slot(typeof(T));
+
+ Expression value = ReadValue(
+ r,
+ ordinal,
+ columnType,
+ slot,
+ readerType,
+ isDbNull,
+ getValue,
+ cell);
+ if (NullTest(
+ r,
+ ordinal,
+ columnType,
+ slot,
+ readerType,
+ isDbNull,
+ getValue,
+ cell) is { } isNull)
+ {
+ value = Expression.Condition(
+ isNull,
+ Expression.Default(typeof(T)),
+ value);
+ }
+
+ Expression guarded = Expression.TryCatch(
+ value,
+ Expression.Catch(
+ ex,
+ Expression.Condition(
+ Expression.OrElse(
+ Expression.TypeIs(ex, typeof(DbException)),
+ Expression.TypeIs(
+ ex,
+ typeof(OperationCanceledException))),
+ Expression.Rethrow(typeof(T)),
+ Expression.Throw(
+ Expression.Call(
+ typeof(RowMap).GetMethod(
+ nameof(FirstColumnError),
+ BindingFlags.Static | BindingFlags.NonPublic)!,
+ ex,
+ r),
+ typeof(T)))));
+
+ return Expression
+ .Lambda>(
+ Expression.Block(typeof(T), [cell], guarded),
+ r)
+ .Compile();
+ }
+
+ static ValueMappingException FirstColumnError(
+ Exception ex,
+ IDataRecord record)
+ {
+ string column;
+ try { column = record.GetName(0); }
+ catch (Exception) { column = ""; } // Nothing to name: the failure being reported matters more.
+
+ return new ValueMappingException(
+ $"Unable to read column '{column}' as {Shape.TypeName(typeof(T))}.",
+ "",
+ column,
+ typeof(T),
+ ex);
+ }
+
// The skeleton shared by both mappers. A model built as it always was (a class with a public parameterless constructor, a struct with no constructor of its own):
// `new T()`, then for each column that maps to a property, `item.Property = `. A model built through a constructor: every fed cell is read, in column order, into a typed local,
// then `new T(arguments)`, then the properties no parameter covers are set. Only how a cell is read differs (a live reader vs buffered object[] rows).
@@ -991,6 +1092,51 @@ static class TypedGetters
}.ToFrozenDictionary();
}
+// The row source of a single-value model type (Shape.IsScalar):
+// column 0 of every row, read as a property of type T is,
+// whatever the column is named, other columns ignored.
+// One instance per T (null for any other T, so Map() branches on it).
+// Like a property, the reader is chosen once per result set,
+// from the column's reported type;
+// the cache is keyed by that type alone,
+// so there is no name matching to pay for.
+internal sealed class FirstColumn : IRowSource
+{
+ internal static readonly FirstColumn? Instance
+ = Shape.IsScalar(typeof(T)) ? new() : null;
+
+ sealed class Entry(Type columnType)
+ {
+ public readonly Type ColumnType = columnType;
+ public Func? Db;
+ public Func? Rec;
+ }
+
+ readonly ConcurrentDictionary _entries = new();
+ Entry? _last;
+
+ Entry Resolve(IDataRecord reader)
+ {
+ Entry? last = _last;
+ Type columnType = reader.GetFieldType(0) ?? typeof(object); // Tolerant of a provider that reports no type, as the layout of a model is.
+ return last is not null && last.ColumnType == columnType
+ ? last
+ : _last = _entries.GetOrAdd(columnType, static t => new Entry(t));
+ }
+
+ public Func Mapper(DbDataReader reader)
+ {
+ Entry e = Resolve(reader);
+ return e.Db ??= RowMap.FirstColumnReader(e.ColumnType);
+ }
+
+ public Func Mapper(IDataRecord reader)
+ {
+ Entry e = Resolve(reader);
+ return e.Rec ??= RowMap.FirstColumnReader(e.ColumnType);
+ }
+}
+
// What one column can feed: a constructor parameter, or a settable public instance property of the model, held once per model type in an ImmutableArray in place of PropertyInfo,
// so the matcher and the compiled readers see only a name, the type a value must become and where it goes. It does not depend on the model type either.
// A slot fed through the constructor has a Position and no Setter; a parameter and a property of the same name (ignoring case) are one such slot, so the property is never set again.
@@ -1008,6 +1154,16 @@ public Slot(string name, Type type, MethodInfo setter, bool required, bool nonNu
KeepsValueOnNull = KeepsOnNull(type, nonNullableReference);
}
+ // The one value of a single-value model type (FirstColumn): no name,
+ // nothing to set.
+ public Slot(Type type)
+ {
+ Name = "";
+ Type = type;
+ Position = -1;
+ KeepsValueOnNull = KeepsOnNull(type, false);
+ }
+
// A constructor parameter, named after the property of that name when there is one.
public Slot(string name, Type type, int position, string parameter, bool isProperty, Expression fallback, bool required, bool nonNullableReference)
{
@@ -1215,7 +1371,7 @@ public void ThrowIfUnmappable()
setsRequiredMembers = false;
string name = TypeName(type);
- if (IsScalar(type)) return $"'{name}' is a single value, not a row model. Use ExecuteScalar() or FirstOrdinalResults().";
+ if (IsScalar(type)) return $"'{name}' is a single value, not a row model. On a command use Map() or ToListAsync(), which read the first column; else FirstOrdinalResults() or ExecuteScalar().";
if (NotConcrete(type) is { } kind) return $"'{name}' cannot be mapped: it is {kind}. Map to a class or struct, or use Map(selector).";
if (IsDictionaryOrDynamic(type)) return $"'{name}' cannot be mapped: it is a dictionary or a dynamic object, which has no properties to set from the columns. Map to a class or record, or use Map(selector).";
@@ -1223,12 +1379,18 @@ public void ThrowIfUnmappable()
}
// A single value, not a row: a column of it, not a model with properties. Nullable of these is one too.
- static bool IsScalar(Type type)
+ // DateOnly and TimeOnly are matched by name,
+ // so every target framework agrees
+ // (the netstandard builds cannot name them).
+ internal static bool IsScalar(Type type)
{
type = Nullable.GetUnderlyingType(type) ?? type;
return type.IsPrimitive || type.IsEnum
|| type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(DateTimeOffset)
- || type == typeof(TimeSpan) || type == typeof(Guid) || type == typeof(byte[]);
+ || type == typeof(TimeSpan)
+ || type == typeof(Guid)
+ || type == typeof(byte[])
+ || type.FullName is "System.DateOnly" or "System.TimeOnly";
}
// A dictionary or a dynamic object has no properties to set: it would be built as one empty row. Any of the interfaces that make a type one counts, the closed generic ones through the type's interface list.
diff --git a/Source/Core/Extensions/IExecuteReader.cs b/Source/Core/Extensions/IExecuteReader.cs
index 85b6ca5..0bdb942 100644
--- a/Source/Core/Extensions/IExecuteReader.cs
+++ b/Source/Core/Extensions/IExecuteReader.cs
@@ -431,6 +431,138 @@ public static ImmutableArray ToImmutableArray(this IExecuteReader command,
behavior | CommandBehavior.SingleResult);
}
+ #region Async terminals that take the type: Map() then the terminal
+
+ // Only the async forms are on the command:
+ // a synchronous caller writes command.Map().ToList()
+ // (the reader-level synchronous helpers are unchanged).
+ // Each is one expression: no await, no state machine of its own.
+
+ ///
+ /// Reads every row into a list, asynchronously.
+ /// Equivalent to command.Map<T>().ToListAsync(cancellationToken):
+ /// a single value such as reads the first column;
+ /// any other maps by column name.
+ ///
+ ///
+ /// A single value (a primitive, an enum, ,
+ /// , ,
+ /// , ,
+ /// , byte[], DateOnly,
+ /// TimeOnly or a of one)
+ /// is read from the first column of each row, whatever it is named;
+ /// the other columns are ignored, so select only the one you need.
+ /// The column is converted as the mapper converts a property of that type:
+ /// its typed getter when the column type matches (no boxing),
+ /// a range-checked integer conversion, else a general conversion.
+ /// A NULL is for a non-nullable value type
+ /// (0 for )
+ /// and for ,
+ /// and byte[];
+ /// declare int? to tell NULL from 0.
+ /// A value that cannot be converted throws .
+ /// Only the asynchronous terminals are on the command;
+ /// for a synchronous read use command.Map<T>().ToList().
+ /// Pass the token or nothing:
+ /// ToListAsync<int>(default) binds to the obsolete instance method
+ /// ToListAsync(transform, behavior) of an expressive command,
+ /// which is the one member that still wins over these extensions.
+ ///
+ /// The model type to map the rows to, or the single value to read from the first column.
+ /// The command to read from.
+ /// A token combined with the command's own.
+ /// The rows as a list.
+ ///
+ /// is .
+ ///
+ ///
+ /// cannot be mapped
+ /// (an interface, an abstract class, ...):
+ /// thrown at the call, before anything runs.
+ ///
+ ///
+ public static ValueTask> ToListAsync(
+ this IExecuteReader command,
+ CancellationToken cancellationToken = default)
+ => command.Map().ToListAsync(cancellationToken);
+
+ ///
+ /// Reads every row into an array, asynchronously.
+ /// Equivalent to command.Map<T>().ToArrayAsync(cancellationToken).
+ ///
+ ///
+ /// The command to read from.
+ /// A token combined with the command's own.
+ /// The rows as an array.
+ public static ValueTask ToArrayAsync(
+ this IExecuteReader command,
+ CancellationToken cancellationToken = default)
+ => command.Map().ToArrayAsync(cancellationToken);
+
+ ///
+ /// Reads every row into an immutable array, asynchronously.
+ /// Equivalent to command.Map<T>().ToImmutableArrayAsync(cancellationToken).
+ ///
+ ///
+ /// The command to read from.
+ /// A token combined with the command's own.
+ /// The rows as an immutable array.
+ public static ValueTask> ToImmutableArrayAsync(
+ this IExecuteReader command,
+ CancellationToken cancellationToken = default)
+ => command.Map().ToImmutableArrayAsync(cancellationToken);
+
+ ///
+ /// Reads the first row, or when there is none,
+ /// asynchronously.
+ /// Equivalent to command.Map<T>().FirstOrDefaultAsync(cancellationToken).
+ ///
+ ///
+ /// The command to read from.
+ /// A token combined with the command's own.
+ /// The first row, or .
+ public static ValueTask FirstOrDefaultAsync(
+ this IExecuteReader command,
+ CancellationToken cancellationToken = default)
+ => command.Map().FirstOrDefaultAsync(cancellationToken);
+
+ ///
+ /// Reads the only row, or when there is none,
+ /// asynchronously.
+ /// Equivalent to command.Map<T>().SingleOrDefaultAsync(cancellationToken).
+ ///
+ ///
+ /// The command to read from.
+ /// A token combined with the command's own.
+ /// The only row, or .
+ ///
+ /// There is more than one row.
+ ///
+ public static ValueTask SingleOrDefaultAsync(
+ this IExecuteReader command,
+ CancellationToken cancellationToken = default)
+ => command.Map().SingleOrDefaultAsync(cancellationToken);
+
+ ///
+ /// Reads every row into a hash set, asynchronously.
+ /// Equivalent to command.Map<T>().ToHashSetAsync(comparer, cancellationToken).
+ /// The comparer comes first, as on :
+ /// pass the token by name,
+ /// ToHashSetAsync<int>(cancellationToken: ct).
+ ///
+ ///
+ /// The command to read from.
+ /// The comparer of the set, or for the default.
+ /// A token combined with the command's own.
+ /// The rows as a hash set.
+ public static ValueTask> ToHashSetAsync(
+ this IExecuteReader command,
+ IEqualityComparer? comparer = null,
+ CancellationToken cancellationToken = default)
+ => command.Map().ToHashSetAsync(comparer, cancellationToken);
+
+ #endregion
+
///
/// Iterates all records within the first result set using an IDataReader and returns the results.
/// values are left unchanged (retained).
diff --git a/Source/Core/MappedQuery.cs b/Source/Core/MappedQuery.cs
index 03ee374..af396ae 100644
--- a/Source/Core/MappedQuery.cs
+++ b/Source/Core/MappedQuery.cs
@@ -12,7 +12,18 @@ public static partial class CoreExtensions
///
/// A type with a public parameterless constructor is built with new T(), and its public settable properties (init and required ones too) are set from the columns.
/// Any other type is built through its one public constructor (a positional record, a record struct, a class with one constructor), and the properties the constructor does not cover are set afterwards.
- /// A required property needs a column. A that cannot be mapped (an interface, an abstract class, a single value such as , a type with two or more public constructors and none without parameters)
+ /// A required property needs a column.
+ /// A single value such as
+ /// (a primitive, an enum, , ,
+ /// a date or time, , byte[]
+ /// or a of one)
+ /// is read from the first column of each row,
+ /// converted as a property of that type is;
+ /// a NULL is default for a non-nullable value type,
+ /// so declare int? to tell NULL from 0.
+ /// A that cannot be mapped
+ /// (an interface, an abstract class,
+ /// a type with two or more public constructors and none without parameters)
/// throws when the map is built or is read, which the overloads without a do at the call.
/// See Records and constructors.
///
@@ -69,7 +80,17 @@ public static MappedQuery Map(
[OverloadResolutionPriority(4)]
public static MappedQuery Map(
this IExecuteReader command)
- => Map(command, RowMap.Default);
+ {
+ if (command is null) throw new ArgumentNullException(nameof(command));
+ Contract.EndContractBlock();
+
+ // A single value such as int is read from the first column;
+ // any other T maps by column name.
+ return FirstColumn.Instance is { } firstColumn
+ ? new MappedQuery(
+ new MappedQueryCore(command, firstColumn, 0, -1, false))
+ : Map(command, RowMap.Default);
+ }
///
/// Locks in a custom projection and returns a deferred . Nothing runs until
diff --git a/Source/Core/PublicAPI.Unshipped.txt b/Source/Core/PublicAPI.Unshipped.txt
index 159b5a4..df7d0d6 100644
--- a/Source/Core/PublicAPI.Unshipped.txt
+++ b/Source/Core/PublicAPI.Unshipped.txt
@@ -13,6 +13,12 @@ static Open.Database.Extensions.ConnectionExtensions.OpenAsync(this System.Data.
Open.Database.Extensions.MappedQuery.SingleResult() -> Open.Database.Extensions.MappedQuery
Open.Database.Extensions.MappedQueryWithSkip.SingleResult() -> Open.Database.Extensions.MappedQueryWithSkip
Open.Database.Extensions.MappedQueryWithTake.SingleResult() -> Open.Database.Extensions.MappedQueryWithTake
+static Open.Database.Extensions.IExecuteReaderExtensions.ToListAsync(this Open.Database.Extensions.IExecuteReader! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+static Open.Database.Extensions.IExecuteReaderExtensions.ToArrayAsync(this Open.Database.Extensions.IExecuteReader! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+static Open.Database.Extensions.IExecuteReaderExtensions.ToImmutableArrayAsync(this Open.Database.Extensions.IExecuteReader! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask>
+static Open.Database.Extensions.IExecuteReaderExtensions.FirstOrDefaultAsync(this Open.Database.Extensions.IExecuteReader! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+static Open.Database.Extensions.IExecuteReaderExtensions.SingleOrDefaultAsync(this Open.Database.Extensions.IExecuteReader! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+static Open.Database.Extensions.IExecuteReaderExtensions.ToHashSetAsync(this Open.Database.Extensions.IExecuteReader! command, System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
*REMOVED*Open.Database.Extensions.ExpressiveCommandBase.ExecuteReaderAsync(System.Action! handler, System.Data.CommandBehavior behavior = System.Data.CommandBehavior.Default) -> System.Threading.Tasks.ValueTask
*REMOVED*Open.Database.Extensions.ExpressiveCommandBase.ExecuteReaderAsync(System.Func! handler, System.Data.CommandBehavior behavior = System.Data.CommandBehavior.Default) -> System.Threading.Tasks.ValueTask
*REMOVED*Open.Database.Extensions.ExpressiveCommandBase.ExecuteReaderAsync(System.Func>! handler, System.Data.CommandBehavior behavior = System.Data.CommandBehavior.Default) -> System.Threading.Tasks.ValueTask
diff --git a/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs b/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs
new file mode 100644
index 0000000..300b808
--- /dev/null
+++ b/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs
@@ -0,0 +1,145 @@
+#nullable enable
+
+namespace Open.Database.Extensions.Tests;
+
+// Reading one column through the command's terminal (cmd.Map().ToArray(),
+// Map()) costs what the hand-written selector costs:
+// the same bytes per call, so nothing is boxed per row on the typed path.
+// Each measurement compares the two in the same run,
+// so the budget follows the runtime and the configuration.
+[ExcludeFromCodeCoverage]
+public static class SingleValueAllocationTests
+{
+ static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ // Bytes per call: after a warm-up,
+ // the smallest of three rounds of 1,000 calls
+ // (the allocated-bytes counter of this thread is exact,
+ // so only a stray JIT or GC adds noise).
+ static long BytesPerCall(Action call)
+ {
+ for (int i = 0; i < 100; i++) call();
+ long best = long.MaxValue;
+ for (int round = 0; round < 3; round++)
+ {
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ for (int i = 0; i < 1000; i++) call();
+ best = Math.Min(
+ best,
+ (GC.GetAllocatedBytesForCurrentThread() - before) / 1000);
+ }
+
+ return best;
+ }
+
+ // What the hand-written version may cost beyond the measured ideal:
+ // the two differ by no more than a rounding of the measurement.
+ const int Slack = 16;
+
+ // A DataTableReader is a DbDataReader that reports its column type
+ // and has typed getters,
+ // so the typed (unboxed) path runs.
+ static PlainReaderCommand Rows(
+ Type column,
+ int count,
+ Func value)
+ {
+ var table = new DataTable();
+ table.Columns.Add("v", column);
+ table.Columns.Add("other", typeof(string));
+ for (int i = 0; i < count; i++) table.Rows.Add(value(i), "x");
+ return new PlainReaderCommand(() => new DataTableReader(table));
+ }
+
+ static void NoMoreThanByHand(
+ Action library,
+ Action byHand,
+ string what)
+ {
+ long mine = BytesPerCall(library);
+ long theirs = BytesPerCall(byHand);
+ Assert.True(
+ mine <= theirs + Slack,
+ $"{what}: {mine} B per call through the terminal, {theirs} B by hand (allowed {Slack} more).");
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(100)]
+ public static void ToArrayAndToListOfInt_AllocateNoMoreThanASelector(
+ int rows)
+ {
+ PlainReaderCommand cmd = Rows(typeof(int), rows, i => i);
+
+ NoMoreThanByHand(
+ () => cmd.Map().ToArray(),
+ () => cmd.Map(static r => r.GetInt32(0)).ToArray(),
+ $"ToArray over {rows} rows");
+ NoMoreThanByHand(
+ () => cmd.Map().ToList(),
+ () => cmd.Map(static r => r.GetInt32(0)).ToList(),
+ $"ToList over {rows} rows");
+ NoMoreThanByHand(
+ () => cmd.Map().ToList(),
+ () => cmd.Map(static r => r.GetInt32(0)).ToList(),
+ $"Map().ToList() over {rows} rows");
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(100)]
+ public static void ALongColumnIntoInt_AllocatesNoMoreThanASelector(int rows)
+ {
+ PlainReaderCommand cmd = Rows(typeof(long), rows, i => (long)i);
+
+ NoMoreThanByHand(
+ () => cmd.Map().ToList(),
+ () => cmd.Map(static r => checked((int)r.GetInt64(0))).ToList(),
+ $"ToList over a long column, {rows} rows");
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(100)]
+ public static void ToListOfNullableIntAndString_AllocateNoMoreThanASelector(
+ int rows)
+ {
+ PlainReaderCommand ints = Rows(typeof(int), rows, i => i);
+ NoMoreThanByHand(
+ () => ints.Map().ToList(),
+ () => ints
+ .Map(static r => r.IsDBNull(0) ? (int?)null : r.GetInt32(0))
+ .ToList(),
+ $"ToList over {rows} rows");
+
+ PlainReaderCommand texts = Rows(typeof(string), rows, i => "t");
+ NoMoreThanByHand(
+ () => texts.Map().ToList(),
+ () => texts
+ .Map(static r => r.IsDBNull(0) ? null : r.GetString(0))
+ .ToList(),
+ $"ToList over {rows} rows");
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(100)]
+ public static void ToListAsyncOfInt_AllocatesNoMoreThanASelector(int rows)
+ {
+ PlainReaderCommand cmd = Rows(typeof(int), rows, i => i);
+
+ NoMoreThanByHand(
+ () => cmd.ToListAsync(Ct).AsTask().GetAwaiter().GetResult(),
+ () => cmd
+ .Map(static r => r.GetInt32(0))
+ .ToListAsync(Ct)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult(),
+ $"ToListAsync over {rows} rows");
+ }
+}
diff --git a/Tests/Open.Database.Extensions.Core.Tests/SingleValueBindingTests.cs b/Tests/Open.Database.Extensions.Core.Tests/SingleValueBindingTests.cs
new file mode 100644
index 0000000..a0c85a2
--- /dev/null
+++ b/Tests/Open.Database.Extensions.Core.Tests/SingleValueBindingTests.cs
@@ -0,0 +1,119 @@
+#pragma warning disable CS0618 // The transform overloads are being deprecated; this file pins that they still bind as they did.
+
+#nullable enable
+
+namespace Open.Database.Extensions.Tests;
+
+// The terminals that take the type are extensions on IExecuteReader
+// next to the transform overloads of the same names.
+// Every call that compiled before binds as it did;
+// the new calls bind to the new members.
+// The compile-only part is the table:
+// each call is pinned to the type it returns,
+// so a change of binding breaks the build.
+//
+// These must NOT compile (checked by hand when the table changes;
+// a test cannot assert a compile error):
+// cmd.ToList(); CS7036
+// (only the async terminals take the type;
+// a sync caller writes cmd.Map().ToList())
+// cmd.ToList(); CS7036
+// cmd.ToHashSetAsync(ct); CS1503
+// (the comparer comes first, as on MappedQuery)
+// iExecuteReader.ToListAsync(ct); CS0411 (no T)
+// iExecuteReader.ToListAsync(r => 1); CS1660
+// (a lambda where a CancellationToken is expected)
+// rawDbCommand.ToList(); CS1061
+// (nothing new on IDbCommand, DbCommand or IDataReader)
+[ExcludeFromCodeCoverage]
+public static class SingleValueBindingTests
+{
+ static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ static void Pin(T _) { }
+ static void PinAsync(ValueTask _) { }
+
+ static int Id(IDataRecord r)
+ => r.GetInt32(0);
+
+ // Compiled, never run:
+ // a lambda in a field keeps the table free of unused variables.
+ static readonly Action<
+ ExpressiveDbCommand,
+ IExecuteReader,
+ CancellationToken> Table = static (cmd, exec, ct) =>
+ {
+ // The transform overloads, as before.
+ Pin>(cmd.ToList(r => r.GetInt32(0)));
+ Pin>(cmd.ToList(Id));
+ Pin>(cmd.ToList(Id, CommandBehavior.SequentialAccess));
+ Pin>(cmd.ToList(null!));
+ Pin(cmd.ToArray(r => r.GetInt32(0)));
+ Pin(cmd.ToArray(Id, CommandBehavior.SequentialAccess));
+ Pin>(cmd.ToImmutableArray(Id));
+ Pin>(
+ cmd.ToImmutableArray(Id, CommandBehavior.SequentialAccess));
+ Pin(cmd.FirstOrDefault(Id));
+ Pin(cmd.SingleOrDefault(Id));
+ Pin(cmd.First(Id));
+ Pin(cmd.Single(Id));
+ Pin>(exec.ToList(Id));
+ PinAsync>(cmd.ToListAsync(r => r.GetInt32(0)));
+ PinAsync>(
+ cmd.ToListAsync(
+ r => r.GetInt32(0),
+ CommandBehavior.SequentialAccess));
+
+ // The new terminals.
+
+ PinAsync>(cmd.ToListAsync());
+ PinAsync>(cmd.ToListAsync(ct));
+ PinAsync>(cmd.ToListAsync(cancellationToken: ct));
+ PinAsync(cmd.ToArrayAsync());
+ PinAsync(cmd.ToArrayAsync(ct));
+ PinAsync(cmd.ToArrayAsync(cancellationToken: ct));
+ PinAsync>(cmd.ToImmutableArrayAsync(ct));
+ PinAsync(cmd.FirstOrDefaultAsync(ct));
+ PinAsync(cmd.SingleOrDefaultAsync(ct));
+ PinAsync>(cmd.ToHashSetAsync());
+ PinAsync>(
+ cmd.ToHashSetAsync(EqualityComparer.Default, ct));
+ PinAsync>(cmd.ToHashSetAsync(cancellationToken: ct));
+ PinAsync>(exec.ToListAsync(ct));
+ PinAsync(exec.ToArrayAsync(ct));
+
+ // A row model,
+ // and the Map twin (a MappedQuery is not an IExecuteReader,
+ // so its own terminals are untouched).
+ PinAsync>(
+ cmd.ToListAsync(ct));
+ Pin>(cmd.Map().ToList());
+ Pin>(cmd.Map(Id).ToList());
+ };
+
+ [Fact]
+ public static void TheTableCompiles()
+ => Assert.NotNull(Table);
+
+ // The one trap:
+ // ToListAsync(default!)
+ // (or null) still binds to the instance transform overload
+ // of an expressive command,
+ // not to the new extension.
+ // With no transform that overload has no guard:
+ // an empty result returns [] and a row throws.
+ [Fact]
+ public static async Task ToListAsyncOfIntWithDefault_BindsToTheInstanceTransformOverload_OfAnExpressiveCommand()
+ {
+ var rig = new TokenRig { Rows = 1 };
+ static ExpressiveDbCommand Command(TokenRig r)
+ => new(new TokenConnection(r), CommandType.Text, "SELECT Id");
+
+ Assert.Equal([1], await Command(rig).ToListAsync(Ct)); // the token: the new extension
+ await Assert.ThrowsAsync(
+ async () => await Command(rig).ToListAsync(default!));
+
+ rig.Rows = 0;
+ Assert.Empty(await Command(rig).ToListAsync(default!)); // no row, so the missing transform is never called
+ }
+}
diff --git a/Tests/Open.Database.Extensions.Core.Tests/SingleValueTerminalTests.cs b/Tests/Open.Database.Extensions.Core.Tests/SingleValueTerminalTests.cs
new file mode 100644
index 0000000..9fb0c15
--- /dev/null
+++ b/Tests/Open.Database.Extensions.Core.Tests/SingleValueTerminalTests.cs
@@ -0,0 +1,625 @@
+using static Open.Database.Extensions.Tests.ReaderFixtures;
+
+#nullable enable
+
+namespace Open.Database.Extensions.Tests;
+
+// A command's ToList()
+// and the other terminals are Map() then that terminal;
+// a single value such as int is read from the first column.
+// These tests pin which types, how the column is converted, what a NULL is,
+// which column is read, and that every terminal equals its Map() twin.
+[ExcludeFromCodeCoverage]
+public static class SingleValueTerminalTests
+{
+ static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ static T Block(ValueTask task)
+ => task.AsTask().GetAwaiter().GetResult();
+
+ public enum Level { None = 0, One = 1, Two = 2 }
+ public enum Small : byte { A = 0, B = 3 }
+
+ public sealed record PostRow
+ {
+ public int Id { get; set; }
+ public string? Title { get; set; }
+ }
+
+ // One column named "v" of the given type,
+ // one row per cell (a null is a NULL).
+ static FakeExecuteReader Cmd(Type column, params object?[] cells)
+ => new(() => TypedReader.Create(
+ [("v", column)],
+ [.. cells.Select(c => new object?[] { c })]));
+
+ static FakeExecuteReader Cmd(
+ ImmutableArray columns,
+ params object?[][] rows)
+ => new(() => TypedReader.Create(columns, rows));
+
+ // Every terminal, async, gives the answer of the Map() twin.
+ static void Check(
+ Type column,
+ object? cell,
+ T expected)
+ {
+ FakeExecuteReader cmd = Cmd(column, cell);
+ Assert.Equal([expected], cmd.Map().ToList());
+ Assert.Equal([expected], cmd.Map().ToArray());
+ Assert.Equal(expected, cmd.Map().FirstOrDefault());
+ Assert.Equal(expected, cmd.Map().SingleOrDefault());
+ Assert.Single(cmd.Map().ToHashSet());
+ Assert.Equal([expected], cmd.Map().ToImmutableArray());
+ Assert.Equal(
+ [expected],
+ cmd.ToListAsync(Ct).AsTask().GetAwaiter().GetResult());
+ }
+
+ // ---- 1. every single-value type, from a column of its own type ----
+
+ [Fact]
+ public static void EverySingleValueType_IsReadFromAColumnOfItsOwnType()
+ {
+ var guid = Guid.NewGuid();
+ var moment = new DateTime(2026, 11, 9, 8, 7, 6, DateTimeKind.Utc);
+ var offset = new DateTimeOffset(moment);
+
+ Check(typeof(int), 5, 5);
+ Check(typeof(long), 5L, 5L);
+ Check(typeof(short), (short)5, (short)5);
+ Check(typeof(byte), (byte)5, (byte)5);
+ Check(typeof(sbyte), (sbyte)-5, (sbyte)-5);
+ Check(typeof(ushort), (ushort)5, (ushort)5);
+ Check(typeof(uint), 5u, 5u);
+ Check(typeof(ulong), 5ul, 5ul);
+ Check(typeof(bool), true, true);
+ Check(typeof(char), 'x', 'x');
+ Check(typeof(float), 1.5f, 1.5f);
+ Check(typeof(double), 1.5d, 1.5d);
+ Check(typeof(decimal), 1.5m, 1.5m);
+ Check(typeof(string), "text", "text");
+ Check(typeof(Guid), guid, guid);
+ Check(typeof(DateTime), moment, moment);
+ Check(typeof(DateTimeOffset), offset, offset);
+ Check(
+ typeof(TimeSpan),
+ TimeSpan.FromMinutes(5),
+ TimeSpan.FromMinutes(5));
+ Check(typeof(byte[]), new byte[] { 1, 2, 3 }, new byte[] { 1, 2, 3 });
+ Check(
+ typeof(DateOnly),
+ new DateOnly(2026, 11, 9),
+ new DateOnly(2026, 11, 9));
+ Check(typeof(TimeOnly), new TimeOnly(8, 7, 6), new TimeOnly(8, 7, 6));
+ Check(typeof(int), 2, Level.Two);
+ Check(typeof(byte), (byte)3, Small.B);
+ }
+
+ [Fact]
+ public static void ANullableOfEverySingleValue_IsReadFromAColumnOfTheUnderlyingType()
+ {
+ var guid = Guid.NewGuid();
+ Check(typeof(int), 5, 5);
+ Check(typeof(long), 5L, 5L);
+ Check(typeof(bool), true, true);
+ Check(typeof(double), 1.5d, 1.5d);
+ Check(typeof(decimal), 1.5m, 1.5m);
+ Check(typeof(Guid), guid, guid);
+ Check(
+ typeof(DateTime),
+ new DateTime(2026, 1, 2),
+ new DateTime(2026, 1, 2));
+ Check(
+ typeof(TimeSpan),
+ TimeSpan.FromSeconds(3),
+ TimeSpan.FromSeconds(3));
+ Check(typeof(int), 1, Level.One);
+ Check(
+ typeof(DateOnly),
+ new DateOnly(2026, 1, 2),
+ new DateOnly(2026, 1, 2));
+ }
+
+ // ---- 2. NULL ----
+
+ [Fact]
+ public static void ANull_IsDefaultForANonNullableValueType_AndNullForTheRest()
+ {
+ Check(typeof(int), null, 0);
+ Check(typeof(sbyte), null, (sbyte)0); // a column type with no typed getter: the general path
+ Check(typeof(decimal), null, 0); // a column that has to be converted
+ Check(typeof(Guid), null, Guid.Empty);
+ Check(typeof(DateTime), null, default(DateTime));
+ Check(typeof(int), null, Level.None);
+ Check(typeof(int), null, null);
+ Check(typeof(string), null, null);
+ Check(typeof(string), null, null);
+ Check(typeof(int), null, null);
+ Check(typeof(byte[]), null, null);
+ Check(typeof(DateOnly), null, default);
+ }
+
+ [Fact]
+ public static void ANull_InTheFirstRowAndInALaterRow_IsHandledPerRow()
+ {
+ FakeExecuteReader cmd = Cmd(typeof(int), null, 5, null, 7);
+ Assert.Equal([0, 5, 0, 7], cmd.Map().ToList());
+ Assert.Equal([null, 5, null, 7], cmd.Map().ToList());
+ Assert.Equal([null, "5", null, "7"], cmd.Map().ToList());
+ }
+
+ // ---- 3. conversions: as a property of that type is read ----
+
+ [Fact]
+ public static void ALongColumn_ConvertsToInt_WithARangeCheck()
+ {
+ Check(typeof(long), 5L, 5);
+
+ ValueMappingException failure = Assert.Throws(
+ () => Cmd(typeof(long), 5_000_000_000L).Map().ToList());
+ Assert.IsType(failure.InnerException);
+ Assert.Equal("v", failure.ColumnName);
+ Assert.Equal(typeof(int), failure.TargetType);
+ Assert.Equal("", failure.PropertyName);
+ Assert.Contains("'v'", failure.Message);
+ }
+
+ [Fact]
+ public static void Numbers_WidenAndConvert()
+ {
+ Check(typeof(int), 5, 5L);
+ Check(typeof(int), 5, 5d);
+ Check(typeof(decimal), 2.6m, 3); // Convert rounds to nearest
+ Check(typeof(int), 5, "5");
+ Check(typeof(short), (short)5, 5);
+ }
+
+ [Fact]
+ public static void TextAndDates_ConvertLikeAProperty()
+ {
+ var guid = Guid.NewGuid();
+ Check(typeof(string), guid.ToString(), guid);
+ Check(typeof(string), "two", Level.Two);
+ Check(typeof(string), "42", 42);
+ var moment = new DateTime(2026, 11, 9, 1, 2, 3);
+ Check(typeof(DateTime), moment, new DateTimeOffset(moment));
+ Check(typeof(DateTime), moment, DateOnly.FromDateTime(moment));
+ Check(typeof(TimeSpan), TimeSpan.FromHours(1), new TimeOnly(1, 0));
+ }
+
+ [Fact]
+ public static void AValueThatDoesNotConvert_IsAValueMappingException_NamingTheColumn()
+ {
+ ValueMappingException text = Assert.Throws(
+ () => Cmd(typeof(string), "abc").Map().ToList());
+ Assert.IsType(text.InnerException);
+ Assert.Equal("v", text.ColumnName);
+
+ Assert.Throws(
+ () => Cmd(typeof(byte[]), new byte[] { 1 }).Map().ToList()); // ConvertValue refuses a byte array into a string
+ Assert.Throws(
+ () => Cmd(typeof(long), 5L).Map().ToList()); // nint has no typed getter and Convert.ChangeType cannot make one
+ }
+
+ // ---- 4. more than one column ----
+
+ [Fact]
+ public static void OnlyTheFirstColumn_IsRead_WhateverItIsNamed()
+ {
+ FakeExecuteReader a = Cmd(
+ [("Id", typeof(int)), ("v", typeof(int)), ("Name", typeof(string))],
+ [1, 2, "x"],
+ [3, 4, "y"]);
+ Assert.Equal([1, 3], a.Map().ToList());
+
+ FakeExecuteReader b = Cmd(
+ [("v", typeof(int)), ("Id", typeof(int))],
+ [2, 1],
+ [4, 3]);
+ Assert.Equal([2, 4], b.Map().ToList()); // swapping the columns changes the result
+ Assert.Equal(["1", "3"], a.Map().ToList());
+ }
+
+ [Fact]
+ public static void AReaderWithNoColumns_FailsAtTheFirstRow_NotHangs()
+ {
+ FakeExecuteReader none = Cmd([], [], []);
+ Assert.Throws(() => none.Map().ToList());
+ Assert.Equal([], Cmd([]).Map().ToList()); // and with no rows nothing is read at all
+ }
+
+ // ---- 5. empty and single results ----
+
+ [Fact]
+ public static void AnEmptyResult_IsEmpty_AndFirstOrDefaultIsDefault()
+ {
+ FakeExecuteReader cmd = Cmd(typeof(int));
+ Assert.Empty(cmd.Map().ToList());
+ Assert.Empty(cmd.Map().ToArray());
+ Assert.Empty(cmd.Map().ToImmutableArray());
+ Assert.Empty(cmd.Map().ToHashSet());
+ Assert.Equal(0, cmd.Map().FirstOrDefault());
+ Assert.Null(cmd.Map().FirstOrDefault());
+ Assert.Equal(0, cmd.Map().SingleOrDefault());
+ Assert.Empty(Block(cmd.ToListAsync(Ct)));
+ }
+
+ [Fact]
+ public static void SingleOrDefault_WithTwoRows_Throws_AsMapDoes()
+ {
+ FakeExecuteReader cmd = Cmd(typeof(int), 1, 2);
+ Assert.Throws(
+ () => cmd.Map().SingleOrDefault());
+ Assert.Throws(
+ () => cmd.Map().SingleOrDefault());
+ Assert.Equal(1, cmd.Map().FirstOrDefault());
+ Assert.Equal([1, 2], cmd.Map().ToHashSet().Order());
+ Assert.Equal(
+ ["a", "A"],
+ Cmd(typeof(string), "a", "A")
+ .Map()
+ .ToHashSet(StringComparer.Ordinal)
+ .Order(StringComparer.Ordinal)
+ .Reverse());
+ Assert.Single(
+ Cmd(typeof(string), "a", "A")
+ .Map()
+ .ToHashSet(StringComparer.OrdinalIgnoreCase));
+ }
+
+ // ---- 6. parity with Map() for every terminal,
+ // a single value and a row model ----
+
+ static void Drop(ValueTask _) { }
+
+ static void Parity(FakeExecuteReader cmd)
+ {
+ IEqualityComparer comparer = EqualityComparer.Default;
+ Assert.Equal(
+ cmd.Map().ToListAsync(Ct).AsTask().GetAwaiter().GetResult(),
+ cmd.ToListAsync(Ct).AsTask().GetAwaiter().GetResult());
+ Assert.Equal(
+ cmd.Map().ToArrayAsync(Ct).AsTask().GetAwaiter().GetResult(),
+ cmd.ToArrayAsync(Ct).AsTask().GetAwaiter().GetResult());
+ Assert.Equal(
+ cmd
+ .Map()
+ .ToImmutableArrayAsync(Ct)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult(),
+ cmd.ToImmutableArrayAsync(Ct).AsTask().GetAwaiter().GetResult());
+ Assert.Equal(
+ cmd
+ .Map()
+ .FirstOrDefaultAsync(Ct)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult(),
+ cmd.FirstOrDefaultAsync(Ct).AsTask().GetAwaiter().GetResult());
+ Assert.Equal(
+ cmd
+ .Map()
+ .SingleOrDefaultAsync(Ct)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult(),
+ cmd.SingleOrDefaultAsync(Ct).AsTask().GetAwaiter().GetResult());
+ Assert.True(
+ cmd
+ .Map()
+ .ToHashSetAsync(comparer, Ct)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult()
+ .SetEquals(
+ cmd
+ .ToHashSetAsync(comparer, Ct)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult()));
+ }
+
+ [Fact]
+ public static void EveryTerminal_EqualsItsMapTwin()
+ {
+ ImmutableArray |