Skip to content
170 changes: 166 additions & 4 deletions Source/Core/Core/RowMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <see langword="null"/> column for a required property,
/// or two members reading one column throw <see cref="ArgumentException"/>.
/// A type that cannot be mapped throws <see cref="InvalidOperationException"/> when the map is constructed or <see cref="Default"/> 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 <see cref="int"/> or <see cref="string"/>, a dictionary or a dynamic object (<c>Dictionary&lt;string, object?&gt;</c>, <see cref="System.Dynamic.ExpandoObject"/>: they have no properties to set, so use <c>Map(selector)</c>), a type with two or more public constructors and none without parameters, a class with no public constructor and a type with a <c>required</c> public field (a field is never mapped, so it cannot be set).
/// an interface, an abstract class, a delegate,
/// a single value such as <see cref="int"/> or <see cref="string"/>
/// (use <c>Map&lt;T&gt;()</c> on a command to read one column),
/// a dictionary or a dynamic object
/// (<c>Dictionary&lt;string, object?&gt;</c>,
/// <see cref="System.Dynamic.ExpandoObject"/>:
/// they have no properties to set, so use <c>Map(selector)</c>),
/// a type with two or more public constructors and none without parameters,
/// a class with no public constructor
/// and a type with a <c>required</c> 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 <see cref="Nullable{T}"/> (an enum counts), or a reference type with nullable annotations on (<c>string</c>, not <c>string?</c>).
/// 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 <see langword="default"/>.
/// Anything else receives <see langword="null"/>: <see cref="Nullable{T}"/>, a reference type declared nullable (<c>string?</c>, or <c>[AllowNull]</c> on a property), a reference type in code with nullable annotations off, and a member declared as a type parameter.
Expand Down Expand Up @@ -622,6 +632,97 @@ Func<TReader, T> Build<TReader>(Layout layout)
cell);
}

// The reader of a single value (FirstColumn<T>):
// 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<TReader, T> FirstColumnReader<TReader>(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<T>).GetMethod(
nameof(FirstColumnError),
BindingFlags.Static | BindingFlags.NonPublic)!,
ex,
r),
typeof(T)))));

return Expression
.Lambda<Func<TReader, T>>(
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 = <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 Expand Up @@ -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<T>() 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<T> : IRowSource<T>
{
internal static readonly FirstColumn<T>? Instance
= Shape.IsScalar(typeof(T)) ? new() : null;

sealed class Entry(Type columnType)
{
public readonly Type ColumnType = columnType;
public Func<DbDataReader, T>? Db;
public Func<IDataRecord, T>? Rec;
}

readonly ConcurrentDictionary<Type, Entry> _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<DbDataReader, T> Mapper(DbDataReader reader)
{
Entry e = Resolve(reader);
return e.Db ??= RowMap<T>.FirstColumnReader<DbDataReader>(e.ColumnType);
}

public Func<IDataRecord, T> Mapper(IDataRecord reader)
{
Entry e = Resolve(reader);
return e.Rec ??= RowMap<T>.FirstColumnReader<IDataRecord>(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<Slot> 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.
Expand All @@ -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<T>): 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)
{
Expand Down Expand Up @@ -1215,20 +1371,26 @@ public void ThrowIfUnmappable()
setsRequiredMembers = false;
string name = TypeName(type);

if (IsScalar(type)) return $"'{name}' is a single value, not a row model. Use ExecuteScalar<T>() or FirstOrdinalResults<T>().";
if (IsScalar(type)) return $"'{name}' is a single value, not a row model. On a command use Map<T>() or ToListAsync<T>(), which read the first column; else FirstOrdinalResults<T>() or ExecuteScalar<T>().";
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).";

return Choose(type, name, out constructor, out setsRequiredMembers) ?? (setsRequiredMembers ? null : RequiredField(type, name));
}

// A single value, not a row: a column of it, not a model with properties. Nullable<T> 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.
Expand Down
132 changes: 132 additions & 0 deletions Source/Core/Extensions/IExecuteReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,138 @@ public static ImmutableArray<T> ToImmutableArray<T>(this IExecuteReader command,
behavior | CommandBehavior.SingleResult);
}

#region Async terminals that take the type: Map<T>() then the terminal

// Only the async forms are on the command:
// a synchronous caller writes command.Map<T>().ToList()
// (the reader-level synchronous helpers are unchanged).
// Each is one expression: no await, no state machine of its own.

/// <summary>
/// Reads every row into a list, asynchronously.
/// Equivalent to <c>command.Map&lt;T&gt;().ToListAsync(cancellationToken)</c>:
/// a single value such as <see cref="int"/> reads the first column;
/// any other <typeparamref name="T"/> maps by column name.
/// </summary>
/// <remarks>
/// A single value (a primitive, an enum, <see cref="string"/>,
/// <see cref="decimal"/>, <see cref="DateTime"/>,
/// <see cref="DateTimeOffset"/>, <see cref="TimeSpan"/>,
/// <see cref="Guid"/>, <c>byte[]</c>, <c>DateOnly</c>,
/// <c>TimeOnly</c> or a <see cref="Nullable{T}"/> 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 <see langword="default"/> for a non-nullable value type
/// (<c>0</c> for <see cref="int"/>)
/// and <see langword="null"/> for <see cref="Nullable{T}"/>,
/// <see cref="string"/> and <c>byte[]</c>;
/// declare <c>int?</c> to tell NULL from 0.
/// A value that cannot be converted throws <see cref="ValueMappingException"/>.
/// Only the asynchronous terminals are on the command;
/// for a synchronous read use <c>command.Map&lt;T&gt;().ToList()</c>.
/// Pass the token or nothing:
/// <c>ToListAsync&lt;int&gt;(default)</c> binds to the obsolete instance method
/// <c>ToListAsync(transform, behavior)</c> of an expressive command,
/// which is the one member that still wins over these extensions.
/// </remarks>
/// <typeparam name="T">The model type to map the rows to, or the single value to read from the first column.</typeparam>
/// <param name="command">The command to read from.</param>
/// <param name="cancellationToken">A token combined with the command's own.</param>
/// <returns>The rows as a list.</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="command"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="InvalidOperationException">
/// <typeparamref name="T"/> cannot be mapped
/// (an interface, an abstract class, ...):
/// thrown at the call, before anything runs.
/// </exception>
/// <seealso cref="CoreExtensions.Map{T}(IExecuteReader)"/>
public static ValueTask<List<T>> ToListAsync<T>(
this IExecuteReader command,
CancellationToken cancellationToken = default)
=> command.Map<T>().ToListAsync(cancellationToken);

/// <summary>
/// Reads every row into an array, asynchronously.
/// Equivalent to <c>command.Map&lt;T&gt;().ToArrayAsync(cancellationToken)</c>.
/// </summary>
/// <inheritdoc cref="ToListAsync{T}(IExecuteReader, CancellationToken)" path="/*[not(self::summary) and not(self::returns) and not(self::param)]"/>
/// <param name="command">The command to read from.</param>
/// <param name="cancellationToken">A token combined with the command's own.</param>
/// <returns>The rows as an array.</returns>
public static ValueTask<T[]> ToArrayAsync<T>(
this IExecuteReader command,
CancellationToken cancellationToken = default)
=> command.Map<T>().ToArrayAsync(cancellationToken);

/// <summary>
/// Reads every row into an immutable array, asynchronously.
/// Equivalent to <c>command.Map&lt;T&gt;().ToImmutableArrayAsync(cancellationToken)</c>.
/// </summary>
/// <inheritdoc cref="ToListAsync{T}(IExecuteReader, CancellationToken)" path="/*[not(self::summary) and not(self::returns) and not(self::param)]"/>
/// <param name="command">The command to read from.</param>
/// <param name="cancellationToken">A token combined with the command's own.</param>
/// <returns>The rows as an immutable array.</returns>
public static ValueTask<ImmutableArray<T>> ToImmutableArrayAsync<T>(
this IExecuteReader command,
CancellationToken cancellationToken = default)
=> command.Map<T>().ToImmutableArrayAsync(cancellationToken);

/// <summary>
/// Reads the first row, or <see langword="default"/> when there is none,
/// asynchronously.
/// Equivalent to <c>command.Map&lt;T&gt;().FirstOrDefaultAsync(cancellationToken)</c>.
/// </summary>
/// <inheritdoc cref="ToListAsync{T}(IExecuteReader, CancellationToken)" path="/*[not(self::summary) and not(self::returns) and not(self::param)]"/>
/// <param name="command">The command to read from.</param>
/// <param name="cancellationToken">A token combined with the command's own.</param>
/// <returns>The first row, or <see langword="default"/>.</returns>
public static ValueTask<T?> FirstOrDefaultAsync<T>(
this IExecuteReader command,
CancellationToken cancellationToken = default)
=> command.Map<T>().FirstOrDefaultAsync(cancellationToken);

/// <summary>
/// Reads the only row, or <see langword="default"/> when there is none,
/// asynchronously.
/// Equivalent to <c>command.Map&lt;T&gt;().SingleOrDefaultAsync(cancellationToken)</c>.
/// </summary>
/// <inheritdoc cref="ToListAsync{T}(IExecuteReader, CancellationToken)" path="/*[not(self::summary) and not(self::returns) and not(self::param)]"/>
/// <param name="command">The command to read from.</param>
/// <param name="cancellationToken">A token combined with the command's own.</param>
/// <returns>The only row, or <see langword="default"/>.</returns>
/// <exception cref="InvalidOperationException">
/// There is more than one row.
/// </exception>
public static ValueTask<T?> SingleOrDefaultAsync<T>(
this IExecuteReader command,
CancellationToken cancellationToken = default)
=> command.Map<T>().SingleOrDefaultAsync(cancellationToken);

/// <summary>
/// Reads every row into a hash set, asynchronously.
/// Equivalent to <c>command.Map&lt;T&gt;().ToHashSetAsync(comparer, cancellationToken)</c>.
/// The comparer comes first, as on <see cref="MappedQuery{T}"/>:
/// pass the token by name,
/// <c>ToHashSetAsync&lt;int&gt;(cancellationToken: ct)</c>.
/// </summary>
/// <inheritdoc cref="ToListAsync{T}(IExecuteReader, CancellationToken)" path="/*[not(self::summary) and not(self::returns) and not(self::param)]"/>
/// <param name="command">The command to read from.</param>
/// <param name="comparer">The comparer of the set, or <see langword="null"/> for the default.</param>
/// <param name="cancellationToken">A token combined with the command's own.</param>
/// <returns>The rows as a hash set.</returns>
public static ValueTask<HashSet<T>> ToHashSetAsync<T>(
this IExecuteReader command,
IEqualityComparer<T>? comparer = null,
CancellationToken cancellationToken = default)
=> command.Map<T>().ToHashSetAsync(comparer, cancellationToken);

#endregion

/// <summary>
/// Iterates all records within the first result set using an IDataReader and returns the results.
/// <see cref="DBNull"/> values are left unchanged (retained).
Expand Down
25 changes: 23 additions & 2 deletions Source/Core/MappedQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,18 @@ public static partial class CoreExtensions
/// <para>
/// A type with a public parameterless constructor is built with <c>new T()</c>, and its public settable properties (<c>init</c> and <c>required</c> 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 <c>required</c> property needs a column. A <typeparamref name="T"/> that cannot be mapped (an interface, an abstract class, a single value such as <see cref="int"/>, a type with two or more public constructors and none without parameters)
/// A <c>required</c> property needs a column.
/// A single value such as <see cref="int"/>
/// (a primitive, an enum, <see cref="string"/>, <see cref="decimal"/>,
/// a date or time, <see cref="Guid"/>, <c>byte[]</c>
/// or a <see cref="Nullable{T}"/> of one)
/// is read from the first column of each row,
/// converted as a property of that type is;
/// a NULL is <c>default</c> for a non-nullable value type,
/// so declare <c>int?</c> to tell NULL from 0.
/// A <typeparamref name="T"/> that cannot be mapped
/// (an interface, an abstract class,
/// a type with two or more public constructors and none without parameters)
/// throws <see cref="InvalidOperationException"/> when the map is built or <see cref="RowMap{T}.Default"/> is read, which the overloads without a <see cref="RowMap{T}"/> do at the call.
/// See <see href="https://open-net-libraries.github.io/Open.Database.Extensions/articles/mapping.html#records-and-constructors">Records and constructors</see>.
/// </para>
Expand Down Expand Up @@ -69,7 +80,17 @@ public static MappedQuery<T> Map<T>(
[OverloadResolutionPriority(4)]
public static MappedQuery<T> Map<T>(
this IExecuteReader command)
=> Map(command, RowMap<T>.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<T>.Instance is { } firstColumn
? new MappedQuery<T>(
new MappedQueryCore<T>(command, firstColumn, 0, -1, false))
: Map(command, RowMap<T>.Default);
}

/// <summary>
/// Locks in a custom projection and returns a deferred <see cref="MappedQuery{T}"/>. Nothing runs until
Expand Down
Loading