From 0308657363437a21eb5b980dfe2900b287bdde28 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:19:18 -0800 Subject: [PATCH 1/2] ExecuteScalar converts the way Map() does (a bigint read as int, a numeric as long); overflow throws The exact cast threw InvalidCastException when the column type differed from T. ExecuteScalar, ExecuteScalarAsync, ExecuteReturn and ExecuteReturnAsync now use the single-value conversion Map() uses: the value as is when it is already a T, NULL and no row to default, else a range-checked conversion that throws ValueMappingException on overflow. Co-Authored-By: Claude Sonnet 5.5 --- Source/Core/Core/RowMap.cs | 62 +++ Source/Core/ExpressiveCommandBase.cs | 30 +- Source/Core/ExpressiveDbCommandBase.cs | 10 +- .../ExecuteReturnNullTests.cs | 14 +- .../ExecuteScalarConversionTests.cs | 439 ++++++++++++++++++ .../ExecuteScalarNullTests.cs | 17 +- docsite/articles/coming-from-dapper.md | 4 +- docsite/articles/expressive-commands.md | 10 +- docsite/articles/mapping.md | 2 +- docsite/articles/whats-new-11.md | 4 + docsite/samples/ExpressiveCommands.cs | 21 +- docsite/samples/Program.cs | 4 +- 12 files changed, 575 insertions(+), 42 deletions(-) create mode 100644 Tests/Open.Database.Extensions.Core.Tests/ExecuteScalarConversionTests.cs diff --git a/Source/Core/Core/RowMap.cs b/Source/Core/Core/RowMap.cs index e28aef4..f986baf 100644 --- a/Source/Core/Core/RowMap.cs +++ b/Source/Core/Core/RowMap.cs @@ -723,6 +723,68 @@ static ValueMappingException FirstColumnError( ex); } + // The scalar of a command (ExecuteScalar, ExecuteReturn): + // a value that has arrived boxed, converted as column 0 of Map() is. + // NULL, DBNull and no row are default(T); + // a value that is already a T is returned as it is; + // any other value takes the conversion Map() gives a column of that + // type (a checked integer conversion, a widening to a fractional type, + // else ConvertValue), chosen by the runtime type of the value, + // which is the type the reader reports for the column. + // A failure is the ValueMappingException the column read gives. + // Built from the same helpers (TypedGetters, TryConvert, ConvertValue), + // so the two cannot drift apart. + internal static T ConvertScalar(object? value) + { + if (value is null or DBNull) return default!; + if (value is T exact) return exact; + + try + { + Func convert + = Scalars.GetOrAdd(value.GetType(), BuildScalar); + return convert(value); + } + catch (Exception ex) + { + throw new ValueMappingException( + $"Unable to read the scalar as {Shape.TypeName(typeof(T))}.", + "", + "", + typeof(T), + ex); + } + } + + // One converter per runtime type of the value: + // as many as the types a provider returns for one T. + static readonly ConcurrentDictionary> Scalars = new(); + + // The typed read of Map() (TypedRead and ReadValue) + // applied to an unboxed value; + // a type with no direct conversion to T takes the general path. + static Func BuildScalar(Type valueType) + { + Type? underlying = Nullable.GetUnderlyingType(typeof(T)); + ParameterExpression input = Expression.Parameter(typeof(object), "value"); + + if (TypedGetters.ByType.ContainsKey(valueType) + && TryConvert( + Expression.Convert(input, valueType), + underlying ?? typeof(T)) is { } typed) + { + return Expression + .Lambda>( + underlying is null + ? typed + : Expression.Convert(typed, typeof(T)), + input) + .Compile(); + } + + return static value => ConvertValue(value); + } + // 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). diff --git a/Source/Core/ExpressiveCommandBase.cs b/Source/Core/ExpressiveCommandBase.cs index b3ae4a2..6d4081e 100644 --- a/Source/Core/ExpressiveCommandBase.cs +++ b/Source/Core/ExpressiveCommandBase.cs @@ -1324,11 +1324,14 @@ ValueTask IExecuteReader.ExecuteReaderAsync(Func /// /// Calls ExecuteNonQuery on the underlying command but sets up a return parameter and returns that value. /// - /// The type expected. - /// The value from the return parameter; when it is NULL. - /// The value is not of type . + /// The type to convert the value to. + /// + /// The value from the return parameter, converted to as Map<T>() converts a column: a value that is already a is returned as is, an integer is converted to another integer type with a range check, and any other value takes the general conversion. + /// when it is NULL. + /// + /// The value does not convert to , or is out of its range. public T ExecuteReturn() - => CastScalar(ExecuteReturn()); + => RowMap.ConvertScalar(ExecuteReturn()); /// /// Calls ExecuteNonQueryAsync on the underlying command but sets up a return parameter and returns that value. @@ -1369,7 +1372,7 @@ public T ExecuteReturn() /// public async ValueTask ExecuteReturnAsync( CancellationToken cancellationToken = default) - => CastScalar( + => RowMap.ConvertScalar( await ExecuteReturnAsync(cancellationToken).ConfigureAwait(false)); /// @@ -1387,17 +1390,16 @@ public int ExecuteNonQuery() => Execute(command => command.ExecuteScalar()); /// - /// Calls ExecuteScalar on the underlying command and casts to the expected type. + /// Calls ExecuteScalar on the underlying command and converts the value to the expected type, as Map<T>() converts a column. /// - /// The type expected. - /// The value returned from the method; when the result is NULL or there was no row. - /// The value is not of type . + /// The type to convert the value to. + /// + /// The value, converted to : a value that is already a is returned as is, an integer is converted to another integer type with a range check (a read as an ), and any other value takes the general conversion (a read as a ). + /// when the result is NULL or there was no row. + /// + /// The value does not convert to , or is out of its range; the conversion's own error is the inner exception. public T ExecuteScalar() - => CastScalar(ExecuteScalar()); - - // NULL and no row (null or DBNull) become default(T) for every T; anything else is the plain cast. - private protected static T CastScalar(object? value) - => value is null or DBNull ? default! : (T)value; + => RowMap.ConvertScalar(ExecuteScalar()); // What the transform overloads hand to the user's function: NULL (DBNull) becomes null, like no row; any other value is passed unchanged. private protected static object? ScalarValue(object? value) diff --git a/Source/Core/ExpressiveDbCommandBase.cs b/Source/Core/ExpressiveDbCommandBase.cs index 3719cda..00876e6 100644 --- a/Source/Core/ExpressiveDbCommandBase.cs +++ b/Source/Core/ExpressiveDbCommandBase.cs @@ -182,17 +182,19 @@ await ExecuteScalarAsync(cancellationToken) } /// - /// Asynchronously executes scalar on the underlying command and casts to the expected type. + /// Asynchronously executes scalar on the underlying command and converts the value to the expected type, as Map<T>() converts a column. /// /// /// /// A token that can cancel the call. /// It is used together with the command's own token; either one cancels. /// - public ValueTask ExecuteScalarAsync( + public async ValueTask ExecuteScalarAsync( CancellationToken cancellationToken = default) - => ExecuteCombinedAsync(cancellationToken, static async (command, _, token) - => CastScalar(await command.ExecuteScalarAsync(token).ConfigureAwait(false))); + // Converted after the command has released the connection. + => RowMap.ConvertScalar( + await ExecuteScalarAsync(cancellationToken) + .ConfigureAwait(false)); /// /// Asynchronously executes scalar on the underlying command. diff --git a/Tests/Open.Database.Extensions.Core.Tests/ExecuteReturnNullTests.cs b/Tests/Open.Database.Extensions.Core.Tests/ExecuteReturnNullTests.cs index 31b02bf..dc75cf3 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/ExecuteReturnNullTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/ExecuteReturnNullTests.cs @@ -3,7 +3,7 @@ namespace Open.Database.Extensions.Tests; // ExecuteReturn() / ExecuteReturnAsync() with a NULL return value (the provider leaves null or DBNull in the return parameter): -// default(T) for every T, the same rule as ExecuteScalar(). A value is still the exact cast. +// default(T) for every T, the same rule as ExecuteScalar(). A value converts as Map() does (ExecuteScalarConversionTests). [ExcludeFromCodeCoverage] public static class ExecuteReturnNullTests { @@ -38,12 +38,13 @@ public static void ExecuteReturn_Null_ReturnsDefault() } [Fact] - public static void ExecuteReturn_AValue_IsStillTheExactCast() + public static void ExecuteReturn_AValue_IsConverted() { Assert.Equal(42, Returning(42).ExecuteReturn()); Assert.Equal(42, Returning(42).ExecuteReturn()); Assert.Equal("v", Returning("v").ExecuteReturn()); - Assert.Throws(() => Returning(42).ExecuteReturn()); // a cast, not a conversion + Assert.Equal(42L, Returning(42).ExecuteReturn()); // a conversion, not a cast + Assert.Throws(() => Returning(5_000_000_000L).ExecuteReturn()); // a range check, as Map() does } // ---- async ---- @@ -74,7 +75,7 @@ await Returning(none) } [Fact] - public static async Task ExecuteReturnAsync_AValue_IsStillTheExactCast() + public static async Task ExecuteReturnAsync_AValue_IsConverted() { Assert.Equal( 42, @@ -91,8 +92,9 @@ await Returning(42) await Returning("v") .ExecuteReturnAsync( cancellationToken: TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync( - async () => await Returning(42) + Assert.Equal( + 42L, + await Returning(42) .ExecuteReturnAsync( cancellationToken: TestContext.Current.CancellationToken)); } diff --git a/Tests/Open.Database.Extensions.Core.Tests/ExecuteScalarConversionTests.cs b/Tests/Open.Database.Extensions.Core.Tests/ExecuteScalarConversionTests.cs new file mode 100644 index 0000000..444d1b2 --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/ExecuteScalarConversionTests.cs @@ -0,0 +1,439 @@ +using static Open.Database.Extensions.Tests.ExecuteScalarNullTests; + +#nullable enable + +namespace Open.Database.Extensions.Tests; + +// ExecuteScalar() and ExecuteScalarAsync() convert the value +// the way column 0 of Map() does: +// a value that is already a T as it is, +// an integer into another integer type with a range check, +// any other value by the general conversion, +// NULL and no row to default(T); +// and a failure is the same ValueMappingException, +// naming the scalar rather than a column. +// The fixtures are those of ExecuteScalarNullTests. +[ExcludeFromCodeCoverage] +public static class ExecuteScalarConversionTests +{ + public enum Level { None = 0, One = 1, Two = 2 } + public enum Small : byte { A = 0, B = 3 } + + static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Sync and async give the same value. + static async Task Reads(object? scalar, T expected) + { + Assert.Equal(expected, Sync(scalar).ExecuteScalar()); + Assert.Equal( + expected, + await Async(scalar).ExecuteScalarAsync(Ct)); + } + + // Sync and async give the same failure. + static async Task Fails(object? scalar) + { + ValueMappingException syncFailure = Assert.Throws( + () => Sync(scalar).ExecuteScalar()); + ValueMappingException asyncFailure + = await Assert.ThrowsAsync( + async () => await Async(scalar).ExecuteScalarAsync(Ct)); + + Assert.Equal(syncFailure.Message, asyncFailure.Message); + Assert.Equal( + syncFailure.InnerException?.GetType(), + asyncFailure.InnerException?.GetType()); + return syncFailure; + } + + // ---- the values the consumer met ---- + + [Fact] + public static async Task ABigint_ReadsAsInt() + { + await Reads(5L, 5); + await Reads(int.MaxValue + 0L, int.MaxValue); + await Reads(int.MinValue + 0L, int.MinValue); + await Reads(-5L, -5); + } + + [Fact] + public static async Task ANumeric_ReadsAsLong() + { + await Reads(123m, 123L); + await Reads(42.0m, 42L); + await Reads(2.6m, 3L); // Convert rounds to nearest, as for a property + await Reads(5m, 5); + } + + [Fact] + public static async Task AnInt_ReadsAsLong() + { + await Reads(7, 7L); + await Reads(int.MinValue, (long)int.MinValue); + } + + [Fact] + public static async Task ByteAndShort_Widen() + { + await Reads((byte)7, 7); + await Reads((byte)7, 7L); + await Reads((short)-7, -7); + await Reads((short)7, 7L); + await Reads((sbyte)-7, -7); + await Reads((ushort)7, 7); + await Reads(7u, 7L); + await Reads(7ul, 7L); + } + + [Fact] + public static async Task ALongThatDoesNotFit_IsAValueMappingException() + { + ValueMappingException high = await Fails(int.MaxValue + 1L); + Assert.IsType(high.InnerException); + Assert.Equal(typeof(int), high.TargetType); + Assert.Equal("Unable to read the scalar as Int32.", high.Message); + Assert.Equal("", high.ColumnName); + Assert.Equal("", high.PropertyName); + + await Fails(int.MinValue - 1L); + await Fails(long.MaxValue); + await Fails(70_000); + await Fails(-1); + await Fails(-1); + await Fails(-1L); + await Fails((byte)200); + await Fails(ulong.MaxValue); + await Fails(5_000_000_000m); + } + + [Fact] + public static async Task ANullableTarget_ConvertsToo() + { + await Reads(5L, 5); + await Reads(5, 5L); + await Reads(5, 5); + await Reads(2.6m, 3); + await Reads(5, 5d); + await Reads(2, Level.Two); + + ValueMappingException failure = await Fails(5_000_000_000L); + Assert.Equal(typeof(int?), failure.TargetType); + Assert.Equal("Unable to read the scalar as Int32?.", failure.Message); + } + + [Fact] + public static async Task AFractionalTarget_Widens() + { + await Reads(5L, 5m); + await Reads(5, 5d); + await Reads(5L, 5f); + await Reads(1.5f, 1.5d); + await Reads(1.5m, 1.5d); + await Reads(2.5d, 2.5m); + await Reads(2.6d, 3); // fractional into an integer rounds + } + + // ---- a value that is already a T ---- + + [Fact] + public static async Task TheMatchingType_IsReturnedAsIs() + { + var guid = Guid.NewGuid(); + var moment = new DateTime(2026, 11, 9, 8, 7, 6, DateTimeKind.Utc); + var offset = new DateTimeOffset(moment); + + await Reads(5, 5); + await Reads(5L, 5L); + await Reads(1.5d, 1.5d); + await Reads(1.5m, 1.5m); + await Reads(true, true); + await Reads("text", "text"); + await Reads(guid, guid); + await Reads(moment, moment); + await Reads(offset, offset); + await Reads(TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); + await Reads(2, 2 as int?); + await Reads(Level.Two, Level.Two); + + var bytes = new byte[] { 1, 2, 3 }; + Assert.Same(bytes, Sync(bytes).ExecuteScalar()); + Assert.Same(bytes, await Async(bytes).ExecuteScalarAsync(Ct)); + + var boxed = new object(); + Assert.Same(boxed, Sync(boxed).ExecuteScalar()); + Assert.Same(boxed, await Async(boxed).ExecuteScalarAsync(Ct)); + } + + // ---- NULL ---- + + [Fact] + public static async Task NullDbNullAndNoRow_AreDefault_WhateverT() + { + foreach (object? none in new object?[] { null, DBNull.Value }) + { + await Reads(none, 0); + await Reads(none, 0L); + await Reads(none, null); + await Reads(none, null); + await Reads(none, Level.None); + await Reads(none, default(DateTime)); + await Reads(none, null); + await Reads(none, null); + await Reads(none, null); + } + } + + // ---- text, identifiers, dates and enums ---- + + [Fact] + public static async Task TextAndDates_ConvertLikeAColumn() + { + var guid = Guid.NewGuid(); + var moment = new DateTime(2026, 11, 9, 1, 2, 3); + + await Reads(5, "5"); + await Reads(5L, "5"); + await Reads("42", 42); + await Reads(guid.ToString(), guid); + await Reads(guid.ToByteArray(), guid); + await Reads(moment, new DateTimeOffset(moment)); + await Reads(moment, DateOnly.FromDateTime(moment)); + await Reads(TimeSpan.FromHours(1), new TimeOnly(1, 0)); + } + + [Fact] + public static async Task AnEnum_IsReadFromItsNumberOrItsName() + { + await Reads(2, Level.Two); + await Reads(2L, Level.Two); + await Reads((byte)3, Small.B); + await Reads("Two", Level.Two); + await Reads("two", Level.Two); + await Reads(5, (Level)5); // as a property, any number + } + + [Fact] + public static async Task AValueThatDoesNotConvert_IsAValueMappingException() + { + var guid = Guid.NewGuid(); + + ValueMappingException text = await Fails("abc"); + Assert.IsType(text.InnerException); + Assert.Equal(typeof(int), text.TargetType); + + await Fails("not a guid"); + await Fails(5); + await Fails(guid); + await Fails(5); + await Fails("nope"); + + ValueMappingException bytes = await Fails(new byte[] { 1 }); + Assert.IsType(bytes.InnerException); + } + + [Fact] + public static async Task TheFailure_NamesTheScalar_NotAColumn() + { + ValueMappingException failure = await Fails("abc"); + + Assert.Contains("scalar", failure.Message); + Assert.DoesNotContain("column", failure.Message); + } + + // ---- the same as Map() ---- + + // Map() reads column 0 of one row of that column type; + // ExecuteScalar() converts the value that column holds. + // The same value, or the same failure, sync and async. + static FakeExecuteReader MapOf(Type column, object? value) + => new(() => TypedReader.Create( + [("v", column)], + [value])); + + static async Task SameAsMap( + Type column, + object? value, + bool? fails = null) + { + T? expected = default; + ValueMappingException? mapFailure = null; + try + { + expected = MapOf(column, value).Map().ToList()[0]; + } + catch (ValueMappingException ex) + { + mapFailure = ex; + } + + if (fails is { } mustFail) + Assert.Equal(mustFail, mapFailure is not null); + + if (mapFailure is null) + { + Assert.Equal(expected, Sync(value).ExecuteScalar()); + Assert.Equal( + expected, + await Async(value).ExecuteScalarAsync(Ct)); + return; + } + + SameFailure( + mapFailure, + Assert.Throws( + () => Sync(value).ExecuteScalar())); + SameFailure( + mapFailure, + await Assert.ThrowsAsync( + async () => await Async(value).ExecuteScalarAsync(Ct))); + } + + static void SameFailure( + ValueMappingException map, + ValueMappingException scalar) + { + Assert.Equal(map.InnerException?.GetType(), scalar.InnerException?.GetType()); + Assert.Equal(map.InnerException?.Message, scalar.InnerException?.Message); + Assert.Equal(map.TargetType, scalar.TargetType); + Assert.Equal(map.PropertyName, scalar.PropertyName); + } + + [Fact] + public static async Task Integers_AreConvertedAsMapConvertsThem() + { + await SameAsMap(typeof(long), 5L, fails: false); + await SameAsMap(typeof(long), int.MaxValue + 0L, fails: false); + await SameAsMap(typeof(long), int.MaxValue + 1L, fails: true); + await SameAsMap(typeof(long), int.MinValue - 1L, fails: true); + await SameAsMap(typeof(long), 5L, fails: false); + await SameAsMap(typeof(int), 5, fails: false); + await SameAsMap(typeof(int), 70_000, fails: true); + await SameAsMap(typeof(int), -1, fails: true); + await SameAsMap(typeof(int), -1, fails: true); + await SameAsMap(typeof(long), -1L, fails: true); + await SameAsMap(typeof(short), (short)5, fails: false); + await SameAsMap(typeof(byte), (byte)5, fails: false); + await SameAsMap(typeof(byte), (byte)200, fails: true); + await SameAsMap(typeof(ulong), 5ul, fails: false); // no typed getter + await SameAsMap(typeof(ulong), ulong.MaxValue, fails: true); + await SameAsMap(typeof(sbyte), (sbyte)-5, fails: false); + await SameAsMap(typeof(long), 5L, fails: false); + await SameAsMap(typeof(long), 5_000_000_000L, fails: true); + await SameAsMap(typeof(int), 5, fails: false); + await SameAsMap(typeof(int), 5, fails: false); + } + + [Fact] + public static async Task Fractions_AreConvertedAsMapConvertsThem() + { + await SameAsMap(typeof(long), 5L, fails: false); + await SameAsMap(typeof(int), 5, fails: false); + await SameAsMap(typeof(long), 5L, fails: false); + await SameAsMap(typeof(float), 1.5f, fails: false); + await SameAsMap(typeof(decimal), 1.5m, fails: false); + await SameAsMap(typeof(double), 2.5d, fails: false); + await SameAsMap(typeof(int), 5, fails: false); + await SameAsMap(typeof(decimal), 2.6m, fails: false); + await SameAsMap(typeof(decimal), 2.6m, fails: false); + await SameAsMap(typeof(decimal), 42m, fails: false); + await SameAsMap(typeof(decimal), 5_000_000_000m, fails: true); + await SameAsMap(typeof(double), 2.6d, fails: false); + await SameAsMap(typeof(double), double.MaxValue, fails: true); + } + + [Fact] + public static async Task TextDatesAndEnums_AreConvertedAsMapConvertsThem() + { + var guid = Guid.NewGuid(); + var moment = new DateTime(2026, 11, 9, 8, 7, 6, DateTimeKind.Utc); + + await SameAsMap(typeof(int), 5, fails: false); + await SameAsMap(typeof(long), 5L, fails: false); + await SameAsMap(typeof(Guid), guid, fails: false); + await SameAsMap(typeof(DateTime), moment, fails: false); + await SameAsMap(typeof(byte[]), new byte[] { 1 }, fails: true); + await SameAsMap(typeof(string), "42", fails: false); + await SameAsMap(typeof(string), "abc", fails: true); + await SameAsMap(typeof(string), guid.ToString(), fails: false); + await SameAsMap(typeof(string), "x", fails: true); + await SameAsMap(typeof(byte[]), guid.ToByteArray(), fails: false); + await SameAsMap(typeof(int), 5, fails: true); + await SameAsMap(typeof(Guid), guid, fails: true); + await SameAsMap(typeof(DateTime), moment, fails: false); + await SameAsMap(typeof(DateTime), moment, fails: false); + await SameAsMap(typeof(DateTime), moment, fails: false); + await SameAsMap(typeof(TimeSpan), TimeSpan.FromHours(1), fails: false); + await SameAsMap(typeof(int), 5, fails: true); + await SameAsMap(typeof(int), 1, fails: false); + await SameAsMap(typeof(bool), true, fails: false); + await SameAsMap(typeof(char), 'x', fails: false); + await SameAsMap(typeof(byte[]), new byte[] { 1, 2 }, fails: false); + await SameAsMap(typeof(int), 2, fails: false); + await SameAsMap(typeof(int), 5, fails: false); + await SameAsMap(typeof(long), 2L, fails: false); + await SameAsMap(typeof(long), 5_000_000_000L); // whatever Map does + await SameAsMap(typeof(string), "two", fails: false); + await SameAsMap(typeof(string), "nope", fails: true); + await SameAsMap(typeof(byte), (byte)3, fails: false); + await SameAsMap(typeof(int), 300); // whatever Map does + await SameAsMap(typeof(int), 2, fails: false); + } + + // ---- the call ---- + + [Fact] + public static async Task ACancelledToken_CancelsTheCall() + { + using CancellationTokenSource cancelled = new(); + await cancelled.CancelAsync(); + + await Assert.ThrowsAnyAsync( + async () => await Async(5L) + .ExecuteScalarAsync(cancelled.Token)); + + // The command's own token cancels it too. + await Assert.ThrowsAnyAsync( + async () => await Async(5L) + .UseCancellationToken(cancelled.Token) + .ExecuteScalarAsync(Ct)); + + // A live token changes nothing. + Assert.Equal( + 5, + await Async(5L) + .UseCancellationToken(Ct) + .ExecuteScalarAsync(Ct)); + } + + // The value is converted once the command has released the connection: + // a conversion that fails leaves the connection as the call found it. + [Fact] + public static void AFailedConversion_LeavesTheConnectionAsTheCallFoundIt() + { + var probe = new ConnectionProbe(ConnectionState.Closed); + probe.Command.ExecuteScalar().Returns(_ => 5_000_000_000L); + + Assert.Throws( + () => probe.Connection.Command("SELECT 1").ExecuteScalar()); + + Assert.Equal(ConnectionState.Closed, probe.State); + Assert.Contains("Open", probe.Calls); + Assert.Contains("Close", probe.Calls); + Assert.DoesNotContain("DisposeConnection", probe.Calls); + } + + // The transform overloads are not touched: + // they receive the provider's value as it is. + [Fact] + public static async Task TheTransformOverloads_StillReceiveTheRawValue() + { + Assert.Equal( + typeof(long), + Sync(5L).ExecuteScalar(value => value!.GetType())); + Assert.Equal( + typeof(long), + await Async(5L) + .ExecuteScalarAsync(value => value!.GetType(), Ct)); + } +} diff --git a/Tests/Open.Database.Extensions.Core.Tests/ExecuteScalarNullTests.cs b/Tests/Open.Database.Extensions.Core.Tests/ExecuteScalarNullTests.cs index 9de1119..9dea8e0 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/ExecuteScalarNullTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/ExecuteScalarNullTests.cs @@ -5,7 +5,7 @@ namespace Open.Database.Extensions.Tests; // ExecuteScalar() / ExecuteScalarAsync() with a NULL scalar or a query that returned no row (the provider hands back null or DBNull): -// default(T) for every T (0 for int, null for int? and reference types). A value is still the exact cast. +// default(T) for every T (0 for int, null for int? and reference types). A value converts as Map() does (ExecuteScalarConversionTests). // The transform overloads get null for both (never DBNull); the raw ExecuteScalar()/ExecuteScalarAsync() stay raw. [ExcludeFromCodeCoverage] public static class ExecuteScalarNullTests @@ -13,7 +13,7 @@ public static class ExecuteScalarNullTests // What a provider returns for a NULL scalar (DBNull) and for no rows (null). static readonly object?[] NoValue = [null, DBNull.Value]; - static ExpressiveCommand Sync(object? scalar) + internal static ExpressiveCommand Sync(object? scalar) { var probe = new ConnectionProbe(ConnectionState.Open); probe.Command.ExecuteScalar().Returns(_ => scalar); @@ -37,7 +37,7 @@ public override void Close() { } } [SuppressMessage("Roslynator", "RCS1047:Non-asynchronous method name should not end with 'Async'")] - static ExpressiveDbCommand Async(object? scalar) + internal static ExpressiveDbCommand Async(object? scalar) { DbCommand command = Substitute.For(); command.ExecuteScalarAsync(Arg.Any()).Returns(Task.FromResult(scalar)); @@ -63,12 +63,12 @@ public static void ExecuteScalar_NullOrNoRow_ReturnsDefault() } [Fact] - public static void ExecuteScalar_AValue_IsStillTheExactCast() + public static void ExecuteScalar_AValue_IsConverted() { Assert.Equal(42, Sync(42).ExecuteScalar()); Assert.Equal(42, Sync(42).ExecuteScalar()); Assert.Equal("v", Sync("v").ExecuteScalar()); - Assert.Throws(() => Sync(42).ExecuteScalar()); // a cast, not a conversion + Assert.Equal(42L, Sync(42).ExecuteScalar()); // a conversion, not a cast } // ---- async ---- @@ -113,7 +113,7 @@ await Async(none) } [Fact] - public static async Task ExecuteScalarAsync_AValue_IsStillTheExactCast() + public static async Task ExecuteScalarAsync_AValue_IsConverted() { Assert.Equal( 42, @@ -130,8 +130,9 @@ await Async(42) await Async("v") .ExecuteScalarAsync( cancellationToken: TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync( - async () => await Async(42) + Assert.Equal( + 42L, + await Async(42) .ExecuteScalarAsync( cancellationToken: TestContext.Current.CancellationToken)); } diff --git a/docsite/articles/coming-from-dapper.md b/docsite/articles/coming-from-dapper.md index 2498b3a..8796c89 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 => ...)`. For many rows of one column, `Map().ToList()` converts like a property (a `long` into an `int` included), which `ExecuteScalar()` does not. +- **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 converts the value the way `Map()` converts a column: an integer into another integer type with a range check (a `long` into an `int`: SQLite returns `count(*)` as a `long`, and SQL Server a `bigint` identity as one), any other value by the general conversion. A value that does not convert, or does not fit, throws `ValueMappingException`. To convert in your own way, pass a function: `ExecuteScalar(value => ...)`. For many rows of one column, `Map().ToList()` converts the same way. ### A command with no result set @@ -275,7 +275,7 @@ With the extensions, use `Map(selector)` or `ExecuteReader(reader => ...)` for t | `QueryFirstOrDefault`, `QuerySingleOrDefault` | `Map().FirstOrDefault()`, `Map().SingleOrDefault()` | | `QueryFirst`, `QuerySingle` | No throwing form: call `FirstOrDefault()` or `SingleOrDefault()` and check for `null` | | `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 | +| `ExecuteScalar` | `ExecuteScalar()`; it converts the value to `T` as `Map()` does | | `Execute` | `ExecuteNonQuery()` | | `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)) | diff --git a/docsite/articles/expressive-commands.md b/docsite/articles/expressive-commands.md index fe79ed2..2a9816d 100644 --- a/docsite/articles/expressive-commands.md +++ b/docsite/articles/expressive-commands.md @@ -135,12 +135,14 @@ The terminals that take the type read one column as easily as a model; the token ### Which scalar form -- **`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 value as `T`; it converts the way `Map()` does (a `bigint` read as `int`). A `NULL` or no rows gives `default(T)`. +- **`ExecuteScalar(value => ...)`** to convert in your own way. 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). +- **`ToListAsync()`** (or `Map().ToList()`, `ToArrayAsync()`, ...) for many rows of one column: it converts like a property, as `ExecuteScalar()` does. 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. +`ExecuteScalar()` converts the value the provider returns the way `Map()` converts a column. A value that is already a `T` is returned as it is. An integer converts to another integer type with a range check (a `bigint` read as `int`, an `int` read as `long`), and any other value takes the general conversion (a `numeric`, which is a `decimal`, read as `long`). A value that cannot be converted, or does not fit, throws `ValueMappingException` with the conversion's own error inside. To tell a `NULL` from 0, ask for the nullable type: `int?` or `long?`. + +[!code-csharp[](../samples/ExpressiveCommands.cs#scalar_read_as_int)] [!code-csharp[](../samples/ExpressiveCommands.cs#scalar_and_nonquery)] diff --git a/docsite/articles/mapping.md b/docsite/articles/mapping.md index 6c6cb8f..eb1ead6 100644 --- a/docsite/articles/mapping.md +++ b/docsite/articles/mapping.md @@ -131,7 +131,7 @@ The rules: - **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)). +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, converted the same way as the column of `Map()` (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`. diff --git a/docsite/articles/whats-new-11.md b/docsite/articles/whats-new-11.md index 81b9063..c3b42d9 100644 --- a/docsite/articles/whats-new-11.md +++ b/docsite/articles/whats-new-11.md @@ -5,6 +5,10 @@ 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.4 + +- **`ExecuteScalar()` and `ExecuteScalarAsync()` convert the way `Map()` does.** They cast the provider's value exactly, so a `bigint` read as `int`, or a `numeric` read as `long`, threw `InvalidCastException`, which a port from Dapper met only on a real database. Now a value that is already a `T` is returned as it is, an integer converts to another integer type with a range check, and any other value takes the general conversion. `NULL` and no row still give `default(T)`. A value that cannot be converted, or does not fit, throws `ValueMappingException` with the conversion's error inside. **Behavior change:** a call that threw `InvalidCastException` now converts, or throws that exception. `ExecuteReturn()` and `ExecuteReturnAsync()` convert the same way. The overloads that take a `Func` are unchanged. + ## Breaking in rc.4 Rebuild consumers of rc.3. Both changes are source-compatible for every call except the ones noted. diff --git a/docsite/samples/ExpressiveCommands.cs b/docsite/samples/ExpressiveCommands.cs index 7e61b35..8fae89f 100644 --- a/docsite/samples/ExpressiveCommands.cs +++ b/docsite/samples/ExpressiveCommands.cs @@ -70,8 +70,9 @@ public static (long Count, int Updated) ScalarAndNonQuery(IDbConnectionFactory casts the value the provider returns, so T must be that exact type - // (SQLite counts as long; SQL Server counts as int). Use the Func overload to convert instead. + // ExecuteScalar converts the value the provider returns, as Map() does: + // a count can be read as long or as int (SQLite counts as long; SQL Server counts as int). + // Use the Func overload to convert in your own way. long count = connectionFactory .Command("select count(*) from posts where author = @author") .AddParam(author) // name and type inferred from the variable @@ -112,6 +113,22 @@ public static int ConvertedScalar(IDbConnectionFactory connect return count; } + public static async Task ScalarReadAsInt( + IDbConnectionFactory connectionFactory, + CancellationToken cancellationToken) + { + #region scalar_read_as_int + // SQLite returns count(*) as a long (a bigint). + // ExecuteScalarAsync converts it, as Map() does: + // a value that does not fit in an int throws ValueMappingException. + int count = await connectionFactory + .Command("select count(*) from posts") + .ExecuteScalarAsync(cancellationToken); + #endregion + + return count; + } + public static Post[] ConditionalParam(IDbConnectionFactory connectionFactory, int? author) { #region param_if diff --git a/docsite/samples/Program.cs b/docsite/samples/Program.cs index c7124ff..ee1a12c 100644 --- a/docsite/samples/Program.cs +++ b/docsite/samples/Program.cs @@ -42,7 +42,9 @@ void Check(bool condition, string what) (long count, int updated) = ExpressiveCommands.ScalarAndNonQuery(factory); Check(count == 3 && updated == 3, "scalar and non-query"); Check(ExpressiveCommands.ConvertedScalar(factory) == 5, "ExecuteScalar with a conversion"); -Check(Throws(() => factory.Command("select count(*) from posts").ExecuteScalar()), "ExecuteScalar over a SQLite integer is a failed cast"); +Check(await ExpressiveCommands.ScalarReadAsInt(factory, CancellationToken.None) == 5, "ExecuteScalarAsync reads a SQLite bigint as int"); +Check(factory.Command("select count(*) from posts").ExecuteScalar() == 5, "ExecuteScalar over a SQLite integer converts, as Map() does"); +Check(Throws(() => factory.Command("select 5000000000").ExecuteScalar()), "ExecuteScalar over a value that does not fit throws ValueMappingException"); Check(factory.Command("select max(id) from posts where id > 1000").ExecuteScalar() == 0, "ExecuteScalar over NULL is default"); Check(factory.Command("select id from posts where id > 1000").ExecuteScalar() == 0, "ExecuteScalar over no rows is default"); Check(factory.Command("select max(id) from posts where id > 1000").ExecuteScalar() is null, "ExecuteScalar over NULL is null"); From 695e27eadab59a49e39f00f8474dd632b806a504 Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:43:05 -0800 Subject: [PATCH 2/2] ExecuteScalar conversion: the docs and comments say the same code Map() uses, not "the general conversion" or "cannot drift apart" Co-Authored-By: Claude Sonnet 5.5 --- Source/Core/Core/RowMap.cs | 4 ++-- docsite/articles/expressive-commands.md | 2 +- docsite/articles/whats-new-11.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/Core/Core/RowMap.cs b/Source/Core/Core/RowMap.cs index f986baf..caf9a3a 100644 --- a/Source/Core/Core/RowMap.cs +++ b/Source/Core/Core/RowMap.cs @@ -637,7 +637,7 @@ Func Build(Layout layout) // 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. + // Built from the helpers below, which the single-column reader also uses. internal static Func FirstColumnReader(Type columnType) where TReader : class, IDataRecord { @@ -733,7 +733,7 @@ static ValueMappingException FirstColumnError( // which is the type the reader reports for the column. // A failure is the ValueMappingException the column read gives. // Built from the same helpers (TypedGetters, TryConvert, ConvertValue), - // so the two cannot drift apart. + // the same ones the single-column reader uses; the two agree for every provider tested, which the parity matrix checks. internal static T ConvertScalar(object? value) { if (value is null or DBNull) return default!; diff --git a/docsite/articles/expressive-commands.md b/docsite/articles/expressive-commands.md index 2a9816d..a352201 100644 --- a/docsite/articles/expressive-commands.md +++ b/docsite/articles/expressive-commands.md @@ -140,7 +140,7 @@ The terminals that take the type read one column as easily as a model; the token - **`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, as `ExecuteScalar()` does. See [One value per row](mapping.md#one-value-per-row). -`ExecuteScalar()` converts the value the provider returns the way `Map()` converts a column. A value that is already a `T` is returned as it is. An integer converts to another integer type with a range check (a `bigint` read as `int`, an `int` read as `long`), and any other value takes the general conversion (a `numeric`, which is a `decimal`, read as `long`). A value that cannot be converted, or does not fit, throws `ValueMappingException` with the conversion's own error inside. To tell a `NULL` from 0, ask for the nullable type: `int?` or `long?`. +`ExecuteScalar()` converts the value the provider returns the way `Map()` converts a column. A value that is already a `T` is returned as it is. An integer converts to another integer type with a range check (a `bigint` read as `int`, an `int` read as `long`), and any other value is converted by the same code `Map()` uses (a `numeric`, which is a `decimal`, read as `long`). A value that cannot be converted, or does not fit, throws `ValueMappingException` with the conversion's own error inside. To tell a `NULL` from 0, ask for the nullable type: `int?` or `long?`. [!code-csharp[](../samples/ExpressiveCommands.cs#scalar_read_as_int)] diff --git a/docsite/articles/whats-new-11.md b/docsite/articles/whats-new-11.md index c3b42d9..f273382 100644 --- a/docsite/articles/whats-new-11.md +++ b/docsite/articles/whats-new-11.md @@ -7,7 +7,7 @@ Version 11 is a performance release and a tidy-up release. Result mapping is rew ## Changes since rc.4 -- **`ExecuteScalar()` and `ExecuteScalarAsync()` convert the way `Map()` does.** They cast the provider's value exactly, so a `bigint` read as `int`, or a `numeric` read as `long`, threw `InvalidCastException`, which a port from Dapper met only on a real database. Now a value that is already a `T` is returned as it is, an integer converts to another integer type with a range check, and any other value takes the general conversion. `NULL` and no row still give `default(T)`. A value that cannot be converted, or does not fit, throws `ValueMappingException` with the conversion's error inside. **Behavior change:** a call that threw `InvalidCastException` now converts, or throws that exception. `ExecuteReturn()` and `ExecuteReturnAsync()` convert the same way. The overloads that take a `Func` are unchanged. +- **`ExecuteScalar()` and `ExecuteScalarAsync()` convert the way `Map()` does.** They cast the provider's value exactly, so a `bigint` read as `int`, or a `numeric` read as `long`, threw `InvalidCastException`, which a port from Dapper met only on a real database. Now a value that is already a `T` is returned as it is, an integer converts to another integer type with a range check, and any other value is converted by the same code `Map()` uses. `NULL` and no row still give `default(T)`. A value that cannot be converted, or does not fit, throws `ValueMappingException` with the conversion's error inside. **Behavior change:** a call that threw `InvalidCastException` now converts, or throws that exception. `ExecuteReturn()` and `ExecuteReturnAsync()` convert the same way. The overloads that take a `Func` are unchanged. ## Breaking in rc.4