From 4ee8cabbf7d2a70ccb658eb67ee2bcb80e5592b4 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:16:23 -0800 Subject: [PATCH 1/6] Map() of a single value reads the first column, and a command has async terminals that take the type Co-Authored-By: Claude Sonnet 5.5 --- Source/Core/Core/RowMap.cs | 102 ++++++++++++++++++++++- Source/Core/Extensions/IExecuteReader.cs | 68 +++++++++++++++ Source/Core/MappedQuery.cs | 13 ++- Source/Core/PublicAPI.Unshipped.txt | 6 ++ 4 files changed, 183 insertions(+), 6 deletions(-) diff --git a/Source/Core/Core/RowMap.cs b/Source/Core/Core/RowMap.cs index 1a14f871..9ea01ef5 100644 --- a/Source/Core/Core/RowMap.cs +++ b/Source/Core/Core/RowMap.cs @@ -21,7 +21,7 @@ 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 +622,50 @@ 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 +1035,45 @@ 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 +1091,15 @@ 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 +1307,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 +1315,14 @@ 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 6b37032c..98b42c78 100644 --- a/Source/Core/Extensions/IExecuteReader.cs +++ b/Source/Core/Extensions/IExecuteReader.cs @@ -336,6 +336,74 @@ 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 03ee3747..b7076bb8 100644 --- a/Source/Core/MappedQuery.cs +++ b/Source/Core/MappedQuery.cs @@ -12,7 +12,8 @@ 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 +70,15 @@ 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 6990ea2f..4a5a2082 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.Core.Transformer.AsDequeueingEnumerable(Open.Database.Extensions.Core.QueryResult!>! results) -> System.Collections.Generic.IEnumerable! *REMOVED*static Open.Database.Extensions.Core.QueryResultExtensions.DequeueAs(this Open.Database.Extensions.Core.QueryResult!>! source, Open.Database.Extensions.RowMap? map = null) -> System.Collections.Generic.IEnumerable! *REMOVED*static Open.Database.Extensions.Core.QueryResultExtensions.DequeueAs(this System.Threading.Tasks.Task!>!>! source) -> System.Threading.Tasks.ValueTask!> From 072d6402e5142fc7cd9a594693b14ef6b99a6d6d Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:16:23 -0800 Subject: [PATCH 2/6] Tests pin the first-column read of a single value, the async terminals of a command and their binding Co-Authored-By: Claude Sonnet 5.5 --- .../SingleValueAllocationTests.cs | 97 ++++ .../SingleValueBindingTests.cs | 87 ++++ .../SingleValueTerminalTests.cs | 432 ++++++++++++++++++ .../UnmappableTypeTests.cs | 39 +- 4 files changed, 640 insertions(+), 15 deletions(-) create mode 100644 Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs create mode 100644 Tests/Open.Database.Extensions.Core.Tests/SingleValueBindingTests.cs create mode 100644 Tests/Open.Database.Extensions.Core.Tests/SingleValueTerminalTests.cs 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 00000000..030868c4 --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs @@ -0,0 +1,97 @@ +#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 00000000..b53aaa94 --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/SingleValueBindingTests.cs @@ -0,0 +1,87 @@ +#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 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 00000000..a7feed96 --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/SingleValueTerminalTests.cs @@ -0,0 +1,432 @@ +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 columns = [("Id", typeof(int)), ("Title", typeof(string))]; + FakeExecuteReader one = Cmd(columns, [1, "a"]); // SingleOrDefault needs one row + FakeExecuteReader many = Cmd(columns, [1, "a"], [2, null], [3, "c"]); + + Parity(one); + + Parity(one); + Parity(one); + Parity(one); + Assert.Equal([new() { Id = 1, Title = "a" }, new() { Id = 2 }, new() { Id = 3, Title = "c" }], many.Map().ToList()); + } + + // ---- 7. a row model maps by name; a type that cannot be mapped fails at the call ---- + + public interface IShapeless { } + + [Fact] + public static void ARowModel_MapsByColumnName() + { + FakeExecuteReader cmd = Cmd([("Title", typeof(string)), ("Id", typeof(int))], ["a", 1]); + Assert.Equal([new() { Id = 1, Title = "a" }], cmd.Map().ToList()); + Assert.Equal(new PostRow { Id = 1, Title = "a" }, cmd.Map().FirstOrDefault()); + } + + [Fact] + public static void AnUnmappableType_ThrowsAtTheCall_BeforeAnythingRuns() + { + FakeExecuteReader cmd = Cmd(typeof(int), 1); + Assert.Throws(() => cmd.Map().ToList()); + Assert.Throws(() => cmd.Map().ToArray()); + Assert.Throws(() => cmd.Map().ToImmutableArray()); + Assert.Throws(() => cmd.Map().FirstOrDefault()); + Assert.Throws(() => cmd.Map().SingleOrDefault()); + Assert.Throws(() => cmd.Map().ToHashSet()); + Assert.Throws(() => Drop(cmd.ToListAsync(Ct))); + Assert.Throws(() => Drop(cmd.ToArrayAsync(Ct))); + Assert.Throws(() => Drop(cmd.ToImmutableArrayAsync(Ct))); + Assert.Throws(() => Drop(cmd.FirstOrDefaultAsync(Ct))); + Assert.Throws(() => Drop(cmd.SingleOrDefaultAsync(Ct))); + Assert.Throws(() => Drop(cmd.ToHashSetAsync(cancellationToken: Ct))); + Assert.Equal(0, cmd.ExecuteCount); + } + + [Fact] + public static void ANullCommand_IsAnArgumentNullException_ForEveryType() + { + IExecuteReader? none = null; + Assert.Equal("command", Assert.Throws(() => none!.Map().ToList()).ParamName); + Assert.Equal("command", Assert.Throws(() => Drop(none!.ToListAsync(Ct))).ParamName); + Assert.Equal("command", Assert.Throws(() => none!.Map()).ParamName); + Assert.Equal("command", Assert.Throws(() => none!.Map()).ParamName); + } + + // ---- 8. Map() works; what takes a model still does not ---- + + [Fact] + public static void MapOfASingleValue_ReadsTheFirstColumn() + { + FakeExecuteReader cmd = Cmd(typeof(long), 1L, 2L); + Assert.Equal([1, 2], cmd.Map().ToList()); + Assert.Equal([2], cmd.Map().Skip(1).Take(1).ToList()); + Assert.Equal(1, cmd.Map().SingleResult().FirstOrDefault()); + Assert.Throws(() => cmd.Map(new KeyValuePair("Id", "Id"))); + } + + // ---- 9. read-through ---- + + [Fact] + public static void AFailureInALaterResultSet_IsThrown_UnlessSingleResult() + { + static FakeExecuteReader failing() => new(() => new MultiSetReader(Rows(2), 2, failAtCall: 2)); + + Assert.Throws(() => failing().Map().ToList()); + Assert.Throws(() => failing().Map().ToList()); + Assert.Equal([1, 2], failing().Map().SingleResult().ToList()); + } + + // ---- 10. tokens ---- + + public static TheoryData AsyncTerminals => ["ToListAsync", "ToArrayAsync", "ToImmutableArrayAsync", "FirstOrDefaultAsync", "SingleOrDefaultAsync", "ToHashSetAsync"]; + + static Task RunAsync(FakeExecuteReader cmd, string terminal, CancellationToken token) => terminal switch + { + "ToListAsync" => cmd.ToListAsync(token).AsTask(), + "ToArrayAsync" => cmd.ToArrayAsync(token).AsTask(), + "ToImmutableArrayAsync" => cmd.ToImmutableArrayAsync(token).AsTask(), + "FirstOrDefaultAsync" => cmd.FirstOrDefaultAsync(token).AsTask(), + "SingleOrDefaultAsync" => cmd.SingleOrDefaultAsync(token).AsTask(), + "ToHashSetAsync" => cmd.ToHashSetAsync(cancellationToken: token).AsTask(), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }; + +#pragma warning disable xUnit1051 // This test passes no token on purpose: it checks what the reader is handed with none. + [Theory, MemberData(nameof(AsyncTerminals))] + public static async Task EveryAsyncTerminal_GovernsTheCallByTheCallTokenAndTheCommandsTogether(string terminal) + { + // No token anywhere: the reader is handed none. + FakeExecuteReader none = Cmd(typeof(int), 1); + await RunAsync(none, terminal, default); + Assert.Equal(default, Assert.Single(none.ReaderTokens)); + + // Only the call's token, or only the command's: that token itself. + using var own = new CancellationTokenSource(); + using var call = new CancellationTokenSource(); + FakeExecuteReader onlyCall = Cmd(typeof(int), 1); + await RunAsync(onlyCall, terminal, call.Token); + Assert.Equal(call.Token, Assert.Single(onlyCall.ReaderTokens)); + + FakeExecuteReader onlyCommand = Cmd(typeof(int), 1); + onlyCommand.CancellationToken = own.Token; + await RunAsync(onlyCommand, terminal, default); + Assert.Equal(own.Token, Assert.Single(onlyCommand.ReaderTokens)); + + // Both live: one linked token, which is neither of them. + FakeExecuteReader both = Cmd(typeof(int), 1); + both.CancellationToken = own.Token; + await RunAsync(both, terminal, call.Token); + CancellationToken linked = Assert.Single(both.ReaderTokens); + Assert.True(linked.CanBeCanceled); + Assert.NotEqual(own.Token, linked); + Assert.NotEqual(call.Token, linked); + + // Either one canceled before the call stops it. + using var canceled = new CancellationTokenSource(); + canceled.Cancel(); + FakeExecuteReader callCanceled = Cmd(typeof(int), 1); + callCanceled.CancellationToken = own.Token; + await Assert.ThrowsAnyAsync(() => RunAsync(callCanceled, terminal, canceled.Token)); + + FakeExecuteReader commandCanceled = Cmd(typeof(int), 1); + commandCanceled.CancellationToken = canceled.Token; + await Assert.ThrowsAnyAsync(() => RunAsync(commandCanceled, terminal, call.Token)); + } +#pragma warning restore xUnit1051 + + // ---- 11. both reader routes ---- + + static IDataReader Plain(Type column, params object[] values) + { + IDataReader reader = Substitute.For(); + reader.FieldCount.Returns(1); + reader.GetName(0).Returns("v"); + reader.GetFieldType(0).Returns(column); + bool[] more = [.. values.Select(_ => true), false]; + reader.Read().Returns(more[0], more[1..]); + int i = -1; + reader.When(r => r.Read()).Do(_ => i++); + reader.IsDBNull(0).Returns(_ => values[i] is DBNull); + reader.GetInt32(0).Returns(_ => (int)values[i]); + reader.GetInt64(0).Returns(_ => (long)values[i]); + reader.GetValue(0).Returns(_ => values[i]); + return reader; + } + + [Fact] + public static void APlainIDataReader_GivesTheSameAnswersAsADbDataReader() + { + Assert.Equal([1, 2], new PlainReaderCommand(() => Plain(typeof(int), 1, 2)).Map().ToList()); + Assert.Equal([1, 2], new PlainReaderCommand(() => Plain(typeof(long), 1L, 2L)).Map().ToArray()); + Assert.Equal([1, 0], new PlainReaderCommand(() => Plain(typeof(int), 1, DBNull.Value)).Map().ToList()); + Assert.Equal([1, null], new PlainReaderCommand(() => Plain(typeof(int), 1, DBNull.Value)).Map().ToList()); + Assert.Equal(["1", "2"], new PlainReaderCommand(() => Plain(typeof(int), 1, 2)).Map().ToList()); + } + + // ---- 12. the reader is chosen per result set, from the column's type ---- + + [Fact] + public static void OneT_OverColumnsOfDifferentTypes_ReadsEachCorrectly() + { + Assert.Equal([1], Cmd(typeof(int), 1).Map().ToList()); + Assert.Equal([2], Cmd(typeof(long), 2L).Map().ToList()); + Assert.Equal([3], Cmd(typeof(int), 3).Map().ToList()); + Assert.Equal([4], Cmd(typeof(string), "4").Map().ToList()); + Assert.Equal([5], Cmd(typeof(long), 5L).Map().ToList()); + } + + // A provider such as SQLite reports the type of the current row's value: the getter chosen for the first row serves the rest of the result set. + sealed class ShiftingReader(Type first, object?[] cells) : TypedReader([("v", typeof(object))], [.. cells.Select(c => new object?[] { c })]) + { + int _reported; + public override Type GetFieldType(int ordinal) => _reported++ == 0 ? first : typeof(string); + public override long GetInt64(int ordinal) => Convert.ToInt64(GetValue(ordinal), System.Globalization.CultureInfo.InvariantCulture); + } + + [Fact] + public static void ALaterRowOfAnotherType_IsReadWithTheFirstRowsGetter() + { + // The provider coerces: the long getter serves a text row holding digits. + Assert.Equal([1, 22], new FakeExecuteReader(() => new ShiftingReader(typeof(long), [1L, "22"])).Map().ToList()); + + // The provider throws: a ValueMappingException, as for a property. + Assert.Throws(() => new FakeExecuteReader(() => new ShiftingReader(typeof(int), [1, "x"])).Map().ToList()); + } +} diff --git a/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs b/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs index 9c3e7661..efcb66ee 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs @@ -113,7 +113,7 @@ public struct StructWithPrivateConstructor public static StructWithPrivateConstructor Make() => new(0); } - static string Single(string name) => $"'{name}' is a single value, not a row model. Use ExecuteScalar() or FirstOrdinalResults()."; + static string Single(string name) => $"'{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()."; static string NotConcrete(string name, string kind) => $"'{name}' cannot be mapped: it is {kind}. Map to a class or struct, or use Map(selector)."; static string NoConstructor(string name) => $"'{name}' cannot be mapped: it has no public constructor."; static string Ambiguous(string name, int count, string signatures) => $"'{name}' cannot be mapped: it has {count} public constructors, {signatures}, and none without parameters. Make one of them non-public, or use Map(selector)."; @@ -151,7 +151,7 @@ public sealed class HoldsADictionary } // Every way to meet a type: the default map, a map constructed for it, and a query over it with no rows. Each one throws the same exception. - static void AssertUnmappable(string message) + static void AssertUnmappable(string message, bool mapReadsTheFirstColumn = false) { Assert.Equal(message, Assert.Throws(() => RowMap.Default).Message); Assert.Equal(message, Assert.Throws(() => RowMap.Default).Message); // Reading it again throws again: it is not a failed type initializer. @@ -160,7 +160,8 @@ static void AssertUnmappable(string message) Assert.Equal(message, Assert.Throws(() => RowMap.From([])).Message); var command = new FakeExecuteReader(["Id"]); - Assert.Equal(message, Assert.Throws(() => command.Map()).Message); + if (mapReadsTheFirstColumn) _ = command.Map(); // a single value is read from the first column: Map() works, and runs nothing yet + else Assert.Equal(message, Assert.Throws(() => command.Map()).Message); Assert.Equal(0, command.ExecuteCount); // Nothing ran: the call failed before the query was executed. Assert.Equal(message, Assert.Throws(() => FakeReader.Create(["Id"]).Results()).Message); @@ -181,18 +182,20 @@ static void AssertUnmappable(string message) [Fact] public static void Scalars_AreRejected_WithAPointerToTheScalarMethods() { - AssertUnmappable(Single("Int32")); - AssertUnmappable(Single("String")); - AssertUnmappable(Single("Int32?")); - AssertUnmappable(Single("Guid")); - AssertUnmappable(Single("Decimal")); - AssertUnmappable(Single("DateTime")); - AssertUnmappable(Single("DateTimeOffset?")); - AssertUnmappable(Single("TimeSpan")); - AssertUnmappable(Single("Boolean")); - AssertUnmappable(Single("ShapeKind")); - AssertUnmappable(Single("ShapeKind?")); - AssertUnmappable(Single("Byte[]")); + AssertUnmappable(Single("Int32"), true); + AssertUnmappable(Single("String"), true); + AssertUnmappable(Single("Int32?"), true); + AssertUnmappable(Single("Guid"), true); + AssertUnmappable(Single("Decimal"), true); + AssertUnmappable(Single("DateTime"), true); + AssertUnmappable(Single("DateTimeOffset?"), true); + AssertUnmappable(Single("TimeSpan"), true); + AssertUnmappable(Single("Boolean"), true); + AssertUnmappable(Single("ShapeKind"), true); + AssertUnmappable(Single("ShapeKind?"), true); + AssertUnmappable(Single("Byte[]"), true); + AssertUnmappable(Single("DateOnly"), true); + AssertUnmappable(Single("TimeOnly?"), true); } [Fact] @@ -384,6 +387,12 @@ static Task Run(Action call) ["Map().ToListAsync()"] = async () => await Exec().Map().ToListAsync(), ["Map().FirstOrDefaultAsync()"] = async () => await Exec().Map().FirstOrDefaultAsync(), ["Map().ToDictionaryAsync()"] = async () => await Exec().Map().ToDictionaryAsync(_ => 0), + ["ToListAsync()"] = async () => await Exec().ToListAsync(), + ["ToArrayAsync()"] = async () => await Exec().ToArrayAsync(), + ["ToImmutableArrayAsync()"] = async () => await Exec().ToImmutableArrayAsync(), + ["FirstOrDefaultAsync()"] = async () => await Exec().FirstOrDefaultAsync(), + ["SingleOrDefaultAsync()"] = async () => await Exec().SingleOrDefaultAsync(), + ["ToHashSetAsync()"] = async () => await Exec().ToHashSetAsync(), ["Map(pair)"] = () => Run(() => Exec().Map(new KeyValuePair("Id", "Id")).ToList()), ["Map(pairs)"] = () => Run(() => Exec().Map(new KeyValuePair("Id", "Id"), new("Name", "Name")).ToList()), From 04b4adfc2510886bf17d160addbcf9c0b7abee03 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:16:23 -0800 Subject: [PATCH 3/6] The docs and samples show Map() and ToArrayAsync(ct) reading one column Co-Authored-By: Claude Sonnet 5.5 --- docsite/articles/coming-from-dapper.md | 6 +++--- docsite/articles/expressive-commands.md | 6 ++++++ docsite/articles/foundation.md | 2 +- docsite/articles/mapping.md | 22 +++++++++++++++++++--- docsite/articles/whats-new-11.md | 9 +++++---- docsite/samples/ExpressiveCommands.cs | 12 ++++++++++++ docsite/samples/Mapping.cs | 10 ++++++++++ docsite/samples/Program.cs | 2 ++ 8 files changed, 58 insertions(+), 11 deletions(-) diff --git a/docsite/articles/coming-from-dapper.md b/docsite/articles/coming-from-dapper.md index 11d7e3ad..4f8bc429 100644 --- a/docsite/articles/coming-from-dapper.md +++ b/docsite/articles/coming-from-dapper.md @@ -96,7 +96,7 @@ Lines you write, counted from `dapper_scalar_call` and `ode_scalar_call`: Dapper What is different: -- **The value's type.** Dapper converts the provider's value to `T` where it can (it uses `Convert.ChangeType`) and returns `default` for `NULL`. The extensions' `ExecuteScalar()` also returns `default` for `NULL` or no rows, and otherwise casts the value exactly as the provider returns it, so `T` must be that type: SQLite returns `count(*)` as `long` and SQL Server as `int`, and any other `T` throws `InvalidCastException`. To convert, pass a function: `ExecuteScalar(value => ...)`. +- **The value's type.** Dapper converts the provider's value to `T` where it can (it uses `Convert.ChangeType`) and returns `default` for `NULL`. The extensions' `ExecuteScalar()` also returns `default` for `NULL` or no rows, and otherwise casts the value exactly as the provider returns it, so `T` must be that type: SQLite returns `count(*)` as `long` and SQL Server as `int`, and any other `T` throws `InvalidCastException`. To convert, pass a function: `ExecuteScalar(value => ...)`. For many rows of one column, `Map().ToList()` converts like a property (a `long` into an `int` included), which `ExecuteScalar()` does not. ### A command with no result set @@ -274,10 +274,10 @@ 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` | -| `Query` over one column | `FirstOrdinalResults()`, which casts the first column of every row to `T`, so `T` is the provider's own type (`long` for an integer on SQLite). `Map()` throws: a single value is not a row model | +| `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()`; `T` must be the type the provider returns | | `Execute` | `ExecuteNonQuery()` | -| `QueryAsync` | `Map().ToListAsync()`, which returns a `ValueTask` (async terminals need a factory of a `DbConnection` type) | +| `QueryAsync` | `ToListAsync(cancellationToken)` (or `Map().ToListAsync()`), which returns a `ValueTask` (async terminals need a factory of a `DbConnection` type) | | `Execute("usp_x", ..., commandType: CommandType.StoredProcedure)` | `StoredProcedure("usp_x")`, then `AddParam(...)` and `ExecuteNonQuery()` (see [Return values and output parameters](expressive-commands.md#return-values-and-output-parameters)) | | A transaction passed to each call | Start from the transaction: `transaction.Command(sql)` | diff --git a/docsite/articles/expressive-commands.md b/docsite/articles/expressive-commands.md index 64716cde..eb6a7d02 100644 --- a/docsite/articles/expressive-commands.md +++ b/docsite/articles/expressive-commands.md @@ -122,8 +122,13 @@ 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) | | `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: + +[!code-csharp[](../samples/ExpressiveCommands.cs#single_value_async)] + `ExpressiveDbCommand` and `ExpressiveSqlCommand` add `Async` twins (for example `ExecuteNonQueryAsync()`) that honor the command's cancellation token (`UseCancellationToken(token)`). `SetTimeout(seconds)` sets the command timeout. The default is 60 seconds. On SQL Server the lookup of a stored procedure's parameter names uses the same timeout. A failed lookup throws the provider's own exception (a `SqlException` stays a `SqlException`, so a retry policy still sees it), with the procedure's name in `Exception.Data["Procedure"]`. `UseCancellationToken(token)` sets a token for the command. A token passed to an async terminal such as `ToListAsync(token)` is used together with it, and either one cancels the call. The call then stops with `OperationCanceledException`, except that a provider may report a cancellation that arrives while the command is executing as its own exception (SQL Server's `SqlException`, "Operation cancelled by user"); the library does not translate it. The token covers opening the connection, looking up stored-procedure parameter names, executing the command and reading the rows. @@ -133,6 +138,7 @@ Output values cannot be read back yet. You can send an output parameter (`AddPar - **`ExecuteScalar()`** when `T` is the type the provider returns. A `NULL` or no rows gives `default(T)`. - **`ExecuteScalar(value => ...)`** to convert. The function takes the provider's value as an `object?` (`Func`): `null` for a `NULL` or no rows, never `DBNull`. - **`ExecuteScalar()`** for the raw value: `DBNull.Value` for a `NULL`, `null` for no rows. +- **`ToListAsync()`** (or `Map().ToList()`, `ToArrayAsync()`, ...) for many rows of one column: it converts like a property (a SQLite `long` into `int`), where `ExecuteScalar()` casts. See [One value per row](mapping.md#one-value-per-row). `ExecuteScalar()` casts the value the provider returns, so `T` must be that exact type, and any other type throws `InvalidCastException`. To tell a `NULL` from 0, use the nullable form of the provider's type: `long?` for `count(*)` on SQLite, `int?` on SQL Server. diff --git a/docsite/articles/foundation.md b/docsite/articles/foundation.md index 78e09bb0..d9999521 100644 --- a/docsite/articles/foundation.md +++ b/docsite/articles/foundation.md @@ -46,6 +46,6 @@ Several helpers exist to keep the time a connection is open short: ## Reader and command helpers -The foundation also includes helpers you can use without the expressive syntax: `ForEach` and `ForEachAsync` over a reader, `Select` and `SelectAsync`, `AsEnumerable` and `AsAsyncEnumerable` (rows as `object[]`), `ToList`, `ToArray`, `ToDataTable` and friends on commands. Buffered reads copy the rows out of the reader first, so the connection can be released early: `Retrieve()` copies every row into a queue of `object[]` (raw rows with column names; `DequeueAsMappedDictionaries()` turns them into dictionaries). `DequeueAs()`, which mapped that queue to `T`, is deprecated: use `Map().ToList()`. `ResultsBuffered()` (and `Results()` on an `IDbCommand`, which buffers the rows and stays) buffers and maps in one call; `Results()` on an `IExecuteReader` is deprecated, use `Map().ToList()`. `FirstOrdinalResults()` reads the first column of every row as `T`. For typed rows `Map()` is the better route (see [Performance](performance.md#where-it-is-not-faster)). +The foundation also includes helpers you can use without the expressive syntax: `ForEach` and `ForEachAsync` over a reader, `Select` and `SelectAsync`, `AsEnumerable` and `AsAsyncEnumerable` (rows as `object[]`), `ToList`, `ToArray`, `ToDataTable` and friends on commands. Buffered reads copy the rows out of the reader first, so the connection can be released early: `Retrieve()` copies every row into a queue of `object[]` (raw rows with column names; `DequeueAsMappedDictionaries()` turns them into dictionaries). `DequeueAs()`, which mapped that queue to `T`, is deprecated: use `Map().ToList()`. `ResultsBuffered()` (and `Results()` on an `IDbCommand`, which buffers the rows and stays) buffers and maps in one call; `Results()` on an `IExecuteReader` is deprecated, use `Map().ToList()`. `FirstOrdinalResults()` reads the first column of every row as `T`. On a command, `Map()` (or `ToListAsync()`) reads the first column as `int` with the mapper's conversions, and `FirstOrdinalResults()` is the form for a raw command and a reader (see [One value per row](mapping.md#one-value-per-row)). For typed rows `Map()` is the better route (see [Performance](performance.md#where-it-is-not-faster)). See the [API reference](xref:Open.Database.Extensions) for the full list. diff --git a/docsite/articles/mapping.md b/docsite/articles/mapping.md index 150da3fd..78abb531 100644 --- a/docsite/articles/mapping.md +++ b/docsite/articles/mapping.md @@ -111,13 +111,29 @@ A type that cannot be mapped throws `InvalidOperationException` at the call, bef | An interface | `'IPost' cannot be mapped: it is an interface. Map to a class or struct, or use Map(selector).` | | An abstract class or a delegate | The same, with `it is an abstract class` or `it is a delegate` | | A dictionary (`Dictionary<,>`, `IDictionary<,>`, `IReadOnlyDictionary<,>`) or a dynamic object (`ExpandoObject`) | ``'Dictionary`2' 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).`` | -| A single value (`int`, `string`, ...) | `'Int32' is a single value, not a row model. Use ExecuteScalar() or FirstOrdinalResults().` | +| A single value (`int`, `string`, ...) passed to a method that maps rows to a model (`RowMap`, `Results`, `ToChannel`, `To(DataTable)`, ...) | `'Int32' is a single value, not a row model. On a command use Map() or ToListAsync(), which read the first column; else FirstOrdinalResults() or ExecuteScalar().` | | No public constructor | `'Post' cannot be mapped: it has no public constructor.` | | Two or more public constructors, none without parameters | `'PostCard' cannot be mapped: it has 2 public constructors, PostCard(String title) and PostCard(String title, String body), and none without parameters. Make one of them non-public, or use Map(selector).` | | A `required` public field | `'Post' cannot be mapped: its required field 'Title' cannot be set. Make it a property.` | | A constructor parameter that is `ref`, `in`, `out` or a pointer | `'Post' cannot be mapped: its constructor parameter 'id' is passed by reference or is a pointer. Use Map(selector).` | -`FirstOrdinalResults()` reads the first column of every row as `T` (see [the foundation](foundation.md#reader-and-command-helpers)). +## 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()`. + +[!code-csharp[](../samples/Mapping.cs#map_single_value)] + +The rules: + +- **Which types.** A primitive, an enum, `string`, `decimal`, `DateTime`, `DateTimeOffset`, `TimeSpan`, `DateOnly`, `TimeOnly`, `Guid`, `byte[]` and a `Nullable<>` of those. Not `object`, `char[]`, a provider type such as `SqlInt32`, or a `Stream` (the reader is closed when the terminal returns): use `Map(selector)` for those. +- **The first column, whatever its name.** The other columns are ignored, so select only the one you need. +- **Converted like a property of that type.** When the column type matches, or is an integer that fits (a `long` into an `int` is range-checked; a `short` widens), the value is read with the typed getter and nothing is boxed. Otherwise it is converted: a `decimal` into an `int` rounds, text into a `Guid`, an enum from its name (ignoring case) or its number, a `DateTime` into a `DateTimeOffset`. A value that cannot be converted throws `ValueMappingException`. The reader is chosen once per result set, from the type the first row reports. +- **NULL.** A non-nullable value type gets `default` (`0`, `Guid.Empty`); `int?`, `string` and `byte[]` get `null`. Declare `int?` to tell NULL from 0. +- **Read through.** As every `Map` terminal, they (and the async forms on the command) read through the later result sets; `.SingleResult()` on `Map()` stops after the first. + +It differs from the two other ways to read one column. `FirstOrdinalResults()` returns the provider's own value (its typed getter, or a plain cast) from the first result set only, and is the form for a raw `IDbCommand` or a reader (see [the foundation](foundation.md#reader-and-command-helpers)); `ExecuteScalar()` returns one value, cast exactly as the provider returns it (see [which scalar form](expressive-commands.md#which-scalar-form)). + +Pass the token or nothing to these `Async` forms: `ToListAsync(default)` on an expressive command binds to its older `ToListAsync(transform, behavior)`, which has no transform to call. `ToHashSetAsync(cancellationToken: ct)` names the token because the comparer comes first, as on `MappedQuery`. Errors in the data throw per row: a missing `required` column and an unconvertible value throw `ValueMappingException`, and a constructor that throws is wrapped (see [Values that cannot be mapped](#values-that-cannot-be-mapped)). @@ -235,7 +251,7 @@ The deprecated `Results` on an `IExecuteReader`, the selector `FirstOrDefault `.SingleResult()` goes after `Map` (and on the result of `Skip` and `Take`). 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: ```csharp -var ids = await command.Map(r => r.GetInt32(0)).SingleResult().ToListAsync(); +var ids = await command.Map().SingleResult().ToListAsync(); ``` > [!WARNING] diff --git a/docsite/articles/whats-new-11.md b/docsite/articles/whats-new-11.md index 8ada7203..f652aa05 100644 --- a/docsite/articles/whats-new-11.md +++ b/docsite/articles/whats-new-11.md @@ -26,10 +26,11 @@ Version 11 is a performance release and a tidy-up release. Result mapping is rew - **Faster, allocation-light mapping.** The mapping for a result-set shape is compiled once and cached, and values are read with typed getters, so nothing is boxed on the common path. Before, every `Map()` compiled a setter per property (about 0.94 ms per query in the library's benchmark). In that benchmark (in-memory provider, 12-column rows, Dapper 2.1.89), reading 100 or more rows into a list or array takes 0.36 to 0.42 times Dapper's time (2.4 to 2.8 times faster) and 0.33 to 0.37 times its allocations (`Benchmarks/RESULTS.md`, re-measured on the release candidate; a positional record costs the same). Against SQL Server LocalDB the database dominates: times are 0.92 to 1.04 times Dapper's in that benchmark (0.89 to 1.08 in the re-run), within the noise, and allocations 0.55 to 0.6 times on multi-row results. - **`RowMap`: declare a column mapping once.** `public static RowMap PostMap => field ??= [new(nameof(Post.Id), "post_id"), ...];`, passed as the `map` argument of every method that maps rows to a model. It takes one pair, several or none, and a read through it allocates what `Map()` allocates. With no map, a column matches a property exactly, else ignoring case, else ignoring case and underscores (`first_name` fills `FirstName`). Only public instance properties are mapped. See [Columns named differently](mapping.md#columns-named-differently) and [snake_case columns map to PascalCase properties](mapping.md#snake_case-columns-map-to-pascalcase-properties). -- **`MappedQuery`: direct read loops, and one token for the whole call.** `Map()` returns a deferred query. `ToList`, `ToArray`, `ToImmutableArray`, `ToHashSet`, `ToDictionary`, `FirstOrDefault`, `SingleOrDefault`, `Read` and their async twins read straight from the reader; `Skip` advances without mapping and `Take` maps no more rows once it is met. They are instance methods, so the query struct is not boxed; the `IMappedQuery` extensions remain for generic code. The token passed to an async terminal is used together with the command's `UseCancellationToken` token, and either one cancels the whole call: opening the connection, looking up parameter names, executing and reading. Readers open with `SequentialAccess` (`Map(selector)` skips it, because a selector may read columns in any order); `.SingleResult()` adds `SingleResult`. See [Terminals](expressive-commands.md#terminals). +- **`MappedQuery`: direct read loops, and one token for the whole call.** `Map()` returns a deferred query. `ToList`, `ToArray`, `ToImmutableArray`, `ToHashSet`, `ToDictionary`, `FirstOrDefault`, `SingleOrDefault`, `Read` and their async twins read straight from the reader; `Skip` advances without mapping and `Take` maps no more rows once it is met. They are instance methods, so the query struct is not boxed; the `IMappedQuery` extensions remain for generic code. The token passed to an async terminal is used together with the command's `UseCancellationToken` token, and either one cancels the whole call: opening the connection, looking up parameter names, executing and reading. Readers open with `SequentialAccess` (`Map(selector)` skips it, because a selector may read columns in any order); `.SingleResult()` adds `SingleResult`. The async terminals exist on the command itself too: `ToListAsync()` is `Map().ToListAsync()`. See [Terminals](expressive-commands.md#terminals). +- **Commands take the type, asynchronously.** `ToListAsync()`, `ToArrayAsync()`, `ToImmutableArrayAsync()`, `FirstOrDefaultAsync()`, `SingleOrDefaultAsync()` and `ToHashSetAsync()` on a command are `Map()` then the terminal (there is no synchronous shortcut: write `Map().ToList()`): `await db.Command(sql).ToArrayAsync(ct)`. A single value such as `int` reads the first column, converted as a property of that type would be; `Map()` no longer throws. See [One value per row](mapping.md#one-value-per-row). - **Less overhead around the query.** `IExecuteReader` has state-passing `ExecuteReader` overloads, so a `static` lambda allocates no closure. A command created from a connection allocates no pool wrapper, `Params` is created on first use, and async execution skips awaiting when the connection is already open. -- **Constructor binding, `required` and `init`.** Positional records, record structs and classes with one public constructor are built through it, and the `init` and settable properties it does not cover are set; a parameter is fed like a property. A `required` property is enforced: when no column feeds it, the first row of that shape throws `ValueMappingException`. `where T : new()` is gone from every method that maps rows, so a type that cannot be mapped (an interface, an abstract class, a delegate, a single value such as `int`, a class with no public constructor, two or more public constructors and none without parameters (use `Map(selector)`), or a `required` public field) throws `InvalidOperationException` at the call. See [Records and constructors](mapping.md#records-and-constructors). -- **Clear NULL rules.** A nullable member (`int?`, `string?`), a reference type in code with nullable annotations off, and a member declared as a type parameter get `null`. A non-nullable value type, or a non-nullable reference with nullable annotations on, keeps its initializer, else its default; a constructor parameter gets its declared default (`int views = 3` gives 3), else `default`, also when no column feeds it. Nothing throws. `ExecuteScalar()`, `ExecuteReturn()` and `FirstOrdinalResults` give `default(T)` for NULL. See [NULL in a non-nullable column](mapping.md#null-in-a-non-nullable-column). +- **Constructor binding, `required` and `init`.** Positional records, record structs and classes with one public constructor are built through it, and the `init` and settable properties it does not cover are set; a parameter is fed like a property. A `required` property is enforced: when no column feeds it, the first row of that shape throws `ValueMappingException`. `where T : new()` is gone from every method that maps rows, so a type that cannot be mapped (an interface, an abstract class, a delegate, a class with no public constructor, two or more public constructors and none without parameters (use `Map(selector)`), or a `required` public field) throws `InvalidOperationException` at the call. A single value such as `int` is not a row model but is read from the first column by `Map()` and `ToListAsync()`. See [Records and constructors](mapping.md#records-and-constructors). +- **Clear NULL rules.** A nullable member (`int?`, `string?`), a reference type in code with nullable annotations off, and a member declared as a type parameter get `null`. A non-nullable value type, or a non-nullable reference with nullable annotations on, keeps its initializer, else its default; a constructor parameter gets its declared default (`int views = 3` gives 3), else `default`, also when no column feeds it. Nothing throws. `ExecuteScalar()`, `ExecuteReturn()` and `FirstOrdinalResults` give `default(T)` for NULL. A single value read by `Map()` or `ToListAsync()` follows the same rule: `default(T)` for a non-nullable value type, `null` for `int?`, `string` and `byte[]`. See [NULL in a non-nullable column](mapping.md#null-in-a-non-nullable-column). - **`ValueMappingException`.** A value that cannot be mapped (an unconvertible value, an overflow) throws it: a `DataException` that names the property, the column and the target type. Provider exceptions (`DbException`) are not wrapped. - **Stored procedures without spelling every name twice.** `AddParam(author)` and `AddParamIf(condition, author)` take the parameter name from the argument (a variable or a chain such as `search.Author`) and match it to the procedure's real parameter name: exactly, ignoring case, ignoring case and underscores (`TitleContains` finds `@title_contains`), then without a common prefix (`p_`, `in_`, `i_`, `prm_`, `par_`, `v_`); `m_author`, `s_author` and `_author` also try `author`. `AddParams(source)` sends the public properties and fields of `source` that the procedure declares. A name you pass yourself, `AddParam("name", value)`, always wins. A NULL is sent with the type of its argument. SQL Server is supported in both SQL Server packages. See [Parameters](expressive-commands.md#name-a-parameter-from-its-argument). - **Five packages.** `Open.Database.Extensions.Core` (any ADO.NET provider), `MSSqlClient` (SQL Server on `Microsoft.Data.SqlClient`), `Channel`, `Dataflow`, and `SqlClient` (the older `System.Data.SqlClient`, **deprecated in favor of MSSqlClient**). Each README opens with its banner and has the same table of the five. @@ -64,7 +65,7 @@ These can change behavior without a compile error, most likely first: 6. **The scalar transform overloads receive `null`, not `DBNull`, for a NULL:** `ExecuteScalar(Func)` and its two async forms. A function that tested `o is DBNull` must test `o is null`. The raw `ExecuteScalar()` still returns `DBNull.Value`. 7. **`IExecuteReader` has three new members:** two state-passing overloads and an `ExecuteReaderAsync` that takes the call's token. A custom implementation must add them; the `ExpressiveCommand*` classes already do. 8. **`RowMap.Default` is a property,** not a field; reading it throws `InvalidOperationException` for a type that cannot be mapped. -9. **`Map()` and other unmappable types throw at the call,** even for an empty result. They used to return default objects. For one value use `ExecuteScalar()` or `FirstOrdinalResults()`. +9. **`Map()` of a single value such as `int` reads the first column** (10.x returned `default(T)` for every row). Other types that cannot be mapped (an interface, an abstract class, ...) throw at the call, even for an empty result. 10. **A struct with one declared public constructor is built through it.** A `record struct` gets equal results; a struct whose constructor does more than assign now runs it. A constructor parameter no column feeds gets its declared default, replacing the plain default of before. 11. **Channels and Dataflow.** On the mapped overloads a mapping failure faults the target when `complete` is `true`; the target is never completed or faulted before the reader is released; `ArgumentNullException.ParamName` for a null target is `"target"`; a cancelled mapped async channel call succeeds only if the reader had already reported the end of the results, else it throws `OperationCanceledException`. 12. **Async calls pass the command's `CancellationToken` to the connection open.** diff --git a/docsite/samples/ExpressiveCommands.cs b/docsite/samples/ExpressiveCommands.cs index fb0d7199..99dfb5ff 100644 --- a/docsite/samples/ExpressiveCommands.cs +++ b/docsite/samples/ExpressiveCommands.cs @@ -36,6 +36,18 @@ public static Post[] ConnectionForm(SqliteConnection connection) return posts; } + public static async Task ColumnOfIds(IDbConnectionFactory connectionFactory, CancellationToken cancellationToken) + { + #region single_value_async + // The terminals take the type: ToArrayAsync(token) reads the first column of each row, as Map().ToArrayAsync(token) does. + int[] ids = await connectionFactory + .Command("select id from posts order by id") + .ToArrayAsync(cancellationToken); + #endregion + + return ids; + } + public static Post? FirstPost(IDbConnectionFactory connectionFactory) { const int id = 3; diff --git a/docsite/samples/Mapping.cs b/docsite/samples/Mapping.cs index 5334d9a8..3c092e1c 100644 --- a/docsite/samples/Mapping.cs +++ b/docsite/samples/Mapping.cs @@ -287,6 +287,16 @@ public static string[] MapSelector(SqliteConnection connection) return titles; } + public static int[] MapSingleValue(SqliteConnection connection) + { + #region map_single_value + // One column of every row as an int. A SQLite integer is a long; it converts to int with a range check, as a property would. + int[] ids = connection.Command("select id from posts order by id").Map().ToArray(); + #endregion + + return ids; + } + public static int StreamRows(SqliteConnection connection) { #region map_stream diff --git a/docsite/samples/Program.cs b/docsite/samples/Program.cs index 370d52db..5a3df977 100644 --- a/docsite/samples/Program.cs +++ b/docsite/samples/Program.cs @@ -103,6 +103,8 @@ void Check(bool condition, string what) Check(first?.Id == 1 && single?.Id == 2, "first and single"); Check(Throws(() => connection.Command("select * from posts").Map().SingleOrDefault()), "SingleOrDefault throws for more than one row"); Check(Mapping.MapSelector(connection)[0] == "Post 1 (10 views)", "Map(selector)"); +Check(Mapping.MapSingleValue(connection).SequenceEqual(connection.Command("select id from posts order by id").Map(static r => (int)r.GetInt64(0)).ToList()) && Mapping.MapSingleValue(connection).Length == 5, "Map() reads the first column, a SQLite long converted to int"); +Check((await ExpressiveCommands.ColumnOfIds(factory, CancellationToken.None)).SequenceEqual([1, 2, 3, 4, 5]), "ToArrayAsync(token) on a command"); Check(Mapping.StreamRows(connection) == 15, "Read streams rows inside the handler"); Check((await Mapping.ByIdAsync(connection, CancellationToken.None)).Count == 5, "ToDictionaryAsync"); From e11331d7ec3e99e5273b6f260be5685acc6bc44f Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:34:59 -0800 Subject: [PATCH 4/6] The command terminals follow the vertical style: one parameter per line, => on its own line, doc comments wrapped at natural breaks Co-Authored-By: Claude Sonnet 5.5 --- Source/Core/Core/RowMap.cs | 106 +++++- Source/Core/Extensions/IExecuteReader.cs | 110 ++++-- Source/Core/MappedQuery.cs | 20 +- .../SingleValueAllocationTests.cs | 84 ++++- .../SingleValueBindingTests.cs | 72 +++- .../SingleValueTerminalTests.cs | 355 ++++++++++++++---- .../UnmappableTypeTests.cs | 22 +- docsite/samples/ExpressiveCommands.cs | 8 +- docsite/samples/Mapping.cs | 9 +- docsite/samples/Program.cs | 14 +- 10 files changed, 622 insertions(+), 178 deletions(-) diff --git a/Source/Core/Core/RowMap.cs b/Source/Core/Core/RowMap.cs index 9ea01ef5..5cebea8d 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 (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). +/// 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,14 +632,22 @@ 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. + // 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)])!; + 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"); @@ -637,9 +655,28 @@ internal static Func FirstColumnReader(Type columnType) 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 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, @@ -648,22 +685,40 @@ internal static Func FirstColumnReader(Type columnType) Expression.Condition( Expression.OrElse( Expression.TypeIs(ex, typeof(DbException)), - Expression.TypeIs(ex, typeof(OperationCanceledException))), + Expression.TypeIs( + ex, + typeof(OperationCanceledException))), Expression.Rethrow(typeof(T)), Expression.Throw( - Expression.Call(typeof(RowMap).GetMethod(nameof(FirstColumnError), BindingFlags.Static | BindingFlags.NonPublic)!, ex, r), + 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(); + return Expression + .Lambda>( + Expression.Block(typeof(T), [cell], guarded), + r) + .Compile(); } - static ValueMappingException FirstColumnError(Exception ex, IDataRecord record) + 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); + 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): @@ -1035,12 +1090,18 @@ 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. +// 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; + internal static readonly FirstColumn? Instance + = Shape.IsScalar(typeof(T)) ? new() : null; sealed class Entry(Type columnType) { @@ -1091,7 +1152,8 @@ 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. + // The one value of a single-value model type (FirstColumn): no name, + // nothing to set. public Slot(Type type) { Name = ""; @@ -1315,13 +1377,17 @@ public void ThrowIfUnmappable() } // A single value, not a row: a column of it, not a model with properties. Nullable of these is one too. - // DateOnly and TimeOnly are matched by name, so every target framework agrees (the netstandard builds cannot name them). + // 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"; } diff --git a/Source/Core/Extensions/IExecuteReader.cs b/Source/Core/Extensions/IExecuteReader.cs index 98b42c78..640f631b 100644 --- a/Source/Core/Extensions/IExecuteReader.cs +++ b/Source/Core/Extensions/IExecuteReader.cs @@ -338,69 +338,133 @@ public static ImmutableArray ToImmutableArray(this IExecuteReader command, #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). + // 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. + /// 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 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. + /// 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. + /// + /// 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); + 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). + /// + /// 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); + 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). + /// + /// 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); + 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). + /// + /// 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); + 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). + /// + /// 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); + /// + /// 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). + /// 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); + public static ValueTask> ToHashSetAsync( + this IExecuteReader command, + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + => command.Map().ToHashSetAsync(comparer, cancellationToken); #endregion diff --git a/Source/Core/MappedQuery.cs b/Source/Core/MappedQuery.cs index b7076bb8..af396ae2 100644 --- a/Source/Core/MappedQuery.cs +++ b/Source/Core/MappedQuery.cs @@ -12,8 +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 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) + /// 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. /// @@ -74,9 +84,11 @@ public static MappedQuery Map( 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. + // 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)) + ? new MappedQuery( + new MappedQueryCore(command, firstColumn, 0, -1, false)) : Map(command, RowMap.Default); } diff --git a/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs b/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs index 030868c4..300b8088 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs @@ -2,14 +2,20 @@ 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. +// 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). + // 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(); @@ -18,17 +24,25 @@ static long BytesPerCall(Action call) { long before = GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < 1000; i++) call(); - best = Math.Min(best, (GC.GetAllocatedBytesForCurrentThread() - before) / 1000); + 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. + // 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) + // 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); @@ -37,24 +51,39 @@ static PlainReaderCommand Rows(Type column, int count, Func value) return new PlainReaderCommand(() => new DataTableReader(table)); } - static void NoMoreThanByHand(Action library, Action byHand, string what) + 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)."); + 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) + 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"); + 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] @@ -65,20 +94,34 @@ 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"); + 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) + 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"); + 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"); + NoMoreThanByHand( + () => texts.Map().ToList(), + () => texts + .Map(static r => r.IsDBNull(0) ? null : r.GetString(0)) + .ToList(), + $"ToList over {rows} rows"); } [Theory] @@ -91,7 +134,12 @@ public static void ToListAsyncOfInt_AllocatesNoMoreThanASelector(int rows) NoMoreThanByHand( () => cmd.ToListAsync(Ct).AsTask().GetAwaiter().GetResult(), - () => cmd.Map(static r => r.GetInt32(0)).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 index b53aaa94..a0c85a20 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/SingleValueBindingTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/SingleValueBindingTests.cs @@ -4,16 +4,27 @@ 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. +// 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()) +// 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) +// 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) +// 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 { @@ -22,10 +33,15 @@ public static class SingleValueBindingTests static void Pin(T _) { } static void PinAsync(ValueTask _) { } - static int Id(IDataRecord r) => r.GetInt32(0); + 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 Table = static (cmd, exec, ct) => + // 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))); @@ -35,14 +51,18 @@ static void PinAsync(ValueTask _) { } 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.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)); + PinAsync>( + cmd.ToListAsync( + r => r.GetInt32(0), + CommandBehavior.SequentialAccess)); // The new terminals. @@ -56,30 +76,42 @@ static void PinAsync(ValueTask _) { } PinAsync(cmd.FirstOrDefaultAsync(ct)); PinAsync(cmd.SingleOrDefaultAsync(ct)); PinAsync>(cmd.ToHashSetAsync()); - PinAsync>(cmd.ToHashSetAsync(EqualityComparer.Default, ct)); + 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)); + // 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); + 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. + // 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"); + 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!)); + 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 index a7feed96..9fb0c158 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/SingleValueTerminalTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/SingleValueTerminalTests.cs @@ -4,14 +4,18 @@ 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. +// 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(); + 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 } @@ -22,15 +26,23 @@ public sealed record PostRow public string? Title { get; set; } } - // One column named "v" of the given type, one row per cell (a null is a NULL). + // 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 })])); + => new(() => TypedReader.Create( + [("v", column)], + [.. cells.Select(c => new object?[] { c })])); - static FakeExecuteReader Cmd(ImmutableArray columns, params object?[][] rows) + 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) + static void Check( + Type column, + object? cell, + T expected) { FakeExecuteReader cmd = Cmd(column, cell); Assert.Equal([expected], cmd.Map().ToList()); @@ -39,7 +51,9 @@ static void Check(Type column, object? cell, T expected) 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()); + Assert.Equal( + [expected], + cmd.ToListAsync(Ct).AsTask().GetAwaiter().GetResult()); } // ---- 1. every single-value type, from a column of its own type ---- @@ -68,9 +82,15 @@ public static void EverySingleValueType_IsReadFromAColumnOfItsOwnType() 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(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(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); @@ -86,10 +106,19 @@ public static void ANullableOfEverySingleValue_IsReadFromAColumnOfTheUnderlyingT 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(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)); + Check( + typeof(DateOnly), + new DateOnly(2026, 1, 2), + new DateOnly(2026, 1, 2)); } // ---- 2. NULL ---- @@ -127,7 +156,8 @@ public static void ALongColumn_ConvertsToInt_WithARangeCheck() { Check(typeof(long), 5L, 5); - ValueMappingException failure = Assert.Throws(() => Cmd(typeof(long), 5_000_000_000L).Map().ToList()); + 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); @@ -161,12 +191,15 @@ public static void TextAndDates_ConvertLikeAProperty() [Fact] public static void AValueThatDoesNotConvert_IsAValueMappingException_NamingTheColumn() { - ValueMappingException text = Assert.Throws(() => Cmd(typeof(string), "abc").Map().ToList()); + 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 + 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 ---- @@ -174,10 +207,16 @@ public static void AValueThatDoesNotConvert_IsAValueMappingException_NamingTheCo [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"]); + 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]); + 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()); } @@ -210,33 +249,85 @@ public static void AnEmptyResult_IsEmpty_AndFirstOrDefaultIsDefault() 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.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)); + 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 ---- + // ---- 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())); + 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 columns = [("Id", typeof(int)), ("Title", typeof(string))]; + ImmutableArray columns = [ + ("Id", typeof(int)), + ("Title", typeof(string)) + ]; FakeExecuteReader one = Cmd(columns, [1, "a"]); // SingleOrDefault needs one row FakeExecuteReader many = Cmd(columns, [1, "a"], [2, null], [3, "c"]); @@ -245,37 +336,62 @@ public static void EveryTerminal_EqualsItsMapTwin() Parity(one); Parity(one); Parity(one); - Assert.Equal([new() { Id = 1, Title = "a" }, new() { Id = 2 }, new() { Id = 3, Title = "c" }], many.Map().ToList()); + Assert.Equal( + [ + new() { Id = 1, Title = "a" }, + new() { Id = 2 }, + new() { Id = 3, Title = "c" } + ], + many.Map().ToList()); } - // ---- 7. a row model maps by name; a type that cannot be mapped fails at the call ---- + // ---- 7. a row model maps by name; + // a type that cannot be mapped fails at the call ---- public interface IShapeless { } [Fact] public static void ARowModel_MapsByColumnName() { - FakeExecuteReader cmd = Cmd([("Title", typeof(string)), ("Id", typeof(int))], ["a", 1]); - Assert.Equal([new() { Id = 1, Title = "a" }], cmd.Map().ToList()); - Assert.Equal(new PostRow { Id = 1, Title = "a" }, cmd.Map().FirstOrDefault()); + FakeExecuteReader cmd = Cmd( + [("Title", typeof(string)), ("Id", typeof(int))], + ["a", 1]); + Assert.Equal( + [new() { Id = 1, Title = "a" }], + cmd.Map().ToList()); + Assert.Equal( + new PostRow { Id = 1, Title = "a" }, + cmd.Map().FirstOrDefault()); } [Fact] public static void AnUnmappableType_ThrowsAtTheCall_BeforeAnythingRuns() { FakeExecuteReader cmd = Cmd(typeof(int), 1); - Assert.Throws(() => cmd.Map().ToList()); - Assert.Throws(() => cmd.Map().ToArray()); - Assert.Throws(() => cmd.Map().ToImmutableArray()); - Assert.Throws(() => cmd.Map().FirstOrDefault()); - Assert.Throws(() => cmd.Map().SingleOrDefault()); - Assert.Throws(() => cmd.Map().ToHashSet()); - Assert.Throws(() => Drop(cmd.ToListAsync(Ct))); - Assert.Throws(() => Drop(cmd.ToArrayAsync(Ct))); - Assert.Throws(() => Drop(cmd.ToImmutableArrayAsync(Ct))); - Assert.Throws(() => Drop(cmd.FirstOrDefaultAsync(Ct))); - Assert.Throws(() => Drop(cmd.SingleOrDefaultAsync(Ct))); - Assert.Throws(() => Drop(cmd.ToHashSetAsync(cancellationToken: Ct))); + Assert.Throws( + () => cmd.Map().ToList()); + Assert.Throws( + () => cmd.Map().ToArray()); + Assert.Throws( + () => cmd.Map().ToImmutableArray()); + Assert.Throws( + () => cmd.Map().FirstOrDefault()); + Assert.Throws( + () => cmd.Map().SingleOrDefault()); + Assert.Throws( + () => cmd.Map().ToHashSet()); + Assert.Throws( + () => Drop(cmd.ToListAsync(Ct))); + Assert.Throws( + () => Drop(cmd.ToArrayAsync(Ct))); + Assert.Throws( + () => Drop(cmd.ToImmutableArrayAsync(Ct))); + Assert.Throws( + () => Drop(cmd.FirstOrDefaultAsync(Ct))); + Assert.Throws( + () => Drop(cmd.SingleOrDefaultAsync(Ct))); + Assert.Throws( + () => Drop(cmd.ToHashSetAsync(cancellationToken: Ct))); Assert.Equal(0, cmd.ExecuteCount); } @@ -283,10 +399,26 @@ public static void AnUnmappableType_ThrowsAtTheCall_BeforeAnythingRuns() public static void ANullCommand_IsAnArgumentNullException_ForEveryType() { IExecuteReader? none = null; - Assert.Equal("command", Assert.Throws(() => none!.Map().ToList()).ParamName); - Assert.Equal("command", Assert.Throws(() => Drop(none!.ToListAsync(Ct))).ParamName); - Assert.Equal("command", Assert.Throws(() => none!.Map()).ParamName); - Assert.Equal("command", Assert.Throws(() => none!.Map()).ParamName); + Assert.Equal( + "command", + Assert.Throws( + () => none!.Map().ToList()) + .ParamName); + Assert.Equal( + "command", + Assert.Throws( + () => Drop(none!.ToListAsync(Ct))) + .ParamName); + Assert.Equal( + "command", + Assert.Throws( + () => none!.Map()) + .ParamName); + Assert.Equal( + "command", + Assert.Throws( + () => none!.Map()) + .ParamName); } // ---- 8. Map() works; what takes a model still does not ---- @@ -298,7 +430,8 @@ public static void MapOfASingleValue_ReadsTheFirstColumn() Assert.Equal([1, 2], cmd.Map().ToList()); Assert.Equal([2], cmd.Map().Skip(1).Take(1).ToList()); Assert.Equal(1, cmd.Map().SingleResult().FirstOrDefault()); - Assert.Throws(() => cmd.Map(new KeyValuePair("Id", "Id"))); + Assert.Throws( + () => cmd.Map(new KeyValuePair("Id", "Id"))); } // ---- 9. read-through ---- @@ -306,7 +439,8 @@ public static void MapOfASingleValue_ReadsTheFirstColumn() [Fact] public static void AFailureInALaterResultSet_IsThrown_UnlessSingleResult() { - static FakeExecuteReader failing() => new(() => new MultiSetReader(Rows(2), 2, failAtCall: 2)); + static FakeExecuteReader failing() + => new(() => new MultiSetReader(Rows(2), 2, failAtCall: 2)); Assert.Throws(() => failing().Map().ToList()); Assert.Throws(() => failing().Map().ToList()); @@ -315,22 +449,39 @@ public static void AFailureInALaterResultSet_IsThrown_UnlessSingleResult() // ---- 10. tokens ---- - public static TheoryData AsyncTerminals => ["ToListAsync", "ToArrayAsync", "ToImmutableArrayAsync", "FirstOrDefaultAsync", "SingleOrDefaultAsync", "ToHashSetAsync"]; - - static Task RunAsync(FakeExecuteReader cmd, string terminal, CancellationToken token) => terminal switch - { - "ToListAsync" => cmd.ToListAsync(token).AsTask(), - "ToArrayAsync" => cmd.ToArrayAsync(token).AsTask(), - "ToImmutableArrayAsync" => cmd.ToImmutableArrayAsync(token).AsTask(), - "FirstOrDefaultAsync" => cmd.FirstOrDefaultAsync(token).AsTask(), - "SingleOrDefaultAsync" => cmd.SingleOrDefaultAsync(token).AsTask(), - "ToHashSetAsync" => cmd.ToHashSetAsync(cancellationToken: token).AsTask(), - _ => throw new ArgumentOutOfRangeException(nameof(terminal)), - }; + public static TheoryData AsyncTerminals + => [ + "ToListAsync", + "ToArrayAsync", + "ToImmutableArrayAsync", + "FirstOrDefaultAsync", + "SingleOrDefaultAsync", + "ToHashSetAsync" + ]; + + static Task RunAsync( + FakeExecuteReader cmd, + string terminal, + CancellationToken token) + => terminal switch + { + "ToListAsync" => cmd.ToListAsync(token).AsTask(), + "ToArrayAsync" => cmd.ToArrayAsync(token).AsTask(), + "ToImmutableArrayAsync" + => cmd.ToImmutableArrayAsync(token).AsTask(), + "FirstOrDefaultAsync" + => cmd.FirstOrDefaultAsync(token).AsTask(), + "SingleOrDefaultAsync" + => cmd.SingleOrDefaultAsync(token).AsTask(), + "ToHashSetAsync" + => cmd.ToHashSetAsync(cancellationToken: token).AsTask(), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }; #pragma warning disable xUnit1051 // This test passes no token on purpose: it checks what the reader is handed with none. [Theory, MemberData(nameof(AsyncTerminals))] - public static async Task EveryAsyncTerminal_GovernsTheCallByTheCallTokenAndTheCommandsTogether(string terminal) + public static async Task EveryAsyncTerminal_GovernsTheCallByTheCallTokenAndTheCommandsTogether( + string terminal) { // No token anywhere: the reader is handed none. FakeExecuteReader none = Cmd(typeof(int), 1); @@ -363,11 +514,13 @@ public static async Task EveryAsyncTerminal_GovernsTheCallByTheCallTokenAndTheCo canceled.Cancel(); FakeExecuteReader callCanceled = Cmd(typeof(int), 1); callCanceled.CancellationToken = own.Token; - await Assert.ThrowsAnyAsync(() => RunAsync(callCanceled, terminal, canceled.Token)); + await Assert.ThrowsAnyAsync( + () => RunAsync(callCanceled, terminal, canceled.Token)); FakeExecuteReader commandCanceled = Cmd(typeof(int), 1); commandCanceled.CancellationToken = canceled.Token; - await Assert.ThrowsAnyAsync(() => RunAsync(commandCanceled, terminal, call.Token)); + await Assert.ThrowsAnyAsync( + () => RunAsync(commandCanceled, terminal, call.Token)); } #pragma warning restore xUnit1051 @@ -393,11 +546,31 @@ static IDataReader Plain(Type column, params object[] values) [Fact] public static void APlainIDataReader_GivesTheSameAnswersAsADbDataReader() { - Assert.Equal([1, 2], new PlainReaderCommand(() => Plain(typeof(int), 1, 2)).Map().ToList()); - Assert.Equal([1, 2], new PlainReaderCommand(() => Plain(typeof(long), 1L, 2L)).Map().ToArray()); - Assert.Equal([1, 0], new PlainReaderCommand(() => Plain(typeof(int), 1, DBNull.Value)).Map().ToList()); - Assert.Equal([1, null], new PlainReaderCommand(() => Plain(typeof(int), 1, DBNull.Value)).Map().ToList()); - Assert.Equal(["1", "2"], new PlainReaderCommand(() => Plain(typeof(int), 1, 2)).Map().ToList()); + Assert.Equal( + [1, 2], + new PlainReaderCommand(() => Plain(typeof(int), 1, 2)) + .Map() + .ToList()); + Assert.Equal( + [1, 2], + new PlainReaderCommand(() => Plain(typeof(long), 1L, 2L)) + .Map() + .ToArray()); + Assert.Equal( + [1, 0], + new PlainReaderCommand(() => Plain(typeof(int), 1, DBNull.Value)) + .Map() + .ToList()); + Assert.Equal( + [1, null], + new PlainReaderCommand(() => Plain(typeof(int), 1, DBNull.Value)) + .Map() + .ToList()); + Assert.Equal( + ["1", "2"], + new PlainReaderCommand(() => Plain(typeof(int), 1, 2)) + .Map() + .ToList()); } // ---- 12. the reader is chosen per result set, from the column's type ---- @@ -412,21 +585,41 @@ public static void OneT_OverColumnsOfDifferentTypes_ReadsEachCorrectly() Assert.Equal([5], Cmd(typeof(long), 5L).Map().ToList()); } - // A provider such as SQLite reports the type of the current row's value: the getter chosen for the first row serves the rest of the result set. - sealed class ShiftingReader(Type first, object?[] cells) : TypedReader([("v", typeof(object))], [.. cells.Select(c => new object?[] { c })]) + // A provider such as SQLite reports the type of the current row's value: + // the getter chosen for the first row serves the rest of the result set. + sealed class ShiftingReader( + Type first, + object?[] cells) + : TypedReader( + [("v", typeof(object))], + [.. cells.Select(c => new object?[] { c })]) { int _reported; - public override Type GetFieldType(int ordinal) => _reported++ == 0 ? first : typeof(string); - public override long GetInt64(int ordinal) => Convert.ToInt64(GetValue(ordinal), System.Globalization.CultureInfo.InvariantCulture); + public override Type GetFieldType(int ordinal) + => _reported++ == 0 ? first : typeof(string); + public override long GetInt64(int ordinal) + => Convert.ToInt64( + GetValue(ordinal), + System.Globalization.CultureInfo.InvariantCulture); } [Fact] public static void ALaterRowOfAnotherType_IsReadWithTheFirstRowsGetter() { - // The provider coerces: the long getter serves a text row holding digits. - Assert.Equal([1, 22], new FakeExecuteReader(() => new ShiftingReader(typeof(long), [1L, "22"])).Map().ToList()); + // The provider coerces: + // the long getter serves a text row holding digits. + Assert.Equal( + [1, 22], + new FakeExecuteReader( + () => new ShiftingReader(typeof(long), [1L, "22"])) + .Map() + .ToList()); // The provider throws: a ValueMappingException, as for a property. - Assert.Throws(() => new FakeExecuteReader(() => new ShiftingReader(typeof(int), [1, "x"])).Map().ToList()); + Assert.Throws( + () => new FakeExecuteReader( + () => new ShiftingReader(typeof(int), [1, "x"])) + .Map() + .ToList()); } } diff --git a/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs b/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs index efcb66ee..5b45c142 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs @@ -113,7 +113,8 @@ public struct StructWithPrivateConstructor public static StructWithPrivateConstructor Make() => new(0); } - static string Single(string name) => $"'{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()."; + static string Single(string name) + => $"'{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()."; static string NotConcrete(string name, string kind) => $"'{name}' cannot be mapped: it is {kind}. Map to a class or struct, or use Map(selector)."; static string NoConstructor(string name) => $"'{name}' cannot be mapped: it has no public constructor."; static string Ambiguous(string name, int count, string signatures) => $"'{name}' cannot be mapped: it has {count} public constructors, {signatures}, and none without parameters. Make one of them non-public, or use Map(selector)."; @@ -151,7 +152,9 @@ public sealed class HoldsADictionary } // Every way to meet a type: the default map, a map constructed for it, and a query over it with no rows. Each one throws the same exception. - static void AssertUnmappable(string message, bool mapReadsTheFirstColumn = false) + static void AssertUnmappable( + string message, + bool mapReadsTheFirstColumn = false) { Assert.Equal(message, Assert.Throws(() => RowMap.Default).Message); Assert.Equal(message, Assert.Throws(() => RowMap.Default).Message); // Reading it again throws again: it is not a failed type initializer. @@ -161,7 +164,11 @@ static void AssertUnmappable(string message, bool mapReadsTheFirstColumn = fa var command = new FakeExecuteReader(["Id"]); if (mapReadsTheFirstColumn) _ = command.Map(); // a single value is read from the first column: Map() works, and runs nothing yet - else Assert.Equal(message, Assert.Throws(() => command.Map()).Message); + else Assert.Equal( + message, + Assert.Throws( + () => command.Map()) + .Message); Assert.Equal(0, command.ExecuteCount); // Nothing ran: the call failed before the query was executed. Assert.Equal(message, Assert.Throws(() => FakeReader.Create(["Id"]).Results()).Message); @@ -389,9 +396,12 @@ static Task Run(Action call) ["Map().ToDictionaryAsync()"] = async () => await Exec().Map().ToDictionaryAsync(_ => 0), ["ToListAsync()"] = async () => await Exec().ToListAsync(), ["ToArrayAsync()"] = async () => await Exec().ToArrayAsync(), - ["ToImmutableArrayAsync()"] = async () => await Exec().ToImmutableArrayAsync(), - ["FirstOrDefaultAsync()"] = async () => await Exec().FirstOrDefaultAsync(), - ["SingleOrDefaultAsync()"] = async () => await Exec().SingleOrDefaultAsync(), + ["ToImmutableArrayAsync()"] + = async () => await Exec().ToImmutableArrayAsync(), + ["FirstOrDefaultAsync()"] + = async () => await Exec().FirstOrDefaultAsync(), + ["SingleOrDefaultAsync()"] + = async () => await Exec().SingleOrDefaultAsync(), ["ToHashSetAsync()"] = async () => await Exec().ToHashSetAsync(), ["Map(pair)"] = () => Run(() => Exec().Map(new KeyValuePair("Id", "Id")).ToList()), ["Map(pairs)"] = () => Run(() => Exec().Map(new KeyValuePair("Id", "Id"), new("Name", "Name")).ToList()), diff --git a/docsite/samples/ExpressiveCommands.cs b/docsite/samples/ExpressiveCommands.cs index 99dfb5ff..e630467b 100644 --- a/docsite/samples/ExpressiveCommands.cs +++ b/docsite/samples/ExpressiveCommands.cs @@ -36,10 +36,14 @@ public static Post[] ConnectionForm(SqliteConnection connection) return posts; } - public static async Task ColumnOfIds(IDbConnectionFactory connectionFactory, CancellationToken cancellationToken) + public static async Task ColumnOfIds( + IDbConnectionFactory connectionFactory, + CancellationToken cancellationToken) { #region single_value_async - // The terminals take the type: ToArrayAsync(token) reads the first column of each row, as Map().ToArrayAsync(token) does. + // The terminals take the type: + // ToArrayAsync(token) reads the first column of each row, + // as Map().ToArrayAsync(token) does. int[] ids = await connectionFactory .Command("select id from posts order by id") .ToArrayAsync(cancellationToken); diff --git a/docsite/samples/Mapping.cs b/docsite/samples/Mapping.cs index 3c092e1c..b00814dd 100644 --- a/docsite/samples/Mapping.cs +++ b/docsite/samples/Mapping.cs @@ -290,8 +290,13 @@ public static string[] MapSelector(SqliteConnection connection) public static int[] MapSingleValue(SqliteConnection connection) { #region map_single_value - // One column of every row as an int. A SQLite integer is a long; it converts to int with a range check, as a property would. - int[] ids = connection.Command("select id from posts order by id").Map().ToArray(); + // One column of every row as an int. + // A SQLite integer is a long; it converts to int with a range check, + // as a property would. + int[] ids = connection + .Command("select id from posts order by id") + .Map() + .ToArray(); #endregion return ids; diff --git a/docsite/samples/Program.cs b/docsite/samples/Program.cs index 5a3df977..f1877058 100644 --- a/docsite/samples/Program.cs +++ b/docsite/samples/Program.cs @@ -103,8 +103,18 @@ void Check(bool condition, string what) Check(first?.Id == 1 && single?.Id == 2, "first and single"); Check(Throws(() => connection.Command("select * from posts").Map().SingleOrDefault()), "SingleOrDefault throws for more than one row"); Check(Mapping.MapSelector(connection)[0] == "Post 1 (10 views)", "Map(selector)"); -Check(Mapping.MapSingleValue(connection).SequenceEqual(connection.Command("select id from posts order by id").Map(static r => (int)r.GetInt64(0)).ToList()) && Mapping.MapSingleValue(connection).Length == 5, "Map() reads the first column, a SQLite long converted to int"); -Check((await ExpressiveCommands.ColumnOfIds(factory, CancellationToken.None)).SequenceEqual([1, 2, 3, 4, 5]), "ToArrayAsync(token) on a command"); +Check( + Mapping.MapSingleValue(connection).SequenceEqual( + connection + .Command("select id from posts order by id") + .Map(static r => (int)r.GetInt64(0)) + .ToList()) + && Mapping.MapSingleValue(connection).Length == 5, + "Map() reads the first column, a SQLite long converted to int"); +Check( + (await ExpressiveCommands.ColumnOfIds(factory, CancellationToken.None)) + .SequenceEqual([1, 2, 3, 4, 5]), + "ToArrayAsync(token) on a command"); Check(Mapping.StreamRows(connection) == 15, "Read streams rows inside the handler"); Check((await Mapping.ByIdAsync(connection, CancellationToken.None)).Count == 5, "ToDictionaryAsync"); From 90fe0352ec7692ee65fe37e787e93a66c81afb00 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:42:23 -0800 Subject: [PATCH 5/6] Braces on the multi-line if and else bodies the vertical style created (IDE0011) Co-Authored-By: Claude Sonnet 5.5 --- Source/Core/Core/RowMap.cs | 2 ++ .../UnmappableTypeTests.cs | 13 ++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Source/Core/Core/RowMap.cs b/Source/Core/Core/RowMap.cs index 5cebea8d..e28aef44 100644 --- a/Source/Core/Core/RowMap.cs +++ b/Source/Core/Core/RowMap.cs @@ -673,10 +673,12 @@ internal static Func FirstColumnReader(Type columnType) isDbNull, getValue, cell) is { } isNull) + { value = Expression.Condition( isNull, Expression.Default(typeof(T)), value); + } Expression guarded = Expression.TryCatch( value, diff --git a/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs b/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs index 5b45c142..a1a501d4 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs @@ -164,11 +164,14 @@ static void AssertUnmappable( var command = new FakeExecuteReader(["Id"]); if (mapReadsTheFirstColumn) _ = command.Map(); // a single value is read from the first column: Map() works, and runs nothing yet - else Assert.Equal( - message, - Assert.Throws( - () => command.Map()) - .Message); + else + { + Assert.Equal( + message, + Assert.Throws( + () => command.Map()) + .Message); + } Assert.Equal(0, command.ExecuteCount); // Nothing ran: the call failed before the query was executed. Assert.Equal(message, Assert.Throws(() => FakeReader.Create(["Id"]).Results()).Message); From 023e67226490addfee7b06d372e91564351e93de Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:50:52 -0800 Subject: [PATCH 6/6] Braces on the if that pairs with the braced else (IDE0011) Co-Authored-By: Claude Sonnet 5.5 --- .../UnmappableTypeTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs b/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs index a1a501d4..9948c720 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/UnmappableTypeTests.cs @@ -163,7 +163,10 @@ static void AssertUnmappable( Assert.Equal(message, Assert.Throws(() => RowMap.From([])).Message); var command = new FakeExecuteReader(["Id"]); - if (mapReadsTheFirstColumn) _ = command.Map(); // a single value is read from the first column: Map() works, and runs nothing yet + if (mapReadsTheFirstColumn) + { + _ = command.Map(); // a single value is read from the first column: Map() works, and runs nothing yet + } else { Assert.Equal(