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