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 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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( From 282474e002f2beef17f57e82dcf6294e0b4ebac3 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:29:12 -0800 Subject: [PATCH 07/15] reader.Map() and command.Map(): mapped views over a caller's reader or command A null map, an empty pair list and a default struct behave as documented. Co-Authored-By: Claude Sonnet 5.5 --- Source/Core/MappedQuery.cs | 48 +- Source/Core/MappedViews.cs | 672 ++++++++++++++++++++++++++++ Source/Core/PublicAPI.Unshipped.txt | 57 +++ 3 files changed, 760 insertions(+), 17 deletions(-) create mode 100644 Source/Core/MappedViews.cs diff --git a/Source/Core/MappedQuery.cs b/Source/Core/MappedQuery.cs index af396ae2..c2b6b776 100644 --- a/Source/Core/MappedQuery.cs +++ b/Source/Core/MappedQuery.cs @@ -29,6 +29,8 @@ public static partial class CoreExtensions /// /// /// Pass a to read columns whose names differ from the properties; the overload without a map reads by column name. + /// Columns match a property exactly, else ignoring case, else ignoring case and underscores (author_id feeds AuthorId); + /// the resolved layout is cached per result-set layout. /// Declare the once and pass it: a map built in the call (a new RowMap<T>(...) or a collection expression in the argument) is rebuilt, and its row reader compiled, on every call /// (about 29 KB and a reader compile for a one-row read of a 12-property model, against 464 B through a declared map; see /// Columns named differently). @@ -42,20 +44,24 @@ public static partial class CoreExtensions /// /// The model type to map the values to. /// The command to read from. - /// The prebuilt to read with. + /// The prebuilt to read with; reads as Map<T>() does (before 11.0.0-rc.4 it threw). /// A deferred query whose results are mapped to . - /// or is . + /// is . /// [OverloadResolutionPriority(1)] public static MappedQuery Map( this IExecuteReader command, - RowMap map) + RowMap? map) { if (command is null) throw new ArgumentNullException(nameof(command)); - if (map is null) throw new ArgumentNullException(nameof(map)); Contract.EndContractBlock(); - return new MappedQuery(new MappedQueryCore(command, map, 0, -1, false)); + // No map reads exactly as Map() does: + // a single value from the first column, a model by name. + return map is null + ? Map(command) + : new MappedQuery( + new MappedQueryCore(command, map, 0, -1, false)); } /// @@ -65,7 +71,9 @@ public static MappedQuery Map( public static MappedQuery Map( this IExecuteReader command, params ReadOnlySpan> mappings) - => Map(command, RowMap.From(mappings)); + => mappings.IsEmpty + ? Map(command) + : Map(command, RowMap.From(mappings)); /// /// The command to read from. @@ -132,36 +140,42 @@ readonly struct MappedQueryCore internal MappedQueryCore(IExecuteReader command, IRowSource source, int skip, int take, bool singleResult) { _command = command; _source = source; _skip = skip; _take = take; _singleResult = singleResult; } + // A default struct has no command: any use of it says so, + // the same way for every type of the family. + IExecuteReader Command + => _command ?? throw new InvalidOperationException( + "This query was not created by Map(); a default value cannot be used."); + // Without .SingleResult() the reader is asked for every result set, because a terminal reads through the later ones to report their failures. CommandBehavior Behavior => _singleResult ? (_source is SelectorSource ? CommandBehavior.SingleResult : GeneratedMapping) : (_source is SelectorSource ? CommandBehavior.Default : CommandBehavior.SequentialAccess); - internal MappedQueryCore WithSingleResult() => new(_command, _source, _skip, _take, true); + internal MappedQueryCore WithSingleResult() => new(Command, _source, _skip, _take, true); internal MappedQueryCore WithSkip(int count) - => count >= 0 ? new(_command, _source, _skip + count, _take, _singleResult) + => count >= 0 ? new(Command, _source, _skip + count, _take, _singleResult) : throw new ArgumentOutOfRangeException(nameof(count), count, "Cannot be negative."); // Take is set exactly once (the staging structs only expose it before any take exists), so this // just records the limit; it applies after the skip, i.e. Enumerable.Skip(a).Take(b). internal MappedQueryCore WithTake(int count) - => count >= 0 ? new(_command, _source, _skip, count, _singleResult) + => count >= 0 ? new(Command, _source, _skip, count, _singleResult) : throw new ArgumentOutOfRangeException(nameof(count), count, "Cannot be negative."); // Honors the command's UseAsyncRead choice (default false: rows are pulled with Read() after an async execute). - bool UseAsyncRead => _command is IExecuteReaderAsync { UseAsyncRead: true }; + bool UseAsyncRead => Command is IExecuteReaderAsync { UseAsyncRead: true }; // One token governs a whole call: the one passed to the terminal together with the command's own (either cancels), for the connection open, the name lookup, the execute and the reads. // No token, one token, or the same token twice needs no source, so those calls allocate nothing for it. Two different tokens need a linked source, which the call disposes when it ends. CancellationToken Combine(CancellationToken cancellationToken, out CancellationTokenSource? linked) - => CancellationTokens.Combine(_command.CancellationToken, cancellationToken, out linked); + => CancellationTokens.Combine(Command.CancellationToken, cancellationToken, out linked); // `token` is the combined token; it is also in `state`, which the terminal's static lambda reads it from. ValueTask ExecuteAsync(TState state, Func> transform, CommandBehavior behavior, CancellationTokenSource? linked, CancellationToken token) => linked is null - ? _command.ExecuteReaderAsync(state, transform, behavior, token) - : ExecuteLinkedAsync(_command, state, transform, behavior, linked, token); + ? Command.ExecuteReaderAsync(state, transform, behavior, token) + : ExecuteLinkedAsync(Command, state, transform, behavior, linked, token); static async ValueTask ExecuteLinkedAsync(IExecuteReader command, TState state, Func> transform, CommandBehavior behavior, CancellationTokenSource linked, CancellationToken token) { @@ -173,8 +187,8 @@ static async ValueTask ExecuteLinkedAsync(IExecuteRead // Nothing is read through when the transform throws, so the caller's own exception is never replaced. TResult Run(TState state, Func transform, CommandBehavior behavior) => _singleResult - ? _command.ExecuteReader(state, transform, behavior) - : _command.ExecuteReader((state, transform), static (reader, s) => + ? Command.ExecuteReader(state, transform, behavior) + : Command.ExecuteReader((state, transform), static (reader, s) => { TResult result = s.transform(reader, s.state); RowReader.ReadThrough(reader); @@ -197,14 +211,14 @@ ValueTask RunAsync(TState state, Func> handler) - => _command.ExecuteReader((handler, _source, _skip, _take, _singleResult), static (reader, s) => + => Command.ExecuteReader((handler, _source, _skip, _take, _singleResult), static (reader, s) => { s.handler(RowReader.Stream(s._source, reader, s._skip, s._take, !s._singleResult)); return false; }, Behavior); internal TResult Read(Func, TResult> handler) - => _command.ExecuteReader((handler, _source, _skip, _take, _singleResult), static (reader, s) + => Command.ExecuteReader((handler, _source, _skip, _take, _singleResult), static (reader, s) => s.handler(RowReader.Stream(s._source, reader, s._skip, s._take, !s._singleResult)), Behavior); internal ValueTask ReadAsync(Func, CancellationToken, ValueTask> handler, CancellationToken cancellationToken) diff --git a/Source/Core/MappedViews.cs b/Source/Core/MappedViews.cs new file mode 100644 index 00000000..1c819d4c --- /dev/null +++ b/Source/Core/MappedViews.cs @@ -0,0 +1,672 @@ +using System.Diagnostics; + +namespace Open.Database.Extensions; + +public static partial class CoreExtensions +{ + /// + /// Locks in a mapping to model type over a data reader the caller owns and returns a deferred . + /// Nothing runs until a terminal (ToList, FirstOrDefault, and so on) is invoked. + /// + /// + /// + /// The reader belongs to the caller: it is never opened, closed or disposed, and is never called on it. + /// Rows are read from the current position of the current result set, and the rows read stay read. + /// + /// + /// How is mapped is the same as for + /// : + /// a reads columns whose names differ from the properties, + /// and the overload without one reads by column name + /// (exactly, else ignoring case, else ignoring case and underscores, so author_id feeds AuthorId). + /// A single value such as is read from the first column. + /// + /// + /// The model type to map the values to. + /// The reader to read from, positioned on the result set to map. + /// The prebuilt to read with; reads as Map<T>() does (before 11.0.0-rc.4 it threw). + /// A deferred query whose results are mapped to . + /// is . + /// + [OverloadResolutionPriority(1)] + public static MappedReader Map( + this IDataReader reader, + RowMap? map) + { + if (reader is null) throw new ArgumentNullException(nameof(reader)); + Contract.EndContractBlock(); + + // No map reads exactly as Map() does: + // a single value from the first column, a model by name. + return map is null + ? Map(reader) + : new MappedReader(Views.ReaderCore(reader, map)); + } + + /// + /// The reader to read from, positioned on the result set to map. + /// The (property name, column name) pairs of a new , built on every call; none reads by column name. + [OverloadResolutionPriority(2)] + public static MappedReader Map( + this IDataReader reader, + params ReadOnlySpan> mappings) + => mappings.IsEmpty + ? Map(reader) + : Map(reader, RowMap.From(mappings)); + + /// + /// The reader to read from, positioned on the result set to map. + /// The one (property name, column name) pair of a new , built on every call. + [OverloadResolutionPriority(3)] + public static MappedReader Map( + this IDataReader reader, + KeyValuePair mapping) + => Map(reader, new RowMap(mapping)); + + /// + [OverloadResolutionPriority(4)] + public static MappedReader Map( + this IDataReader reader) + { + if (reader is null) throw new ArgumentNullException(nameof(reader)); + 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 MappedReader(Views.ReaderCore(reader, firstColumn)) + : Map(reader, RowMap.Default); + } + + /// + /// Locks in a custom projection over a data reader the caller owns and returns a deferred . + /// Nothing runs until a terminal is invoked; + /// is applied per row as the reader streams. + /// + /// + /// + /// The reader belongs to the caller: it is never opened, closed or disposed, and is never called on it. + /// Rows are read from the current position of the current result set, and the rows read stay read. + /// + /// + /// The type each record is projected to. + /// The reader to read from, positioned on the result set to map. + /// The transform applied to each . + /// A deferred query whose results are produced by . + /// or is . + [OverloadResolutionPriority(-1)] + public static MappedReader Map( + this IDataReader reader, + Func selector) + { + if (reader is null) throw new ArgumentNullException(nameof(reader)); + if (selector is null) throw new ArgumentNullException(nameof(selector)); + Contract.EndContractBlock(); + + return new MappedReader( + Views.ReaderCore(reader, new SelectorSource(selector))); + } + + /// + /// Locks in a mapping to model type over a command and returns a deferred . + /// Nothing runs until a terminal (ToList, FirstOrDefault, and so on) is invoked. + /// + /// + /// + /// The command belongs to the caller and is never disposed. + /// The connection is opened only if it is closed, and a connection this call opened is closed (not disposed) when it ends; one that was already open is left open. + /// Like an expressive command, + /// a terminal reads through the later result sets after the first, so a failure in a later statement is thrown; + /// .SingleResult() turns that off. + /// + /// + /// How is mapped is the same as for + /// : + /// a reads columns whose names differ from the properties, + /// and the overload without one reads by column name + /// (exactly, else ignoring case, else ignoring case and underscores, so author_id feeds AuthorId). + /// A single value such as is read from the first column. + /// + /// + /// The model type to map the values to. + /// The command to execute. + /// The prebuilt to read with; reads as Map<T>() does (before 11.0.0-rc.4 it threw). + /// A deferred query whose results are mapped to . + /// is . + /// + [OverloadResolutionPriority(1)] + public static MappedCommand Map( + this IDbCommand command, + RowMap? map) + { + if (command is null) throw new ArgumentNullException(nameof(command)); + Contract.EndContractBlock(); + + // No map reads exactly as Map() does: + // a single value from the first column, a model by name. + return map is null + ? Map(command) + : new MappedCommand(Views.CommandCore(command, map)); + } + + /// + /// The command to execute. + /// The (property name, column name) pairs of a new , built on every call; none reads by column name. + [OverloadResolutionPriority(2)] + public static MappedCommand Map( + this IDbCommand command, + params ReadOnlySpan> mappings) + => mappings.IsEmpty + ? Map(command) + : Map(command, RowMap.From(mappings)); + + /// + /// The command to execute. + /// The one (property name, column name) pair of a new , built on every call. + [OverloadResolutionPriority(3)] + public static MappedCommand Map( + this IDbCommand command, + KeyValuePair mapping) + => Map(command, new RowMap(mapping)); + + /// + [OverloadResolutionPriority(4)] + public static MappedCommand Map( + this IDbCommand command) + { + 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 MappedCommand(Views.CommandCore(command, firstColumn)) + : Map(command, RowMap.Default); + } + + /// + /// Locks in a custom projection over a command and returns a deferred . + /// Nothing runs until a terminal is invoked; + /// is applied per row as the reader streams. + /// + /// + /// + /// The command belongs to the caller and is never disposed. + /// The connection is opened only if it is closed, and a connection this call opened is closed (not disposed) when it ends; one that was already open is left open. + /// Like an expressive command, + /// a terminal reads through the later result sets after the first, so a failure in a later statement is thrown; + /// .SingleResult() turns that off. + /// + /// + /// The type each record is projected to. + /// The command to execute. + /// The transform applied to each . + /// A deferred query whose results are produced by . + /// or is . + [OverloadResolutionPriority(-1)] + public static MappedCommand Map( + this IDbCommand command, + Func selector) + { + if (command is null) throw new ArgumentNullException(nameof(command)); + if (selector is null) throw new ArgumentNullException(nameof(selector)); + Contract.EndContractBlock(); + + return new MappedCommand( + Views.CommandCore(command, new SelectorSource(selector))); + } +} + +// Builds the core a view runs over. +// A caller's reader is read from where it stands and is never read through, +// so it takes the single-result path of the core, +// which never calls NextResult. +// A command takes the same path as an expressive command. +static class Views +{ + internal static MappedQueryCore ReaderCore( + IDataReader reader, + IRowSource source) + => new(new ReaderExecutor(reader), source, 0, -1, true); + + internal static MappedQueryCore CommandCore( + IDbCommand command, + IRowSource source) + => new(new CommandExecutor(command), source, 0, -1, false); +} + +// The two executors let MappedQueryCore run over a reader or a raw command. +// The core only uses the two state forms; the rest are derived from them. +abstract class ViewExecutor : IExecuteReader +{ + public CancellationToken CancellationToken => default; + + public abstract TResult ExecuteReader( + TState state, + Func transform, + CommandBehavior behavior = CommandBehavior.Default); + + public abstract ValueTask ExecuteReaderAsync( + TState state, + Func> transform, + CommandBehavior behavior, + CancellationToken cancellationToken); + + public void ExecuteReader( + Action handler, + CommandBehavior behavior = CommandBehavior.Default) + { + Debug.Assert(handler is not null); + ExecuteReader(handler, static (reader, h) => + { + h(reader); + return false; + }, behavior); + } + + public T ExecuteReader( + Func transform, + CommandBehavior behavior = CommandBehavior.Default) + { + Debug.Assert(transform is not null); + return ExecuteReader( + transform, + static (reader, f) => f(reader), + behavior); + } + + public ValueTask ExecuteReaderAsync( + Func handler, + CommandBehavior behavior = CommandBehavior.Default) + { + Debug.Assert(handler is not null); + return ValueTaskHelpers.Discard(ExecuteReaderAsync( + handler, + static async (reader, h) => + { + await h(reader).ConfigureAwait(false); + return false; + }, + behavior)); + } + + public ValueTask ExecuteReaderAsync( + Func> transform, + CommandBehavior behavior = CommandBehavior.Default) + { + Debug.Assert(transform is not null); + return ExecuteReaderAsync( + transform, + static (reader, f) => f(reader), + behavior); + } + + public ValueTask ExecuteReaderAsync( + TState state, + Func> transform, + CommandBehavior behavior = CommandBehavior.Default) + => ExecuteReaderAsync(state, transform, behavior, default); +} + +// The caller's reader: handed to the transform as it is. +// It is never opened, closed or disposed here, +// and NextResult is never called on it. +sealed class ReaderExecutor(IDataReader reader) : ViewExecutor +{ + public override TResult ExecuteReader( + TState state, + Func transform, + CommandBehavior behavior = CommandBehavior.Default) + => transform(reader, state); + + public override ValueTask ExecuteReaderAsync( + TState state, + Func> transform, + CommandBehavior behavior, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return transform(reader, state); + } +} + +// The caller's command, run as an expressive command runs: +// the connection is opened only if it is closed +// and closed again (not disposed) only if it was closed when the call began. +// The command is never disposed. +sealed class CommandExecutor(IDbCommand command) : ViewExecutor +{ + public override TResult ExecuteReader( + TState state, + Func transform, + CommandBehavior behavior = CommandBehavior.Default) + { + IDbConnection? connection = command.Connection; + ConnectionState before + = connection?.State ?? ConnectionState.Closed; + try + { + connection?.EnsureOpen(); + using IDataReader reader = command.ExecuteReader( + before == ConnectionState.Closed + ? behavior | CommandBehavior.CloseConnection + : behavior); + return transform(reader, state); + } + finally + { + if (before == ConnectionState.Closed) connection?.Close(); + } + } + + public override async ValueTask ExecuteReaderAsync( + TState state, + Func> transform, + CommandBehavior behavior, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + IDbConnection? connection = command.Connection; + ConnectionState before + = connection?.State ?? ConnectionState.Closed; + try + { + // An open connection (the usual case) needs no awaiting at all. + if (connection is not null + && (before & (ConnectionState.Open | ConnectionState.Broken)) + != ConnectionState.Open) + await connection.EnsureOpenAsync(cancellationToken).ConfigureAwait(false); + + CommandBehavior b = before == ConnectionState.Closed + ? behavior | CommandBehavior.CloseConnection + : behavior; + + if (command is DbCommand dbCommand) + { +#if NETSTANDARD2_0 +#else + await +#endif + using DbDataReader dbReader = await dbCommand.ExecuteReaderAsync(b, cancellationToken).ConfigureAwait(false); + return await transform(dbReader, state).ConfigureAwait(false); + } + + using IDataReader reader = command.ExecuteReader(b); + return await transform(reader, state).ConfigureAwait(false); + } + finally + { + if (before == ConnectionState.Closed) connection?.Close(); + } + } +} + +/// +/// A deferred, type-locked read over a data reader the caller owns. +/// Terminals are methods here, with more as extensions on . +/// +/// The type each record is mapped to. +/// +/// +/// The reader belongs to the caller. +/// A terminal never opens, closes or disposes it, +/// and never calls on it: +/// it reads the rows of the current result set from the reader's current position and stops, +/// leaving the reader open and positioned after that set's last row. +/// A caller who wants the next result set calls NextResult and maps again. +/// +/// +/// Skip and Take return the types of ; +/// the SingleResult they carry changes nothing here, since a reader view never reads through later result sets. +/// +/// +/// +/// +public readonly record struct MappedReader : IMappedQuery +{ + readonly MappedQueryCore _core; + + internal MappedReader(MappedQueryCore core) + => _core = core; + + /// + public MappedQueryWithSkip Skip(int count) + => new(_core.WithSkip(count)); + + /// + public MappedQueryWithTake Take(int count) + => new(_core.WithTake(count)); + /// + public void Read(Action> handler) + => _core.Read(handler); + + /// + public TResult Read( + Func, TResult> handler) + => _core.Read(handler); + + /// + public ValueTask ReadAsync( + Func, CancellationToken, ValueTask> handler, + CancellationToken cancellationToken) + => _core.ReadAsync(handler, cancellationToken); + + /// + public ValueTask ReadAsync( + Func< + IAsyncEnumerable, + CancellationToken, + ValueTask> handler, + CancellationToken cancellationToken) + => _core.ReadAsync(handler, cancellationToken); + + /// + public List ToList() + => _core.ToList(); + + /// + public ValueTask> ToListAsync( + CancellationToken cancellationToken = default) + => _core.ToListAsync(cancellationToken); + + /// + public T[] ToArray() + => _core.ToArray(); + + /// + public ValueTask ToArrayAsync( + CancellationToken cancellationToken = default) + => _core.ToArrayAsync(cancellationToken); + + /// + public T? FirstOrDefault() + => _core.FirstOrDefault(); + + /// + public ValueTask FirstOrDefaultAsync( + CancellationToken cancellationToken = default) + => _core.FirstOrDefaultAsync(cancellationToken); + + /// + public T? SingleOrDefault() + => _core.SingleOrDefault(); + + /// + public ValueTask SingleOrDefaultAsync( + CancellationToken cancellationToken = default) + => _core.SingleOrDefaultAsync(cancellationToken); + + /// + public ImmutableArray ToImmutableArray() + => _core.ToImmutableArray(); + + /// + public ValueTask> ToImmutableArrayAsync( + CancellationToken cancellationToken = default) + => _core.ToImmutableArrayAsync(cancellationToken); + + /// + public Dictionary ToDictionary( + Func keySelector, + IEqualityComparer? comparer = null) + where TKey : notnull + => _core.ToDictionary(keySelector, comparer); + + /// + public ValueTask> ToDictionaryAsync( + Func keySelector, + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + where TKey : notnull + => _core.ToDictionaryAsync(keySelector, comparer, cancellationToken); + + /// + public HashSet ToHashSet( + IEqualityComparer? comparer = null) + => _core.ToHashSet(comparer); + + /// + public ValueTask> ToHashSetAsync( + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + => _core.ToHashSetAsync(comparer, cancellationToken); +} + +/// +/// A deferred, type-locked query over a raw . +/// Terminals are methods here, with more as extensions on . +/// +/// The type each record is mapped to. +/// +/// +/// The command belongs to the caller and is never disposed. +/// Each terminal executes it again. +/// The connection is opened only if it is closed, +/// and a connection the terminal opened is closed (Close, not Dispose) when it ends; +/// one that was already open is left open. +/// The asynchronous terminals open and execute asynchronously when the command is a . +/// +/// +/// A terminal returns the rows of the first result set only, +/// then reads through the later sets without reading their rows, +/// so a failure in a later statement is thrown as the provider raised it; +/// turns that off. +/// See for what a read-through does and does not do. +/// +/// +/// +/// +public readonly record struct MappedCommand : IMappedQuery +{ + readonly MappedQueryCore _core; + + internal MappedCommand(MappedQueryCore core) + => _core = core; + + /// + public MappedQueryWithSkip Skip(int count) + => new(_core.WithSkip(count)); + + /// + public MappedQueryWithTake Take(int count) + => new(_core.WithTake(count)); + + /// + public MappedCommand SingleResult() + => new(_core.WithSingleResult()); + /// + public void Read(Action> handler) + => _core.Read(handler); + + /// + public TResult Read( + Func, TResult> handler) + => _core.Read(handler); + + /// + public ValueTask ReadAsync( + Func, CancellationToken, ValueTask> handler, + CancellationToken cancellationToken) + => _core.ReadAsync(handler, cancellationToken); + + /// + public ValueTask ReadAsync( + Func< + IAsyncEnumerable, + CancellationToken, + ValueTask> handler, + CancellationToken cancellationToken) + => _core.ReadAsync(handler, cancellationToken); + + /// + public List ToList() + => _core.ToList(); + + /// + public ValueTask> ToListAsync( + CancellationToken cancellationToken = default) + => _core.ToListAsync(cancellationToken); + + /// + public T[] ToArray() + => _core.ToArray(); + + /// + public ValueTask ToArrayAsync( + CancellationToken cancellationToken = default) + => _core.ToArrayAsync(cancellationToken); + + /// + public T? FirstOrDefault() + => _core.FirstOrDefault(); + + /// + public ValueTask FirstOrDefaultAsync( + CancellationToken cancellationToken = default) + => _core.FirstOrDefaultAsync(cancellationToken); + + /// + public T? SingleOrDefault() + => _core.SingleOrDefault(); + + /// + public ValueTask SingleOrDefaultAsync( + CancellationToken cancellationToken = default) + => _core.SingleOrDefaultAsync(cancellationToken); + + /// + public ImmutableArray ToImmutableArray() + => _core.ToImmutableArray(); + + /// + public ValueTask> ToImmutableArrayAsync( + CancellationToken cancellationToken = default) + => _core.ToImmutableArrayAsync(cancellationToken); + + /// + public Dictionary ToDictionary( + Func keySelector, + IEqualityComparer? comparer = null) + where TKey : notnull + => _core.ToDictionary(keySelector, comparer); + + /// + public ValueTask> ToDictionaryAsync( + Func keySelector, + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + where TKey : notnull + => _core.ToDictionaryAsync(keySelector, comparer, cancellationToken); + + /// + public HashSet ToHashSet( + IEqualityComparer? comparer = null) + => _core.ToHashSet(comparer); + + /// + public ValueTask> ToHashSetAsync( + IEqualityComparer? comparer = null, + CancellationToken cancellationToken = default) + => _core.ToHashSetAsync(comparer, cancellationToken); +} diff --git a/Source/Core/PublicAPI.Unshipped.txt b/Source/Core/PublicAPI.Unshipped.txt index df7d0d65..889eee18 100644 --- a/Source/Core/PublicAPI.Unshipped.txt +++ b/Source/Core/PublicAPI.Unshipped.txt @@ -71,3 +71,60 @@ static Open.Database.Extensions.Core.QueryResultExtensions.DequeueAs(this Sys static Open.Database.Extensions.Core.QueryResultExtensions.DequeueAsMappedDictionaries(this Open.Database.Extensions.Core.QueryResult!>! source) -> System.Collections.Generic.IEnumerable!>! static Open.Database.Extensions.Core.QueryResultExtensions.DequeueAsMappedDictionaries(this System.Threading.Tasks.Task!>!>! source) -> System.Threading.Tasks.ValueTask!>!> static Open.Database.Extensions.Core.QueryResultExtensions.DequeueAsMappedDictionaries(this System.Threading.Tasks.ValueTask!>!> source) -> System.Threading.Tasks.ValueTask!>!> +*REMOVED*static Open.Database.Extensions.CoreExtensions.Map(this Open.Database.Extensions.IExecuteReader! command, Open.Database.Extensions.RowMap! map) -> Open.Database.Extensions.MappedQuery +static Open.Database.Extensions.CoreExtensions.Map(this Open.Database.Extensions.IExecuteReader! command, Open.Database.Extensions.RowMap? map) -> Open.Database.Extensions.MappedQuery +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDataReader! reader) -> Open.Database.Extensions.MappedReader +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDataReader! reader, Open.Database.Extensions.RowMap? map) -> Open.Database.Extensions.MappedReader +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDataReader! reader, System.Collections.Generic.KeyValuePair mapping) -> Open.Database.Extensions.MappedReader +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDataReader! reader, System.Func! selector) -> Open.Database.Extensions.MappedReader +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDataReader! reader, params System.ReadOnlySpan> mappings) -> Open.Database.Extensions.MappedReader +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbCommand! command) -> Open.Database.Extensions.MappedCommand +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbCommand! command, Open.Database.Extensions.RowMap? map) -> Open.Database.Extensions.MappedCommand +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbCommand! command, System.Collections.Generic.KeyValuePair mapping) -> Open.Database.Extensions.MappedCommand +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbCommand! command, System.Func! selector) -> Open.Database.Extensions.MappedCommand +static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbCommand! command, params System.ReadOnlySpan> mappings) -> Open.Database.Extensions.MappedCommand +Open.Database.Extensions.MappedReader +Open.Database.Extensions.MappedReader.FirstOrDefault() -> T? +Open.Database.Extensions.MappedReader.FirstOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedReader.MappedQuery() -> void +Open.Database.Extensions.MappedReader.Read(System.Action!>! handler) -> void +Open.Database.Extensions.MappedReader.Read(System.Func!, TResult>! handler) -> TResult +Open.Database.Extensions.MappedReader.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedReader.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedReader.SingleOrDefault() -> T? +Open.Database.Extensions.MappedReader.SingleOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedReader.Skip(int count) -> Open.Database.Extensions.MappedQueryWithSkip +Open.Database.Extensions.MappedReader.Take(int count) -> Open.Database.Extensions.MappedQueryWithTake +Open.Database.Extensions.MappedReader.ToArray() -> T[]! +Open.Database.Extensions.MappedReader.ToArrayAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedReader.ToDictionary(System.Func! keySelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary! +Open.Database.Extensions.MappedReader.ToDictionaryAsync(System.Func! keySelector, System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedReader.ToHashSet(System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.HashSet! +Open.Database.Extensions.MappedReader.ToHashSetAsync(System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedReader.ToImmutableArray() -> System.Collections.Immutable.ImmutableArray +Open.Database.Extensions.MappedReader.ToImmutableArrayAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> +Open.Database.Extensions.MappedReader.ToList() -> System.Collections.Generic.List! +Open.Database.Extensions.MappedReader.ToListAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedCommand +Open.Database.Extensions.MappedCommand.FirstOrDefault() -> T? +Open.Database.Extensions.MappedCommand.FirstOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.MappedQuery() -> void +Open.Database.Extensions.MappedCommand.Read(System.Action!>! handler) -> void +Open.Database.Extensions.MappedCommand.Read(System.Func!, TResult>! handler) -> TResult +Open.Database.Extensions.MappedCommand.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.SingleOrDefault() -> T? +Open.Database.Extensions.MappedCommand.SingleOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.Skip(int count) -> Open.Database.Extensions.MappedQueryWithSkip +Open.Database.Extensions.MappedCommand.Take(int count) -> Open.Database.Extensions.MappedQueryWithTake +Open.Database.Extensions.MappedCommand.ToArray() -> T[]! +Open.Database.Extensions.MappedCommand.ToArrayAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.ToDictionary(System.Func! keySelector, System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.Dictionary! +Open.Database.Extensions.MappedCommand.ToDictionaryAsync(System.Func! keySelector, System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedCommand.ToHashSet(System.Collections.Generic.IEqualityComparer? comparer = null) -> System.Collections.Generic.HashSet! +Open.Database.Extensions.MappedCommand.ToHashSetAsync(System.Collections.Generic.IEqualityComparer? comparer = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedCommand.ToImmutableArray() -> System.Collections.Immutable.ImmutableArray +Open.Database.Extensions.MappedCommand.ToImmutableArrayAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> +Open.Database.Extensions.MappedCommand.ToList() -> System.Collections.Generic.List! +Open.Database.Extensions.MappedCommand.ToListAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> +Open.Database.Extensions.MappedCommand.SingleResult() -> Open.Database.Extensions.MappedCommand From 33412b1bb6405e3874d282566a7873008a172237 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:29:13 -0800 Subject: [PATCH 08/15] Tests for the reader and command Map views Co-Authored-By: Claude Sonnet 5.5 --- .../MappedViewBindingTests.cs | 102 +++ .../MappedViewOwnershipTests.cs | 430 ++++++++++++ .../MappedViewParityTests.cs | 637 ++++++++++++++++++ 3 files changed, 1169 insertions(+) create mode 100644 Tests/Open.Database.Extensions.Core.Tests/MappedViewBindingTests.cs create mode 100644 Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs create mode 100644 Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewBindingTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewBindingTests.cs new file mode 100644 index 00000000..702bf7c9 --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewBindingTests.cs @@ -0,0 +1,102 @@ +#nullable enable + +namespace Open.Database.Extensions.Tests; + +// Compile-only: every call form of the new Map on IDataReader and +// IDbCommand binds to the type it returns, +// and the calls that existed bind as they did. +// Each call is pinned to the type it returns, +// so a change of binding breaks the build. +// +// A type that is both an IExecuteReader and an IDataReader (or IDbCommand) +// would make reader.Map() ambiguous (CS0121). +// None exists in this library, and none of the expressive commands +// implements IDbCommand or IDataReader (checked by hand; +// a test cannot assert a compile error). +// The reader extensions have no Map of their own, +// so reader.Map(selector) has nothing else to bind to. +[ExcludeFromCodeCoverage] +public static class MappedViewBindingTests +{ + sealed record Item(int Id); + + static void Pin(T _) { } + + static int Id(IDataRecord r) + => r.GetInt32(0); + + static readonly Action< + IDataReader, + IDbCommand, + System.Data.Common.DbCommand, + ExpressiveDbCommand, + IExecuteReader, + RowMap> Table = static (reader, command, dbCommand, expressive, exec, map) => + { + // The reader. + Pin>(reader.Map()); + Pin>(reader.Map(map)); + Pin>(reader.Map((RowMap?)null)); + Pin>(reader.Map(null)); + Pin>(reader.Map()); + Pin>(reader.Map(Id)); + Pin>(reader.Map(r => r.GetInt32(0))); + Pin>( + reader.Map(new KeyValuePair("Id", "N"))); + Pin>( + reader.Map( + new KeyValuePair("Id", "N"), + new KeyValuePair("Id", "M"))); + Pin>(reader.Map([])); + + // The command, and a DbCommand, which is also an IDbCommand. + Pin>(command.Map()); + Pin>(command.Map(map)); + Pin>(command.Map((RowMap?)null)); + Pin>(command.Map(null)); + Pin>(command.Map()); + Pin>(command.Map(Id)); + Pin>( + command.Map(new KeyValuePair("Id", "N"))); + Pin>( + command.Map( + new KeyValuePair("Id", "N"), + new KeyValuePair("Id", "M"))); + Pin>(dbCommand.Map()); + Pin>(dbCommand.Map(Id)); + + // What existed still binds as it did. + Pin>(exec.Map()); + Pin>(exec.Map(map)); + Pin>(exec.Map((RowMap?)null)); + Pin>(exec.Map(null)); + Pin>(exec.Map(Id)); + Pin>(expressive.Map()); + Pin>(expressive.Map(map)); + Pin>(expressive.Map(r => r.GetInt32(0))); + }; + + [Fact] + public static void TheTableCompiles() + => Assert.NotNull(Table); + + // A bare null: which overload takes it is pinned by what it does. + // It reads like the default map, by column name. + [Fact] + public static void ABareNull_ReadsLikeTheDefaultMap() + { + static IDataReader Rd() => ReaderFixtures.Reader([1, "a", null]); + + Assert.Equal(1, Rd().Map(null).FirstOrDefault()!.Id); + Assert.Equal( + 1, + new FakeExecuteReader(() => ReaderFixtures.Reader([1, "a", null])) + .Map(null) + .FirstOrDefault()!.Id); + Assert.Equal( + 1, + new FakeDbCommand(new FakeDbConnection(), () => ReaderFixtures.Reader([1, "a", null])) + .Map(null) + .FirstOrDefault()!.Id); + } +} diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs new file mode 100644 index 00000000..151ffbd7 --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs @@ -0,0 +1,430 @@ +using System.Data.Common; +using static Open.Database.Extensions.Tests.ReaderFixtures; + +#nullable enable + +namespace Open.Database.Extensions.Tests; + +// The reader view never opens, closes or disposes the caller's reader +// and never calls NextResult on it. +// The command view opens a closed connection and closes (not disposes) +// what it opened, leaves an open one open, +// and reads through the later result sets unless SingleResult() was called. +[ExcludeFromCodeCoverage] +public static class MappedViewOwnershipTests +{ + sealed record Row(int Id, string? Name); + + static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Counts what the library does to a reader it does not own. + sealed class CountingReader(object?[][] rows) + : TypedReader(Columns, rows) + { + public int NextResults { get; private set; } + public int Closes { get; private set; } + public int Disposes { get; private set; } + + public override bool NextResult() + { + NextResults++; + return false; + } + + public override Task NextResultAsync(CancellationToken cancellationToken) + { + NextResults++; + return Task.FromResult(false); + } + + public override void Close() + => Closes++; + + protected override void Dispose(bool disposing) + { + if (disposing) Disposes++; + base.Dispose(disposing); + } + + public void AssertUntouched() + { + Assert.Equal(0, NextResults); + Assert.Equal(0, Closes); + Assert.Equal(0, Disposes); + } + } + + public static TheoryData Terminals + => [ + "ToList", "ToArray", "FirstOrDefault", "SingleOrDefault", + "ToDictionary", "ToHashSet", "Read", "Skip", "Take"]; + + static void Sync(MappedReader q, string terminal) + { + switch (terminal) + { + case "ToList": q.ToList(); break; + case "ToArray": q.ToArray(); break; + case "FirstOrDefault": q.FirstOrDefault(); break; + case "SingleOrDefault": q.SingleOrDefault(); break; + case "ToDictionary": q.ToDictionary(r => r.Id); break; + case "ToHashSet": q.ToHashSet(); break; + case "Read": q.Read(items => items.Count()); break; + case "Skip": q.Skip(1).ToList(); break; + case "Take": q.Take(1).ToList(); break; + default: throw new ArgumentOutOfRangeException(nameof(terminal)); + } + } + + static async Task Async(MappedReader q, string terminal) + { + switch (terminal) + { + case "ToList": await q.ToListAsync(Ct); break; + case "ToArray": await q.ToArrayAsync(Ct); break; + case "FirstOrDefault": await q.FirstOrDefaultAsync(Ct); break; + case "SingleOrDefault": await q.SingleOrDefaultAsync(Ct); break; + case "ToDictionary": await q.ToDictionaryAsync(r => r.Id, null, Ct); break; + case "ToHashSet": await q.ToHashSetAsync(null, Ct); break; + case "Read": + await q.ReadAsync( + async (items, t) => + { + await foreach (Row _ in items.WithCancellation(t)) { } + }, + Ct); + break; + case "Skip": await q.Skip(1).ToListAsync(Ct); break; + case "Take": await q.Take(1).ToListAsync(Ct); break; + default: throw new ArgumentOutOfRangeException(nameof(terminal)); + } + } + + [Theory, MemberData(nameof(Terminals))] + public static void TheReaderView_NeverCallsNextResultCloseOrDispose( + string terminal) + { + using var reader = new CountingReader(Rows(3)); + Sync(reader.Map(), terminal); + reader.AssertUntouched(); + } + + [Theory, MemberData(nameof(Terminals))] + public static async Task TheReaderView_NeverCallsNextResultCloseOrDispose_Async( + string terminal) + { + using var reader = new CountingReader(Rows(3)); + await Async(reader.Map(), terminal); + reader.AssertUntouched(); + } + + [Fact] + public static void TheReaderView_ReadsFromTheCurrentPosition() + { + using var reader = new CountingReader(Rows(3)); + Assert.True(reader.Read()); + + Assert.Equal([2, 3], reader.Map().ToList().Select(r => r.Id)); + reader.AssertUntouched(); + } + + [Fact] + public static void TheReaderView_DoesNotReadThroughALaterSet_EvenWhenItFails() + { + var reader = new MultiSetReader(Rows(2), 2, failAtCall: 1); + + Assert.Equal(2, reader.Map().ToList().Count); + Assert.Equal(0, reader.NextResultCalls); + } + + [Fact] + public static async Task TheReaderView_DoesNotReadThroughALaterSet_Async() + { + var reader = new MultiSetReader(Rows(2), 2, failAtCall: 1); + + Assert.Equal(2, (await reader.Map().ToListAsync(Ct)).Count); + Assert.Equal(0, reader.NextResultCalls); + } + + [Fact] + public static void TheReaderView_WorksOnAReaderThatIsNotADbDataReader() + { + IDataReader reader = FakeReader.Create(["Id", "Name"], [1, "a"], [2, null]); + + Assert.Equal(2, reader.Map().ToList().Count); + reader.DidNotReceive().NextResult(); + reader.DidNotReceive().Close(); + reader.DidNotReceive().Dispose(); + } + + [Fact] + public static async Task TheReaderView_WorksOnAReaderThatIsNotADbDataReader_Async() + { + IDataReader reader = FakeReader.Create(["Id", "Name"], [1, "a"], [2, null]); + + Assert.Equal(2, (await reader.Map().ToListAsync(Ct)).Count); + reader.DidNotReceive().NextResult(); + reader.DidNotReceive().Dispose(); + } + + // ---- the command view ---- + + static (FakeDbConnection Connection, FakeDbCommand Command) Command( + ConnectionState state = ConnectionState.Closed, + Func? reader = null) + { + var connection = new FakeDbConnection(); + connection.SetState(state); + return (connection, new FakeDbCommand(connection, reader ?? (() => Reader(Rows(2))))); + } + + [Fact] + public static void AClosedConnection_IsOpenedThenClosed() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + + Assert.Equal(2, command.Map().ToList().Count); + + Assert.Equal(["open", "close"], connection.Log); + Assert.Equal(ConnectionState.Closed, connection.State); + } + + [Fact] + public static async Task AClosedConnection_IsOpenedThenClosed_Async() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + + Assert.Equal(2, (await command.Map().ToListAsync(Ct)).Count); + + Assert.Equal(["open", "close"], connection.Log); + } + + [Fact] + public static void AnOpenConnection_IsLeftOpen() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(ConnectionState.Open); + + Assert.Equal(2, command.Map().ToList().Count); + Assert.Equal(ConnectionState.Open, connection.State); + Assert.Empty(connection.Log); + } + + [Fact] + public static async Task AnOpenConnection_IsLeftOpen_Async() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(ConnectionState.Open); + + Assert.Equal(2, (await command.Map().ToListAsync(Ct)).Count); + Assert.Equal(ConnectionState.Open, connection.State); + Assert.Empty(connection.Log); + } + + [Fact] + public static void AConnectionTheViewOpened_IsClosedWhenTheHandlerThrows() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + + Assert.Throws( + () => command.Map().Read(_ => throw new InvalidOperationException())); + + Assert.Equal(["open", "close"], connection.Log); + } + + [Fact] + public static async Task AConnectionTheViewOpened_IsClosedWhenTheHandlerThrows_Async() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + + await Assert.ThrowsAsync( + async () => await command.Map().ReadAsync( + (_, _) => throw new InvalidOperationException(), + Ct)); + + Assert.Equal(["open", "close"], connection.Log); + } + + [Fact] + public static void TheConnectionAndTheCommand_AreNeverDisposed() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + command.Map().ToList(); + + // Both are still usable: a second terminal runs on the same pair. + Assert.Equal(2, command.Map().ToList().Count); + Assert.Equal(["open", "close", "open", "close"], connection.Log); + } + + [Fact] + public static void ACommandWithoutAReaderThatIsADbCommand_RunsSynchronously() + { + var probe = new ConnectionProbe(); + probe.ReaderFactory = () => FakeReader.Create(["Id", "Name"], [1, "a"]); + + Assert.Single(probe.Command.Map().ToList()); + + Assert.Equal("Open,ExecuteReader,DisposeReader,Close", probe.Log); + Assert.Equal( + CommandBehavior.SequentialAccess | CommandBehavior.CloseConnection, + probe.Behaviors.Single()); + } + + [Fact] + public static async Task ACommandThatIsNotADbCommand_RunsAsyncTerminalsToo() + { + var probe = new ConnectionProbe(ConnectionState.Open); + probe.ReaderFactory = () => FakeReader.Create(["Id", "Name"], [1, "a"]); + + Assert.Single(await probe.Command.Map().ToListAsync(Ct)); + + Assert.Equal("ExecuteReader,DisposeReader", probe.Log); + Assert.Equal(CommandBehavior.SequentialAccess, probe.Behaviors.Single()); + } + + // ---- behaviors and read-through ---- + + [Fact] + public static void TheBehaviors_AreThoseOfAnExpressiveCommandsMappedQuery() + { + (_, FakeDbCommand closed) = Command(); + closed.Map().ToList(); + closed.Map().SingleResult().ToList(); + closed.Map().SingleResult().FirstOrDefault(); + closed.Map(r => r.GetInt32(0)).ToList(); + closed.Map(r => r.GetInt32(0)).SingleResult().ToList(); + + Assert.Equal( + [ + CommandBehavior.SequentialAccess | CommandBehavior.CloseConnection, + CommandBehavior.SingleResult | CommandBehavior.SequentialAccess + | CommandBehavior.CloseConnection, + CommandBehavior.SingleResult | CommandBehavior.SequentialAccess + | CommandBehavior.SingleRow | CommandBehavior.CloseConnection, + CommandBehavior.CloseConnection, + CommandBehavior.SingleResult | CommandBehavior.CloseConnection, + ], + closed.Behaviors); + + (_, FakeDbCommand open) = Command(ConnectionState.Open); + open.Map().ToList(); + open.Map(r => r.GetInt32(0)).ToList(); + + Assert.Equal( + [CommandBehavior.SequentialAccess, CommandBehavior.Default], + open.Behaviors); + } + + [Fact] + public static void TheCommandView_ReadsThroughTheLaterSets() + { + MultiSetReader? reader = null; + (_, FakeDbCommand command) = Command( + reader: () => reader = new MultiSetReader(Rows(1), 2)); + + command.Map().ToList(); + + Assert.Equal(3, reader!.NextResultCalls); + Assert.Equal(0, reader.ReadsAfterNextResult); + } + + [Fact] + public static async Task TheCommandView_ReadsThroughTheLaterSets_Async() + { + MultiSetReader? reader = null; + (_, FakeDbCommand command) = Command( + reader: () => reader = new MultiSetReader(Rows(1), 2)); + + await command.Map().ToListAsync(Ct); + + Assert.Equal(3, reader!.NextResultCalls); + Assert.Equal(0, reader.ReadsAfterNextResult); + } + + [Fact] + public static void TheCommandView_ThrowsALaterFailure_AndClosesTheConnection() + { + (FakeDbConnection connection, FakeDbCommand command) = Command( + reader: () => new MultiSetReader(Rows(1), 2, failAtCall: 2)); + + Assert.Throws(() => command.Map().ToList()); + + Assert.Equal(["open", "close"], connection.Log); + } + + [Fact] + public static async Task TheCommandView_ThrowsALaterFailure_Async() + { + (FakeDbConnection connection, FakeDbCommand command) = Command( + reader: () => new MultiSetReader(Rows(1), 2, failAtCall: 2)); + + await Assert.ThrowsAsync( + async () => await command.Map().ToListAsync(Ct)); + + Assert.Equal(["open", "close"], connection.Log); + } + + [Fact] + public static void SingleResult_ReadsNothingThrough() + { + MultiSetReader? reader = null; + (_, FakeDbCommand command) = Command( + reader: () => reader = new MultiSetReader(Rows(1), 2, failAtCall: 1)); + + Assert.Single(command.Map().SingleResult().ToList()); + Assert.Equal(0, reader!.NextResultCalls); + } + + [Fact] + public static async Task SingleResult_ReadsNothingThrough_Async() + { + MultiSetReader? reader = null; + (_, FakeDbCommand command) = Command( + reader: () => reader = new MultiSetReader(Rows(1), 2, failAtCall: 1)); + + Assert.Single(await command.Map().SingleResult().ToListAsync(Ct)); + Assert.Equal(0, reader!.NextResultCalls); + } + + [Fact] + public static async Task ACanceledToken_ThrowsBeforeAnythingOpens() + { + (FakeDbConnection connection, FakeDbCommand command) = Command(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await command.Map().ToListAsync(cts.Token)); + await Assert.ThrowsAnyAsync( + async () => await Reader(Rows(1)).Map().ToListAsync(cts.Token)); + + Assert.Empty(connection.Log); + } + + // SingleResult(), reached through Skip or Take, is a no-op on a reader view: + // it already reads only the current result set. + [Fact] + public static void SingleResultThroughSkipAndTake_IsANoOp_OnAReaderView() + { + using var reader = new CountingReader(Rows(3)); + + Assert.Equal( + [2, 3], + reader.Map().Skip(1).SingleResult().ToList().Select(r => r.Id)); + reader.AssertUntouched(); + + using var other = new CountingReader(Rows(3)); + Assert.Single(other.Map().Take(1).SingleResult().ToList()); + Assert.Single(other.Map().Skip(1).Take(1).SingleResult().ToList()); + other.AssertUntouched(); + } + + [Fact] + public static async Task SingleResultThroughSkipAndTake_IsANoOp_OnAReaderView_Async() + { + using var reader = new CountingReader(Rows(3)); + + Assert.Equal( + 2, + (await reader.Map().Skip(1).SingleResult().ToListAsync(Ct)).Count); + reader.AssertUntouched(); + } +} diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs new file mode 100644 index 00000000..bae4bb9b --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs @@ -0,0 +1,637 @@ +using System.Data.Common; +using static Open.Database.Extensions.Tests.ReaderFixtures; + +#nullable enable + +namespace Open.Database.Extensions.Tests; + +// reader.Map() and command.Map() give the same results as +// Map() on an expressive command, terminal by terminal, +// in their synchronous and asynchronous forms. +[ExcludeFromCodeCoverage] +public static class MappedViewParityTests +{ + internal sealed record Person + { + public required int Id { get; init; } + public string? Name { get; init; } + public string? Note { get; init; } + } + + static CancellationToken Ct => TestContext.Current.CancellationToken; + + static readonly string[] Terminals = + [ + "ToList", "ToArray", "ToImmutableArray", "FirstOrDefault", + "SingleOrDefault", "ToDictionary", "ToHashSet", "Read", "ReadResult", + "Skip", "Take", "SkipTake", + ]; + + static readonly string[] Sets = ["three", "nulls", "one", "empty"]; + + public static TheoryData Cases + { + get + { + TheoryData data = []; + foreach (string set in Sets) + foreach (string terminal in Terminals) + data.Add(set, terminal); + + return data; + } + } + + static object?[][] Data(string set) + => set switch + { + "three" => Rows(3), + "nulls" => TwoRows, + "one" => Rows(1), + "empty" => [], + _ => throw new ArgumentOutOfRangeException(nameof(set)), + }; + + static MappedQuery Baseline(object?[][] rows) + => new FakeExecuteReader(() => Reader(rows)).Map(); + + static MappedReader ReaderView(object?[][] rows) + => Reader(rows).Map(); + + static MappedCommand CommandView(object?[][] rows) + => new FakeDbCommand(new FakeDbConnection(), () => Reader(rows)) + .Map(); + + static string Capture(Func run) + { + try + { + return Show(run()); + } + catch (Exception ex) + { + return ex.GetType().Name; + } + } + + static string Show(object? value) + => value switch + { + null => "null", + Person p => $"{p.Id}/{p.Name ?? "~"}/{p.Note ?? "~"}", + IDictionary d => string.Join( + ",", + d.Keys.Cast().Select(k => $"{k}:{Show(d[k])}")), + IEnumerable e => "[" + string.Join( + ",", + e.Cast().Select(Show)) + "]", + _ => value.ToString() ?? "", + }; + + [Theory, MemberData(nameof(Cases))] + public static void EveryTerminal_GivesTheSameResultAsAMappedQuery( + string set, + string terminal) + { + object?[][] rows = Data(set); + string expected = Run(Baseline(rows), terminal); + + Assert.Equal(expected, Run(ReaderView(rows), terminal)); + Assert.Equal(expected, Run(CommandView(rows), terminal)); + } + + [Theory, MemberData(nameof(Cases))] + public static async Task EveryAsyncTerminal_GivesTheSameResultAsAMappedQuery( + string set, + string terminal) + { + object?[][] rows = Data(set); + string expected = await RunAsync(Baseline(rows), terminal, Ct); + + Assert.Equal(expected, await RunAsync(ReaderView(rows), terminal, Ct)); + Assert.Equal(expected, await RunAsync(CommandView(rows), terminal, Ct)); + } + + [Fact] + public static void TheBaselineIsNotEmpty() + { + Assert.Equal("[1/n1/~,2/n2/~,3/n3/~]", Run(Baseline(Rows(3)), "ToList")); + Assert.Equal("[1/a/n1,2/~/n2]", Run(ReaderView(TwoRows), "ToList")); + Assert.Equal("null", Run(CommandView([]), "FirstOrDefault")); + Assert.Equal("[2/n2/~]", Run(ReaderView(Rows(3)), "SkipTake")); + } + + // A single value reads the first column, as Map() on a command does. + [Fact] + public static void ASingleValue_ReadsColumnZero() + { + ImmutableArray columns = [("V", typeof(string))]; + object?[][] rows = [["a"], [null], ["c"]]; + static TypedReader Rd(ImmutableArray c, object?[][] r) + => TypedReader.Create(c, r); + string?[] expected = ["a", null, "c"]; + + Assert.Equal(expected, Rd(columns, rows).Map().ToList()); + Assert.Equal( + expected, + new FakeDbCommand(new FakeDbConnection(), () => Rd(columns, rows)) + .Map() + .ToList()); + Assert.Equal( + expected, + new FakeExecuteReader(() => Rd(columns, rows)) + .Map() + .ToList()); + + ImmutableArray ints = [("N", typeof(int))]; + Assert.Equal( + [1, 2], + Rd(ints, [[1], [2]]).Map().ToArray()); + Assert.Equal( + 2, + Rd(ints, [[1], [2]]).Map().Skip(1).FirstOrDefault()); + } + + [Fact] + public static async Task ASingleValue_ReadsColumnZero_Async() + { + ImmutableArray columns = [("V", typeof(string))]; + object?[][] rows = [["a"], [null]]; + + Assert.Equal( + ["a", null], + await TypedReader.Create(columns, rows).Map().ToListAsync(Ct)); + Assert.Equal( + ["a", null], + await new FakeDbCommand( + new FakeDbConnection(), + () => TypedReader.Create(columns, rows)) + .Map() + .ToListAsync(Ct)); + } + + // The selector and the RowMap overloads, on both views. + [Fact] + public static async Task TheOtherOverloads_MapOnBothViews() + { + static TypedReader Rd() => Reader(Rows(2)); + static FakeDbCommand Cmd() + => new(new FakeDbConnection(), () => Reader(Rows(2))); + RowMap map = new( + new KeyValuePair(nameof(Person.Note), "Name")); + + Assert.Equal([1, 2], Rd().Map(r => r.GetInt32(0)).ToList()); + Assert.Equal([1, 2], Cmd().Map(r => r.GetInt32(0)).ToList()); + Assert.Equal([1, 2], await Cmd().Map(r => r.GetInt32(0)).ToListAsync(Ct)); + + Assert.Equal("n1", Rd().Map(map).FirstOrDefault()!.Note); + Assert.Equal("n1", Cmd().Map(map).FirstOrDefault()!.Note); + Assert.Equal( + "n2", + Rd().Map(new KeyValuePair("Note", "Name")) + .Skip(1) + .FirstOrDefault()!.Note); + Assert.Equal( + "n1", + Cmd().Map( + new KeyValuePair("Note", "Name"), + new KeyValuePair("Name", "Note")) + .FirstOrDefault()!.Note); + + // A null map reads like the default one. + Assert.Equal("n1", Rd().Map((RowMap?)null).FirstOrDefault()!.Name); + Assert.Equal("n1", Cmd().Map((RowMap?)null).FirstOrDefault()!.Name); + Assert.Equal( + "n1", + new FakeExecuteReader(() => Reader(Rows(1))) + .Map((RowMap?)null) + .FirstOrDefault()!.Name); + } + + [Fact] + public static void ANullReceiverOrSelector_Throws() + { + IDataReader? reader = null; + IDbCommand? command = null; + RowMap map = RowMap.Default; + + Assert.Throws(() => reader!.Map()); + Assert.Throws(() => reader!.Map(map)); + Assert.Throws( + () => reader!.Map(new KeyValuePair("Id", "Id"))); + Assert.Throws(() => reader!.Map(r => 1)); + Assert.Throws(() => command!.Map()); + Assert.Throws(() => command!.Map(map)); + Assert.Throws(() => command!.Map(r => 1)); + Assert.Throws( + () => Reader(Rows(1)).Map((Func)null!)); + Assert.Throws( + () => new FakeDbCommand(new FakeDbConnection(), () => Reader(Rows(1))) + .Map((Func)null!)); + } + + // A default struct of the whole family says it was not created by + // Map(): InvalidOperationException, never NullReferenceException. + [Fact] + public static void ADefaultStruct_ThrowsInvalidOperation_OnEveryMember() + { + static void Throws(Action use) + { + InvalidOperationException ex + = Assert.Throws(use); + Assert.Contains("Map()", ex.Message); + } + + // An async terminal reports it synchronously or as a faulted task. + static void Fails(Func> use) + { + InvalidOperationException ex = Assert.Throws( + () => use().AsTask().GetAwaiter().GetResult()); + Assert.Contains("Map()", ex.Message); + } + + static void Fails(Func use) + { + InvalidOperationException ex = Assert.Throws( + () => use().AsTask().GetAwaiter().GetResult()); + Assert.Contains("Map()", ex.Message); + } + + static void All(TQuery q, Action more) + where TQuery : IMappedQuery + { + Throws(() => q.Read(_ => { })); + Throws(() => q.Read(items => items.Count())); + Func, CancellationToken, ValueTask> h1 + = (_, _) => default; + Func, CancellationToken, ValueTask> h2 + = (_, _) => new ValueTask(0); + Fails(() => q.ReadAsync(h1, default)); + Fails(() => q.ReadAsync(h2, default)); + more(q); + } + + Throws(() => default(MappedQuery).ToList()); + All(default(MappedQuery), q => + { + Throws(() => q.ToList()); + Throws(() => q.ToArray()); + Throws(() => q.FirstOrDefault()); + Throws(() => q.SingleOrDefault()); + Throws(() => q.ToImmutableArray()); + Throws(() => q.ToDictionary(i => i)); + Throws(() => q.ToHashSet()); + Fails(() => q.ToListAsync()); + Fails(() => q.ToArrayAsync()); + Fails(() => q.FirstOrDefaultAsync()); + Fails(() => q.SingleOrDefaultAsync()); + Fails(() => q.ToImmutableArrayAsync()); + Fails(() => q.ToDictionaryAsync(i => i)); + Fails(() => q.ToHashSetAsync()); + Throws(() => q.Skip(1)); + Throws(() => q.Take(1)); + Throws(() => q.SingleResult()); + }); + All(default(MappedQueryWithSkip), q => + { + Throws(() => q.ToList()); + Fails(() => q.ToListAsync()); + Throws(() => q.Take(1)); + Throws(() => q.SingleResult()); + }); + All(default(MappedQueryWithTake), q => + { + Throws(() => q.ToList()); + Fails(() => q.FirstOrDefaultAsync()); + Throws(() => q.SingleResult()); + }); + All(default(MappedReader), q => + { + Throws(() => q.ToList()); + Throws(() => q.ToArray()); + Throws(() => q.FirstOrDefault()); + Throws(() => q.SingleOrDefault()); + Throws(() => q.ToImmutableArray()); + Throws(() => q.ToDictionary(i => i)); + Throws(() => q.ToHashSet()); + Fails(() => q.ToListAsync()); + Fails(() => q.ToArrayAsync()); + Fails(() => q.FirstOrDefaultAsync()); + Fails(() => q.SingleOrDefaultAsync()); + Fails(() => q.ToImmutableArrayAsync()); + Fails(() => q.ToDictionaryAsync(i => i)); + Fails(() => q.ToHashSetAsync()); + Throws(() => q.Skip(1)); + Throws(() => q.Take(1)); + }); + All(default(MappedCommand), q => + { + Throws(() => q.ToList()); + Throws(() => q.ToArray()); + Throws(() => q.FirstOrDefault()); + Throws(() => q.SingleOrDefault()); + Throws(() => q.ToImmutableArray()); + Throws(() => q.ToDictionary(i => i)); + Throws(() => q.ToHashSet()); + Fails(() => q.ToListAsync()); + Fails(() => q.ToArrayAsync()); + Fails(() => q.FirstOrDefaultAsync()); + Fails(() => q.SingleOrDefaultAsync()); + Fails(() => q.ToImmutableArrayAsync()); + Fails(() => q.ToDictionaryAsync(i => i)); + Fails(() => q.ToHashSetAsync()); + Throws(() => q.Skip(1)); + Throws(() => q.Take(1)); + Throws(() => q.SingleResult()); + }); + + Assert.Equal(default(MappedReader), default(MappedReader)); + Assert.Equal(default(MappedCommand), default(MappedCommand)); + } + + // No map, in every spelling, reads exactly as Map() does. + [Fact] + public static void NoMap_ReadsAsTheNoArgumentOverloadDoes() + { + ImmutableArray ints = [("N", typeof(int))]; + static TypedReader Ints(ImmutableArray c) + => TypedReader.Create(c, [1], [2]); + static TypedReader People() => Reader(Rows(2)); + static FakeDbCommand Cmd(Func reader) + => new(new FakeDbConnection(), reader); + + // A single value reads column 0. + Assert.Equal([1, 2], Ints(ints).Map(null).ToList()); + Assert.Equal([1, 2], Ints(ints).Map([]).ToList()); + Assert.Equal([1, 2], Ints(ints).Map((RowMap?)null).ToList()); + Assert.Equal([1, 2], Cmd(() => Ints(ints)).Map(null).ToList()); + Assert.Equal([1, 2], Cmd(() => Ints(ints)).Map([]).ToList()); + Assert.Equal( + [1, 2], + Cmd(() => Ints(ints)).Map((RowMap?)null).ToList()); + Assert.Equal( + [1, 2], + new FakeExecuteReader(() => Ints(ints)).Map(null).ToList()); + Assert.Equal( + [1, 2], + new FakeExecuteReader(() => Ints(ints)).Map([]).ToList()); + Assert.Equal( + [1, 2], + new FakeExecuteReader(() => Ints(ints)) + .Map((RowMap?)null) + .ToList()); + + // A row model reads by name. + string expected = Show(People().Map().ToList()); + Assert.Equal(expected, Show(People().Map(null).ToList())); + Assert.Equal(expected, Show(People().Map([]).ToList())); + Assert.Equal(expected, Show(Cmd(People).Map(null).ToList())); + Assert.Equal(expected, Show(Cmd(People).Map([]).ToList())); + Assert.Equal( + expected, + Show(new FakeExecuteReader(People).Map(null).ToList())); + Assert.Equal( + expected, + Show(new FakeExecuteReader(People).Map([]).ToList())); + } + + // ---- the generated terminals, once per type ---- + + static string Run(MappedQuery q, string terminal) + => Capture(() => terminal switch + { + "ToList" => q.ToList(), + "ToArray" => q.ToArray(), + "ToImmutableArray" => q.ToImmutableArray(), + "FirstOrDefault" => q.FirstOrDefault(), + "SingleOrDefault" => q.SingleOrDefault(), + "ToDictionary" => q.ToDictionary(p => p.Id), + "ToHashSet" => q.ToHashSet(), + "Read" => ReadAll(q), + "ReadResult" => q.Read(items => items.ToList()), + "Skip" => q.Skip(1).ToList(), + "Take" => q.Take(1).ToList(), + "SkipTake" => q.Skip(1).Take(1).ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }); + + static List ReadAll(MappedQuery q) + { + List list = []; + q.Read(items => list.AddRange(items)); + return list; + } + + static async Task RunAsync( + MappedQuery q, + string terminal, + CancellationToken ct) + { + try + { + object? result = terminal switch + { + "ToList" => await q.ToListAsync(ct), + "ToArray" => await q.ToArrayAsync(ct), + "ToImmutableArray" => await q.ToImmutableArrayAsync(ct), + "FirstOrDefault" => await q.FirstOrDefaultAsync(ct), + "SingleOrDefault" => await q.SingleOrDefaultAsync(ct), + "ToDictionary" => await q.ToDictionaryAsync(p => p.Id, null, ct), + "ToHashSet" => await q.ToHashSetAsync(null, ct), + "Read" => await ReadAllAsync(q, ct), + "ReadResult" => await q.ReadAsync( + async (items, token) => + { + List list = []; + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + return list; + }, + ct), + "Skip" => await q.Skip(1).ToListAsync(ct), + "Take" => await q.Take(1).ToListAsync(ct), + "SkipTake" => await q.Skip(1).Take(1).ToListAsync(ct), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }; + return Show(result); + } + catch (Exception ex) + { + return ex.GetType().Name; + } + } + + static async Task> ReadAllAsync( + MappedQuery q, + CancellationToken ct) + { + List list = []; + await q.ReadAsync( + async (items, token) => + { + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + }, + ct); + return list; + } + + static string Run(MappedReader q, string terminal) + => Capture(() => terminal switch + { + "ToList" => q.ToList(), + "ToArray" => q.ToArray(), + "ToImmutableArray" => q.ToImmutableArray(), + "FirstOrDefault" => q.FirstOrDefault(), + "SingleOrDefault" => q.SingleOrDefault(), + "ToDictionary" => q.ToDictionary(p => p.Id), + "ToHashSet" => q.ToHashSet(), + "Read" => ReadAll(q), + "ReadResult" => q.Read(items => items.ToList()), + "Skip" => q.Skip(1).ToList(), + "Take" => q.Take(1).ToList(), + "SkipTake" => q.Skip(1).Take(1).ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }); + + static List ReadAll(MappedReader q) + { + List list = []; + q.Read(items => list.AddRange(items)); + return list; + } + + static async Task RunAsync( + MappedReader q, + string terminal, + CancellationToken ct) + { + try + { + object? result = terminal switch + { + "ToList" => await q.ToListAsync(ct), + "ToArray" => await q.ToArrayAsync(ct), + "ToImmutableArray" => await q.ToImmutableArrayAsync(ct), + "FirstOrDefault" => await q.FirstOrDefaultAsync(ct), + "SingleOrDefault" => await q.SingleOrDefaultAsync(ct), + "ToDictionary" => await q.ToDictionaryAsync(p => p.Id, null, ct), + "ToHashSet" => await q.ToHashSetAsync(null, ct), + "Read" => await ReadAllAsync(q, ct), + "ReadResult" => await q.ReadAsync( + async (items, token) => + { + List list = []; + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + return list; + }, + ct), + "Skip" => await q.Skip(1).ToListAsync(ct), + "Take" => await q.Take(1).ToListAsync(ct), + "SkipTake" => await q.Skip(1).Take(1).ToListAsync(ct), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }; + return Show(result); + } + catch (Exception ex) + { + return ex.GetType().Name; + } + } + + static async Task> ReadAllAsync( + MappedReader q, + CancellationToken ct) + { + List list = []; + await q.ReadAsync( + async (items, token) => + { + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + }, + ct); + return list; + } + + static string Run(MappedCommand q, string terminal) + => Capture(() => terminal switch + { + "ToList" => q.ToList(), + "ToArray" => q.ToArray(), + "ToImmutableArray" => q.ToImmutableArray(), + "FirstOrDefault" => q.FirstOrDefault(), + "SingleOrDefault" => q.SingleOrDefault(), + "ToDictionary" => q.ToDictionary(p => p.Id), + "ToHashSet" => q.ToHashSet(), + "Read" => ReadAll(q), + "ReadResult" => q.Read(items => items.ToList()), + "Skip" => q.Skip(1).ToList(), + "Take" => q.Take(1).ToList(), + "SkipTake" => q.Skip(1).Take(1).ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }); + + static List ReadAll(MappedCommand q) + { + List list = []; + q.Read(items => list.AddRange(items)); + return list; + } + + static async Task RunAsync( + MappedCommand q, + string terminal, + CancellationToken ct) + { + try + { + object? result = terminal switch + { + "ToList" => await q.ToListAsync(ct), + "ToArray" => await q.ToArrayAsync(ct), + "ToImmutableArray" => await q.ToImmutableArrayAsync(ct), + "FirstOrDefault" => await q.FirstOrDefaultAsync(ct), + "SingleOrDefault" => await q.SingleOrDefaultAsync(ct), + "ToDictionary" => await q.ToDictionaryAsync(p => p.Id, null, ct), + "ToHashSet" => await q.ToHashSetAsync(null, ct), + "Read" => await ReadAllAsync(q, ct), + "ReadResult" => await q.ReadAsync( + async (items, token) => + { + List list = []; + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + return list; + }, + ct), + "Skip" => await q.Skip(1).ToListAsync(ct), + "Take" => await q.Take(1).ToListAsync(ct), + "SkipTake" => await q.Skip(1).Take(1).ToListAsync(ct), + _ => throw new ArgumentOutOfRangeException(nameof(terminal)), + }; + return Show(result); + } + catch (Exception ex) + { + return ex.GetType().Name; + } + } + + static async Task> ReadAllAsync( + MappedCommand q, + CancellationToken ct) + { + List list = []; + await q.ReadAsync( + async (items, token) => + { + await foreach (Person p in items.WithCancellation(token)) + list.Add(p); + }, + ct); + return list; + } + +} From b99e5589506b956b7a10d1554ca0375ac60a21a7 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:29:13 -0800 Subject: [PATCH 09/15] Docs and samples for the reader and command Map views Co-Authored-By: Claude Sonnet 5.5 --- docsite/articles/mapping.md | 12 ++++++++++++ docsite/articles/whats-new-11.md | 7 +++++++ docsite/samples/Mapping.cs | 32 ++++++++++++++++++++++++++++++++ docsite/samples/Program.cs | 10 ++++++++++ 4 files changed, 61 insertions(+) diff --git a/docsite/articles/mapping.md b/docsite/articles/mapping.md index 78abb531..f1f7cbfe 100644 --- a/docsite/articles/mapping.md +++ b/docsite/articles/mapping.md @@ -232,6 +232,18 @@ Every terminal has an `Async` twin that takes a `CancellationToken`: [!code-csharp[](../samples/Mapping.cs#map_dictionary_async)] +## Mapping a reader or a command you already have + +`Map()` also works on a plain `IDataReader` and on a plain `IDbCommand`. `reader.Map()` returns a `MappedReader` and `command.Map()` returns a `MappedCommand`. Both have the same overloads as the expressive command's `Map` (no argument, a `RowMap`, pairs, a selector) and the same terminals, synchronous and asynchronous, including `Skip`, `Take` and `Read`. The rules for mapping `T` are the ones above. + +On a reader, the reader is yours. A terminal never opens, closes or disposes it, and never calls `NextResult` on it: it reads the current result set from where the reader stands and stops. To map the next result set, call `NextResult` yourself and map again. + +[!code-csharp[](../samples/Mapping.cs#map_reader_view)] + +On a command, the terminal opens the connection only if it is closed, and closes it again (`Close`, not `Dispose`) only if it opened it, as an expressive command does. Like a `Map` on an expressive command, it reads through the later result sets, so a failure in a later statement is thrown, and `.SingleResult()` turns that off. The command is yours and is never disposed. The asynchronous terminals open and execute asynchronously when the command is a `DbCommand`. + +[!code-csharp[](../samples/Mapping.cs#map_command_view)] + ## Taking over: `Map(selector)` When the rules are not enough, `Map(Func)` runs your function for each row. It has the same deferred terminals: diff --git a/docsite/articles/whats-new-11.md b/docsite/articles/whats-new-11.md index ed61ea7a..86cb1767 100644 --- a/docsite/articles/whats-new-11.md +++ b/docsite/articles/whats-new-11.md @@ -5,6 +5,13 @@ Version 11 is a performance release and a tidy-up release. Result mapping is rewritten around a cached, typed row reader, column mapping is declared once, NULL and error handling follow simple rules, and stored-procedure parameters can be named from the arguments you already have. See [Performance](performance.md) for what the speed-up does and does not buy you. Coming from 10.x? Read "Upgrading from 10.x" below first. +## Changes since rc.3 + +- `reader.Map()` and `command.Map()` map a plain `IDataReader` or `IDbCommand` with the same terminals as an expressive command's `Map()`. The reader is never opened, closed, disposed or advanced to the next result set; a command opens and closes only the connection it opened. See [Mapping a reader or a command you already have](mapping.md#mapping-a-reader-or-a-command-you-already-have). +- A null `RowMap` passed to `Map(map)` no longer throws `ArgumentNullException`: it maps exactly as `Map()` does (a single value reads the first column). +- A `default` `MappedQuery` (or `MappedQueryWithSkip`, `MappedQueryWithTake`) now throws `InvalidOperationException` when used, where it threw `NullReferenceException`. +- The `Map` documentation now says how columns match by default: exactly, else ignoring case, else ignoring case and underscores (`author_id` feeds `AuthorId`). + ## Changes since rc.2 - A `Map` terminal now reads through the later result sets after the first, so a failure in a later statement is thrown, where rc.2 returned the rows and lost it. This is a behavior change from rc.2. `Map` still returns only the first set's rows. `.SingleResult()` after `Map` stops after the first set and does not see a later failure. A later failure can follow a write that already committed, so a caller that retries can repeat it. The older command methods, Channel and Dataflow do not read through yet. diff --git a/docsite/samples/Mapping.cs b/docsite/samples/Mapping.cs index b00814dd..ac05e6d3 100644 --- a/docsite/samples/Mapping.cs +++ b/docsite/samples/Mapping.cs @@ -274,6 +274,38 @@ public static (Post? First, Post? Single) FirstAndSingle(SqliteConnection connec return (first, single); } + public static async Task MapReaderView( + SqliteConnection connection) + { + #region map_reader_view + // The reader is yours: Map never closes or disposes it, + // and never moves it to the next result set. + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "select * from posts order by id"; + using SqliteDataReader reader = await command.ExecuteReaderAsync(); + + Post[] posts = await reader.Map().ToArrayAsync(); + #endregion + + return posts; + } + + public static async Task MapCommandView( + SqliteConnection connection) + { + #region map_command_view + // The connection is opened if it is closed, + // and closed again only if this call opened it. + // The command is yours and is never disposed here. + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "select * from posts where author = 42 order by id"; + + Post[] posts = await command.Map().ToArrayAsync(); + #endregion + + return posts; + } + public static string[] MapSelector(SqliteConnection connection) { #region map_selector diff --git a/docsite/samples/Program.cs b/docsite/samples/Program.cs index b0431169..292e012b 100644 --- a/docsite/samples/Program.cs +++ b/docsite/samples/Program.cs @@ -68,6 +68,16 @@ void Check(bool condition, string what) Check(PersonMaps.PersonNamesOnlyMap.Count() == 3 && PersonMaps.PersonNamesOnlyMap.Single(pair => pair.Value is null).Key == nameof(Person.LastName), "an ignored property is enumerated with a null column"); Person[] snake = Mapping.MapSnakeCase(connection); Check(snake[0] is { Id: 0, FirstName: "Ada", LastName: "Lovelace" }, "snake_case columns feed PascalCase properties with no map"); +Post[] readerView = await Mapping.MapReaderView(connection); +Check(readerView.Length == 5 && readerView[0].Title == "Post 1", "reader.Map() maps the rows of the caller's reader"); +Post[] commandView = await Mapping.MapCommandView(connection); +Check(commandView.Length == 3 && connection.State == ConnectionState.Open, "command.Map() on an open connection leaves it open"); +using (SqliteConnection closedConnection = factory.Create()) +{ + Check(closedConnection.State == ConnectionState.Closed, "a new connection is closed"); + Post[] viaClosed = await Mapping.MapCommandView(closedConnection); + Check(viaClosed.Length == 3 && closedConnection.State == ConnectionState.Closed, "command.Map() opens a closed connection and closes it again"); +} Person? exactFirst = connection.Command("select 'Alan' as first_name, 'Ada' as FirstName").Map().FirstOrDefault(); Person? exactSecond = connection.Command("select 'Ada' as FirstName, 'Alan' as first_name").Map().FirstOrDefault(); Check(exactFirst?.FirstName == "Ada" && exactSecond?.FirstName == "Ada", "an exact column beats an underscore match, in either column order"); From 09e4fd85bdfdf9aee53341f6a5e307064951d974 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:29:18 -0800 Subject: [PATCH 10/15] PublicAPI: the default constructors of the two views are named for their own types Co-Authored-By: Claude Sonnet 5.5 --- Source/Core/PublicAPI.Unshipped.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Core/PublicAPI.Unshipped.txt b/Source/Core/PublicAPI.Unshipped.txt index 889eee18..a1eb95e7 100644 --- a/Source/Core/PublicAPI.Unshipped.txt +++ b/Source/Core/PublicAPI.Unshipped.txt @@ -86,7 +86,7 @@ static Open.Database.Extensions.CoreExtensions.Map(this System.Data.IDbComman Open.Database.Extensions.MappedReader Open.Database.Extensions.MappedReader.FirstOrDefault() -> T? Open.Database.Extensions.MappedReader.FirstOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -Open.Database.Extensions.MappedReader.MappedQuery() -> void +Open.Database.Extensions.MappedReader.MappedReader() -> void Open.Database.Extensions.MappedReader.Read(System.Action!>! handler) -> void Open.Database.Extensions.MappedReader.Read(System.Func!, TResult>! handler) -> TResult Open.Database.Extensions.MappedReader.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask @@ -108,7 +108,7 @@ Open.Database.Extensions.MappedReader.ToListAsync(System.Threading.Cancellati Open.Database.Extensions.MappedCommand Open.Database.Extensions.MappedCommand.FirstOrDefault() -> T? Open.Database.Extensions.MappedCommand.FirstOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -Open.Database.Extensions.MappedCommand.MappedQuery() -> void +Open.Database.Extensions.MappedCommand.MappedCommand() -> void Open.Database.Extensions.MappedCommand.Read(System.Action!>! handler) -> void Open.Database.Extensions.MappedCommand.Read(System.Func!, TResult>! handler) -> TResult Open.Database.Extensions.MappedCommand.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask From 5aed1ce1a88326928034c78270e5b8c36989d6b9 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:40:17 -0800 Subject: [PATCH 11/15] Fix the test build and the style findings of the view tests and sources Co-Authored-By: Claude Sonnet 5.5 --- Source/Core/MappedQuery.cs | 8 +++-- Source/Core/MappedViews.cs | 2 ++ .../MappedViewOwnershipTests.cs | 12 ++++--- .../MappedViewParityTests.cs | 32 ++++++++++++------- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/Source/Core/MappedQuery.cs b/Source/Core/MappedQuery.cs index c2b6b776..02dfeae9 100644 --- a/Source/Core/MappedQuery.cs +++ b/Source/Core/MappedQuery.cs @@ -131,20 +131,22 @@ readonly struct MappedQueryCore // A caller-supplied selector may read in any order, so it runs with the default access mode. const CommandBehavior GeneratedMapping = CommandBehavior.SingleResult | CommandBehavior.SequentialAccess; - readonly IExecuteReader _command; readonly IRowSource _source; readonly int _skip; readonly int _take; // -1 == unbounded. readonly bool _singleResult; // SingleResult() was called: stop after the first result set and do not read through the rest. internal MappedQueryCore(IExecuteReader command, IRowSource source, int skip, int take, bool singleResult) - { _command = command; _source = source; _skip = skip; _take = take; _singleResult = singleResult; } + { Command = command; _source = source; _skip = skip; _take = take; _singleResult = singleResult; } // A default struct has no command: any use of it says so, // the same way for every type of the family. IExecuteReader Command - => _command ?? throw new InvalidOperationException( + { + get => field ?? throw new InvalidOperationException( "This query was not created by Map(); a default value cannot be used."); + init; + } // Without .SingleResult() the reader is asked for every result set, because a terminal reads through the later ones to report their failures. CommandBehavior Behavior => _singleResult diff --git a/Source/Core/MappedViews.cs b/Source/Core/MappedViews.cs index 1c819d4c..3b3e96c3 100644 --- a/Source/Core/MappedViews.cs +++ b/Source/Core/MappedViews.cs @@ -376,7 +376,9 @@ ConnectionState before if (connection is not null && (before & (ConnectionState.Open | ConnectionState.Broken)) != ConnectionState.Open) + { await connection.EnsureOpenAsync(cancellationToken).ConfigureAwait(false); + } CommandBehavior b = before == ConnectionState.Closed ? behavior | CommandBehavior.CloseConnection diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs index 151ffbd7..a6043b60 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs @@ -257,8 +257,10 @@ public static void TheConnectionAndTheCommand_AreNeverDisposed() [Fact] public static void ACommandWithoutAReaderThatIsADbCommand_RunsSynchronously() { - var probe = new ConnectionProbe(); - probe.ReaderFactory = () => FakeReader.Create(["Id", "Name"], [1, "a"]); + var probe = new ConnectionProbe + { + ReaderFactory = () => FakeReader.Create(["Id", "Name"], [1, "a"]), + }; Assert.Single(probe.Command.Map().ToList()); @@ -271,8 +273,10 @@ public static void ACommandWithoutAReaderThatIsADbCommand_RunsSynchronously() [Fact] public static async Task ACommandThatIsNotADbCommand_RunsAsyncTerminalsToo() { - var probe = new ConnectionProbe(ConnectionState.Open); - probe.ReaderFactory = () => FakeReader.Create(["Id", "Name"], [1, "a"]); + var probe = new ConnectionProbe(ConnectionState.Open) + { + ReaderFactory = () => FakeReader.Create(["Id", "Name"], [1, "a"]), + }; Assert.Single(await probe.Command.Map().ToListAsync(Ct)); diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs index bae4bb9b..a5ff7708 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Data.Common; using static Open.Database.Extensions.Tests.ReaderFixtures; @@ -35,8 +36,12 @@ public static TheoryData Cases { TheoryData data = []; foreach (string set in Sets) + { foreach (string terminal in Terminals) + { data.Add(set, terminal); + } + } return data; } @@ -158,11 +163,12 @@ public static async Task ASingleValue_ReadsColumnZero_Async() ImmutableArray columns = [("V", typeof(string))]; object?[][] rows = [["a"], [null]]; + string?[] expected = ["a", null]; Assert.Equal( - ["a", null], + expected, await TypedReader.Create(columns, rows).Map().ToListAsync(Ct)); Assert.Equal( - ["a", null], + expected, await new FakeDbCommand( new FakeDbConnection(), () => TypedReader.Create(columns, rows)) @@ -250,24 +256,26 @@ static void Fails(Func> use) Assert.Contains("Map()", ex.Message); } - static void Fails(Func use) + static void FailsPlain(Func use) { InvalidOperationException ex = Assert.Throws( () => use().AsTask().GetAwaiter().GetResult()); Assert.Contains("Map()", ex.Message); } + static ValueTask H1(IAsyncEnumerable items, CancellationToken token) + => default; + + static ValueTask H2(IAsyncEnumerable items, CancellationToken token) + => new(0); + static void All(TQuery q, Action more) where TQuery : IMappedQuery { Throws(() => q.Read(_ => { })); Throws(() => q.Read(items => items.Count())); - Func, CancellationToken, ValueTask> h1 - = (_, _) => default; - Func, CancellationToken, ValueTask> h2 - = (_, _) => new ValueTask(0); - Fails(() => q.ReadAsync(h1, default)); - Fails(() => q.ReadAsync(h2, default)); + FailsPlain(() => q.ReadAsync(H1, default)); + Fails(() => q.ReadAsync(H2, default)); more(q); } @@ -345,8 +353,10 @@ Func, CancellationToken, ValueTask> h2 Throws(() => q.SingleResult()); }); - Assert.Equal(default(MappedReader), default(MappedReader)); - Assert.Equal(default(MappedCommand), default(MappedCommand)); + MappedReader reader = default; + MappedCommand command = default; + Assert.Equal(reader, new MappedReader()); + Assert.Equal(command, new MappedCommand()); } // No map, in every spelling, reads exactly as Map() does. From c1c186e13003a04297576f73b57ad638147b163c Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:56:05 -0800 Subject: [PATCH 12/15] Fix the view build on netstandard, the null-map deprecation tests and two view tests Co-Authored-By: Claude Sonnet 5.5 --- Source/Core/MappedQuery.cs | 1 - .../DeprecationTests.cs | 8 ++++---- .../MappedViewOwnershipTests.cs | 9 +++++++-- .../MappedViewParityTests.cs | 7 ++++--- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Source/Core/MappedQuery.cs b/Source/Core/MappedQuery.cs index 02dfeae9..1c9eac35 100644 --- a/Source/Core/MappedQuery.cs +++ b/Source/Core/MappedQuery.cs @@ -145,7 +145,6 @@ IExecuteReader Command { get => field ?? throw new InvalidOperationException( "This query was not created by Map(); a default value cannot be used."); - init; } // Without .SingleResult() the reader is asked for every result set, because a terminal reads through the later ones to report their failures. diff --git a/Tests/Open.Database.Extensions.Core.Tests/DeprecationTests.cs b/Tests/Open.Database.Extensions.Core.Tests/DeprecationTests.cs index 3b663bb3..52154e6f 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/DeprecationTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/DeprecationTests.cs @@ -101,14 +101,14 @@ public static void Results_AndMapToList_GiveTheSameRows() } [Fact] - public static void ANullMap_IsAcceptedByResults_ButNotByMap() + public static void ANullMap_IsAcceptedByResults_AndByMap() { FakeExecuteReader command = Command(1); #pragma warning disable CS0618 // Obsolete in 11.0, removed in 12: kept covered until then. Assert.Single(command.Results((RowMap?)null)); #pragma warning restore CS0618 - Assert.Throws(() => command.Map((RowMap)null!)); + Assert.Single(command.Map((RowMap?)null).ToList()); } // ---- group 2 ---- @@ -240,13 +240,13 @@ public static void RetrieveThenDequeueAs_AndMapToList_GiveTheSameRows() } [Fact] - public static void ANullMap_IsAcceptedByDequeueAs_ButNotByMap() + public static void ANullMap_IsAcceptedByDequeueAs_AndByMap() { FakeExecuteReader command = Command(1); #pragma warning disable CS0618 // Obsolete in 11.0, removed in 12: kept covered until then. Assert.Single(command.Retrieve().DequeueAs(null)); #pragma warning restore CS0618 - Assert.Throws(() => command.Map((RowMap)null!)); + Assert.Single(command.Map((RowMap?)null).ToList()); } } diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs index a6043b60..8e1da06f 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewOwnershipTests.cs @@ -59,6 +59,11 @@ public static TheoryData Terminals "ToList", "ToArray", "FirstOrDefault", "SingleOrDefault", "ToDictionary", "ToHashSet", "Read", "Skip", "Take"]; + // SingleOrDefault throws on a second row, as on a MappedQuery, + // so it reads a one-row set. + static object?[][] RowsFor(string terminal) + => Rows(terminal == "SingleOrDefault" ? 1 : 3); + static void Sync(MappedReader q, string terminal) { switch (terminal) @@ -104,7 +109,7 @@ await q.ReadAsync( public static void TheReaderView_NeverCallsNextResultCloseOrDispose( string terminal) { - using var reader = new CountingReader(Rows(3)); + using var reader = new CountingReader(RowsFor(terminal)); Sync(reader.Map(), terminal); reader.AssertUntouched(); } @@ -113,7 +118,7 @@ public static void TheReaderView_NeverCallsNextResultCloseOrDispose( public static async Task TheReaderView_NeverCallsNextResultCloseOrDispose_Async( string terminal) { - using var reader = new CountingReader(Rows(3)); + using var reader = new CountingReader(RowsFor(terminal)); await Async(reader.Map(), terminal); reader.AssertUntouched(); } diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs index a5ff7708..26ce9e32 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs @@ -184,7 +184,8 @@ public static async Task TheOtherOverloads_MapOnBothViews() static FakeDbCommand Cmd() => new(new FakeDbConnection(), () => Reader(Rows(2))); RowMap map = new( - new KeyValuePair(nameof(Person.Note), "Name")); + new KeyValuePair(nameof(Person.Note), "Name"), + new KeyValuePair(nameof(Person.Name), null)); Assert.Equal([1, 2], Rd().Map(r => r.GetInt32(0)).ToList()); Assert.Equal([1, 2], Cmd().Map(r => r.GetInt32(0)).ToList()); @@ -194,9 +195,9 @@ static FakeDbCommand Cmd() Assert.Equal("n1", Cmd().Map(map).FirstOrDefault()!.Note); Assert.Equal( "n2", - Rd().Map(new KeyValuePair("Note", "Name")) + Rd().Map(new KeyValuePair("Note", null)) .Skip(1) - .FirstOrDefault()!.Note); + .FirstOrDefault()!.Name); Assert.Equal( "n1", Cmd().Map( From 86be9ffa8f54c2cef3ef73b7427d655a860fdc70 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:06:42 -0800 Subject: [PATCH 13/15] Expression body for the Command property (IDE0025) Co-Authored-By: Claude Opus 5.5 --- Source/Core/MappedQuery.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Source/Core/MappedQuery.cs b/Source/Core/MappedQuery.cs index 1c9eac35..adada998 100644 --- a/Source/Core/MappedQuery.cs +++ b/Source/Core/MappedQuery.cs @@ -142,10 +142,8 @@ internal MappedQueryCore(IExecuteReader command, IRowSource source, int skip, // A default struct has no command: any use of it says so, // the same way for every type of the family. IExecuteReader Command - { - get => field ?? throw new InvalidOperationException( + => field ?? throw new InvalidOperationException( "This query was not created by Map(); a default value cannot be used."); - } // Without .SingleResult() the reader is asked for every result set, because a terminal reads through the later ones to report their failures. CommandBehavior Behavior => _singleResult From 6dca05a40a1492b0a3d83596dfe27dcf84a475be Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:13:57 -0800 Subject: [PATCH 14/15] Blank lines between members, PublicAPI order Co-Authored-By: Claude Opus 5.5 --- Source/Core/MappedViews.cs | 2 ++ Source/Core/PublicAPI.Unshipped.txt | 2 +- .../MappedViewParityTests.cs | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Source/Core/MappedViews.cs b/Source/Core/MappedViews.cs index 3b3e96c3..7a808ca3 100644 --- a/Source/Core/MappedViews.cs +++ b/Source/Core/MappedViews.cs @@ -439,6 +439,7 @@ public MappedQueryWithSkip Skip(int count) /// public MappedQueryWithTake Take(int count) => new(_core.WithTake(count)); + /// public void Read(Action> handler) => _core.Read(handler); @@ -577,6 +578,7 @@ public MappedQueryWithTake Take(int count) /// public MappedCommand SingleResult() => new(_core.WithSingleResult()); + /// public void Read(Action> handler) => _core.Read(handler); diff --git a/Source/Core/PublicAPI.Unshipped.txt b/Source/Core/PublicAPI.Unshipped.txt index a1eb95e7..4ea14b8a 100644 --- a/Source/Core/PublicAPI.Unshipped.txt +++ b/Source/Core/PublicAPI.Unshipped.txt @@ -115,6 +115,7 @@ Open.Database.Extensions.MappedCommand.ReadAsync(System.Func.ReadAsync(System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask>! handler, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask Open.Database.Extensions.MappedCommand.SingleOrDefault() -> T? Open.Database.Extensions.MappedCommand.SingleOrDefaultAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Open.Database.Extensions.MappedCommand.SingleResult() -> Open.Database.Extensions.MappedCommand Open.Database.Extensions.MappedCommand.Skip(int count) -> Open.Database.Extensions.MappedQueryWithSkip Open.Database.Extensions.MappedCommand.Take(int count) -> Open.Database.Extensions.MappedQueryWithTake Open.Database.Extensions.MappedCommand.ToArray() -> T[]! @@ -127,4 +128,3 @@ Open.Database.Extensions.MappedCommand.ToImmutableArray() -> System.Collectio Open.Database.Extensions.MappedCommand.ToImmutableArrayAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask> Open.Database.Extensions.MappedCommand.ToList() -> System.Collections.Generic.List! Open.Database.Extensions.MappedCommand.ToListAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> -Open.Database.Extensions.MappedCommand.SingleResult() -> Open.Database.Extensions.MappedCommand diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs index 26ce9e32..fefbce65 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedViewParityTests.cs @@ -644,5 +644,4 @@ await q.ReadAsync( ct); return list; } - } From ed4fe43502b528e7b0463e30256629c24c5551a3 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:54:23 -0800 Subject: [PATCH 15/15] Views: dispose the reader with ConfigureAwait(false), a cancelled token gives a cancelled task, document Read() and the CS0121 ambiguity Co-Authored-By: Claude Opus 5.5 --- Source/Core/MappedViews.cs | 22 ++++++++++++++++++---- docsite/articles/whats-new-11.md | 1 + 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Source/Core/MappedViews.cs b/Source/Core/MappedViews.cs index 7a808ca3..71654532 100644 --- a/Source/Core/MappedViews.cs +++ b/Source/Core/MappedViews.cs @@ -325,7 +325,13 @@ public override ValueTask ExecuteReaderAsync( CommandBehavior behavior, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); + // A cancelled token gives a cancelled task, not an exception at the call. + if (cancellationToken.IsCancellationRequested) + { + return new ValueTask( + Task.FromCanceled(cancellationToken)); + } + return transform(reader, state); } } @@ -387,10 +393,15 @@ ConnectionState before if (command is DbCommand dbCommand) { #if NETSTANDARD2_0 + using DbDataReader dbReader = await dbCommand + .ExecuteReaderAsync(b, cancellationToken) + .ConfigureAwait(false); #else - await + DbDataReader dbReader = await dbCommand + .ExecuteReaderAsync(b, cancellationToken) + .ConfigureAwait(false); + await using var dbReaderDisposal = dbReader.ConfigureAwait(false); #endif - using DbDataReader dbReader = await dbCommand.ExecuteReaderAsync(b, cancellationToken).ConfigureAwait(false); return await transform(dbReader, state).ConfigureAwait(false); } @@ -417,6 +428,8 @@ ConnectionState before /// it reads the rows of the current result set from the reader's current position and stops, /// leaving the reader open and positioned after that set's last row. /// A caller who wants the next result set calls NextResult and maps again. +/// The asynchronous terminals read the rows with Read(), +/// as an expressive command does by default. /// /// /// Skip and Take return the types of ; @@ -548,7 +561,8 @@ public ValueTask> ToHashSetAsync( /// The connection is opened only if it is closed, /// and a connection the terminal opened is closed (Close, not Dispose) when it ends; /// one that was already open is left open. -/// The asynchronous terminals open and execute asynchronously when the command is a . +/// The asynchronous terminals open and execute asynchronously when the command is a , +/// then read the rows with Read(), as an expressive command does by default. /// /// /// A terminal returns the rows of the first result set only, diff --git a/docsite/articles/whats-new-11.md b/docsite/articles/whats-new-11.md index 5f75c755..f503c8f7 100644 --- a/docsite/articles/whats-new-11.md +++ b/docsite/articles/whats-new-11.md @@ -16,6 +16,7 @@ Rebuild consumers of rc.3. Both changes are source-compatible for every call exc - `reader.Map()` and `command.Map()` map a plain `IDataReader` or `IDbCommand` with the same terminals as an expressive command's `Map()`. The reader is never opened, closed, disposed or advanced to the next result set; a command opens and closes only the connection it opened. See [Mapping a reader or a command you already have](mapping.md#mapping-a-reader-or-a-command-you-already-have). - A null `RowMap` passed to `Map(map)` no longer throws `ArgumentNullException`: it maps exactly as `Map()` does (a single value reads the first column). +- A type of your own that implements both `IExecuteReader` and `IDbCommand` (or `IDataReader`) now gets an ambiguous `Map` call (CS0121); cast it to the interface you mean. - A `default` `MappedQuery` (or `MappedQueryWithSkip`, `MappedQueryWithTake`) now throws `InvalidOperationException` when used, where it threw `NullReferenceException`. - The `Map` documentation now says how columns match by default: exactly, else ignoring case, else ignoring case and underscores (`author_id` feeds `AuthorId`).