Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion Source/Core/Core/RowMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@ Func<TReader, T> Build<TReader>(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<TReader, T> FirstColumnReader<TReader>(Type columnType)
where TReader : class, IDataRecord
{
Expand Down Expand Up @@ -723,6 +723,68 @@ static ValueMappingException FirstColumnError(
ex);
}

// The scalar of a command (ExecuteScalar<T>, ExecuteReturn<T>):
// a value that has arrived boxed, converted as column 0 of Map<T>() 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<T>() 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),
// 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!;
if (value is T exact) return exact;

try
{
Func<object, T> 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<Type, Func<object, T>> Scalars = new();

// The typed read of Map<T>() (TypedRead and ReadValue)
// applied to an unboxed value;
// a type with no direct conversion to T takes the general path.
static Func<object, T> 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<Func<object, T>>(
underlying is null
? typed
: Expression.Convert(typed, typeof(T)),
input)
.Compile();
}

return static value => ConvertValue<T>(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 = <cell>`. 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).
Expand Down
30 changes: 16 additions & 14 deletions Source/Core/ExpressiveCommandBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1324,11 +1324,14 @@ ValueTask<T> IExecuteReader.ExecuteReaderAsync<T>(Func<IDataReader, ValueTask<T>
/// <summary>
/// Calls ExecuteNonQuery on the underlying command but sets up a return parameter and returns that value.
/// </summary>
/// <typeparam name="T">The type expected.</typeparam>
/// <returns>The value from the return parameter; <see langword="default"/> when it is NULL.</returns>
/// <exception cref="InvalidCastException">The value is not of type <typeparamref name="T"/>.</exception>
/// <typeparam name="T">The type to convert the value to.</typeparam>
/// <returns>
/// The value from the return parameter, converted to <typeparamref name="T"/> as <c>Map&lt;T&gt;()</c> converts a column: a value that is already a <typeparamref name="T"/> is returned as is, an integer is converted to another integer type with a range check, and any other value takes the general conversion.
/// <see langword="default"/> when it is NULL.
/// </returns>
/// <exception cref="ValueMappingException">The value does not convert to <typeparamref name="T"/>, or is out of its range.</exception>
public T ExecuteReturn<T>()
=> CastScalar<T>(ExecuteReturn());
=> RowMap<T>.ConvertScalar(ExecuteReturn());

/// <summary>
/// Calls ExecuteNonQueryAsync on the underlying command but sets up a return parameter and returns that value.
Expand Down Expand Up @@ -1369,7 +1372,7 @@ public T ExecuteReturn<T>()
/// </param>
public async ValueTask<T> ExecuteReturnAsync<T>(
CancellationToken cancellationToken = default)
=> CastScalar<T>(
=> RowMap<T>.ConvertScalar(
await ExecuteReturnAsync(cancellationToken).ConfigureAwait(false));

/// <summary>
Expand All @@ -1387,17 +1390,16 @@ public int ExecuteNonQuery()
=> Execute(command => command.ExecuteScalar());

/// <summary>
/// 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 <c>Map&lt;T&gt;()</c> converts a column.
/// </summary>
/// <typeparam name="T">The type expected.</typeparam>
/// <returns>The value returned from the method; <see langword="default"/> when the result is NULL or there was no row.</returns>
/// <exception cref="InvalidCastException">The value is not of type <typeparamref name="T"/>.</exception>
/// <typeparam name="T">The type to convert the value to.</typeparam>
/// <returns>
/// The value, converted to <typeparamref name="T"/>: a value that is already a <typeparamref name="T"/> is returned as is, an integer is converted to another integer type with a range check (a <see cref="long"/> read as an <see cref="int"/>), and any other value takes the general conversion (a <see cref="decimal"/> read as a <see cref="long"/>).
/// <see langword="default"/> when the result is NULL or there was no row.
/// </returns>
/// <exception cref="ValueMappingException">The value does not convert to <typeparamref name="T"/>, or is out of its range; the conversion's own error is the inner exception.</exception>
public T ExecuteScalar<T>()
=> CastScalar<T>(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<T>(object? value)
=> value is null or DBNull ? default! : (T)value;
=> RowMap<T>.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)
Expand Down
10 changes: 6 additions & 4 deletions Source/Core/ExpressiveDbCommandBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,17 +182,19 @@ await ExecuteScalarAsync(cancellationToken)
}

/// <summary>
/// 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 <c>Map&lt;T&gt;()</c> converts a column.
/// </summary>
/// <inheritdoc cref="ExpressiveCommandBase{TConnection, TCommand, TReader, TDbType, TThis}.ExecuteScalar{T}()"/>
/// <param name="cancellationToken">
/// A token that can cancel the call.
/// It is used together with the command's own token; either one cancels.
/// </param>
public ValueTask<T> ExecuteScalarAsync<T>(
public async ValueTask<T> ExecuteScalarAsync<T>(
CancellationToken cancellationToken = default)
=> ExecuteCombinedAsync(cancellationToken, static async (command, _, token)
=> CastScalar<T>(await command.ExecuteScalarAsync(token).ConfigureAwait(false)));
// Converted after the command has released the connection.
=> RowMap<T>.ConvertScalar(
await ExecuteScalarAsync(cancellationToken)
.ConfigureAwait(false));

/// <summary>
/// Asynchronously executes scalar on the underlying command.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Open.Database.Extensions.Tests;

// ExecuteReturn<T>() / ExecuteReturnAsync<T>() 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<T>(). A value is still the exact cast.
// default(T) for every T, the same rule as ExecuteScalar<T>(). A value converts as Map<T>() does (ExecuteScalarConversionTests).
[ExcludeFromCodeCoverage]
public static class ExecuteReturnNullTests
{
Expand Down Expand Up @@ -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<int>());
Assert.Equal(42, Returning(42).ExecuteReturn<int?>());
Assert.Equal("v", Returning("v").ExecuteReturn<string>());
Assert.Throws<InvalidCastException>(() => Returning(42).ExecuteReturn<long>()); // a cast, not a conversion
Assert.Equal(42L, Returning(42).ExecuteReturn<long>()); // a conversion, not a cast
Assert.Throws<ValueMappingException>(() => Returning(5_000_000_000L).ExecuteReturn<int>()); // a range check, as Map<int>() does
}

// ---- async ----
Expand Down Expand Up @@ -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,
Expand All @@ -91,8 +92,9 @@ await Returning(42)
await Returning("v")
.ExecuteReturnAsync<string>(
cancellationToken: TestContext.Current.CancellationToken));
await Assert.ThrowsAsync<InvalidCastException>(
async () => await Returning(42)
Assert.Equal(
42L,
await Returning(42)
.ExecuteReturnAsync<long>(
cancellationToken: TestContext.Current.CancellationToken));
}
Expand Down
Loading
Loading