diff --git a/Source/Core/Core/RowMap.cs b/Source/Core/Core/RowMap.cs index caf9a3a..31a2b0b 100644 --- a/Source/Core/Core/RowMap.cs +++ b/Source/Core/Core/RowMap.cs @@ -38,6 +38,17 @@ namespace Open.Database.Extensions; /// The annotations are read from the assembly the model is declared in, once per model type. /// A map is immutable and thread-safe, and caches its compiled row readers. One built at a call site is rebuilt on every call, so declare it once. /// Declare it with the collection syntax, RowMap<Post> map = [new(nameof(Post.Id), "post_id")];, or with the constructor, from one pair or several; a map enumerates the pairs it was declared with. +/// A column that needs a trim, a parse or a format on its way to a member has a converter: +/// map.Convert<long, string>(nameof(Order.Number), n => n.ToString("D6")) returns a new map (this one is unchanged) +/// in which the member receives what the delegate makes of its column, and every other member still maps by name. +/// The delegate is called inside the compiled row reader, on the value read with the typed getter when the column is a TColumn (nothing is boxed), +/// else read the way Map<int>() reads a single value: an integer converts only within the range of TColumn, else the row throws . +/// A converter is not called for a NULL cell, and the member follows the NULL rules above: +/// for a nullable member, its own value (a property) or its declared default (a constructor parameter) for a non-nullable one. +/// A converter that must see NULL names a as TColumn (int?, DateTime?): it is called with , +/// and what it returns is the member's value, a non-nullable member's included. +/// A reference type never receives null (string? says nothing at run time, where the annotation does not exist), so a converter of text sees only text. +/// A converter is not a pair: a map enumerates the pairs it was declared with and the columns Convert was given, never the delegates. /// /// /// @@ -96,6 +107,17 @@ public RowMap(IEnumerable>? mappings) : this(GetMa _ => mappings.ToArray() }; + // What Convert makes: the validated pairs of the map it was called on, and the converters so far with one more. + RowMap( + string?[] columns, + bool[] named, + Converter?[] converters) + { + _columns = columns; + _named = named; + _converters = converters; + } + /// Returns when is empty; otherwise creates a new map from them, exactly as the constructor does. /// The (property name, column name) pairs; a column means "do not map this property". /// The default map, or a new map built from . @@ -114,6 +136,125 @@ public static RowMap From(ReadOnlySpan> mapping IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable>)this).GetEnumerator(); + /// + /// Returns a new map in which receives + /// what makes of its column, + /// for a column that needs a trim, a parse or a format on its way to the member. + /// Every other member maps as it did, and this map is unchanged. + /// + /// + /// The column is the one the map already names for the member (a pair), + /// else the one the member matches by name + /// (exactly, ignoring case, then ignoring case and underscores). + /// To name the column here, use + /// . + /// The delegate is not called for a NULL cell, + /// unless is a + /// (see ). + /// A lambda does not say what the column is read as: + /// name the types, Convert<long, string>(...), + /// or type the parameter of the lambda. + /// + /// The type the column is read as and the delegate receives: the column's own type, else any type a single value can be read as (Map<TColumn>()); an integer converts only within its range. + /// The type the delegate returns: the member's type, or one assignable to it. + /// The name of the property (exactly), or of the constructor parameter (ignoring case), that receives the converted value. + /// Makes the value of the member of the value of the column. It runs once per row, inside the compiled row reader. + /// The new map. + /// or is . + /// The member is unknown or unsettable, has a converter already, is mapped to (it reads no column), or cannot be assigned to it. + public RowMap Convert( + string member, + Func convert) + { + if (member is null) throw new ArgumentNullException(nameof(member)); + if (convert is null) throw new ArgumentNullException(nameof(convert)); + Contract.EndContractBlock(); + + return Converting(member, null, convert); + } + + /// + /// Returns a new map in which receives + /// what makes of : + /// a pair (new(member, column)) with a converter. + /// Every other member maps as it did, and this map is unchanged. + /// + /// + /// Like a pair, it is matched exactly, else ignoring case. + /// A member the map has a pair for is converted without naming its column: + /// . + /// + /// The type the column is read as and the delegate receives: the column's own type, else any type a single value can be read as (Map<TColumn>()); an integer converts only within its range. + /// The type the delegate returns: the member's type, or one assignable to it. + /// The name of the property (exactly), or of the constructor parameter (ignoring case), that receives the converted value. + /// The name of the column the member reads. + /// Makes the value of the member of the value of the column. It runs once per row, inside the compiled row reader. + /// The new map. + /// , or is . + /// The member is unknown or unsettable, is mapped by a pair or has a converter already, is empty or is read by another member, or cannot be assigned to the member. + public RowMap Convert( + string member, + string column, + Func convert) + { + if (member is null) throw new ArgumentNullException(nameof(member)); + if (column is null) throw new ArgumentNullException(nameof(column)); + if (convert is null) throw new ArgumentNullException(nameof(convert)); + Contract.EndContractBlock(); + + return Converting(member, column, convert); + } + + // Both forms. A map is immutable: the new one has copies of the pairs, with the column (when the call names it) and the converter of the member. + RowMap Converting( + string member, + string? column, + Func convert) + { + if (!TryFindSlot(member, out int index)) + throw new ArgumentException(UnknownMember(member), nameof(member)); + + Slot slot = Slots[index]; + if (_converters?[index] is not null) + throw new ArgumentException($"The {Describe(slot)} has a converter already.", nameof(member)); + + if (!slot.Type.IsAssignableFrom(typeof(TMember))) + { + throw new ArgumentException( + $"The converter of the {Describe(slot)} returns {Shape.TypeName(typeof(TMember))}, which cannot be assigned to {Shape.TypeName(slot.Type)}.", + nameof(convert)); + } + + string?[] columns = [.. _columns]; + bool[] named = [.. _named]; + if (column is null) + { + if (columns[index] is null) + throw new ArgumentException($"The {Describe(slot)} is mapped to null, so it reads no column to convert.", nameof(member)); + } + else + { + if (column.Length == 0) + throw new ArgumentException($"The {Describe(slot)} is converted from an empty column name.", nameof(column)); + + if (named[index]) + throw new ArgumentException($"The {Describe(slot)} is mapped more than once: a pair names its column already.", nameof(member)); + + columns[index] = column; + named[index] = true; + RejectSharedColumns(columns, named, nameof(column)); + } + + Converter?[] converters = _converters is null ? new Converter?[Slots.Length] : [.. _converters]; + converters[index] = new Converter( + convert, + typeof(Func), + typeof(TColumn), + typeof(TMember)); + + return new RowMap(columns, named, converters); + } + // The validated column table and the compiled, cached, typed row readers (one per result-set layout: column names + column types), // so after the first query of a given shape no reflection, expression building or compilation happens again. // Values are read with the provider's typed getters whenever the column type matches the property type (or converts numerically), so nothing is boxed. @@ -223,6 +364,10 @@ public static RowMap Default // only a property no pair named is eligible for the underscore tier. readonly bool[] _named; + // Aligned with Slots: the converter the value of a slot goes through on its way from its column (Convert), or null for a slot with none. + // The array itself is null for a map nothing was converted in, so such a map pays nothing for it. + readonly Converter?[]? _converters; + /// The names of the properties that are mapped. internal IEnumerable PropertyNames { @@ -381,7 +526,7 @@ static bool[] Declare(ReadOnlySpan> mappings, stri } if (pair.Value is { Length: 0 }) throw new ArgumentException($"The property '{pair.Key}' is mapped to an empty column name. Use null to ignore it.", paramName); - if (!known) throw new ArgumentException($"'{typeof(T)}' has no settable public property{(TypeShape.Constructor is null ? null : " or constructor parameter")} named '{pair.Key}'.", paramName); + if (!known) throw new ArgumentException(UnknownMember(pair.Key), paramName); if (pair.Value is null && Slots[index].Required) throw new ArgumentException($"The property '{Slots[index].Name}' is required, so it cannot be mapped to null. Map it to a column.", paramName); @@ -407,6 +552,9 @@ static bool TryFindSlot(string name, out int index) return false; } + static string UnknownMember(string name) + => $"'{typeof(T)}' has no settable public property{(TypeShape.Constructor is null ? null : " or constructor parameter")} named '{name}'."; + static string Describe(Slot slot) => (slot.IsProperty ? "property '" : "constructor parameter '") + slot.Name + "'"; // Each mapped slot is compared with the slots before it (the first reader of a column is the one that gets it). @@ -594,10 +742,25 @@ ArrayLayout ResolveArray(ImmutableArray names) MethodInfo isNullCell = typeof(RowMap).GetMethod(nameof(IsNullCell), BindingFlags.Static | BindingFlags.NonPublic)!; + // The cell of column i as a slot's type: NULL is its default. + Expression Read(int i, Slot slot) + => Expression.Call(convert.MakeGenericMethod(slot.Type), Expression.ArrayIndex(row, Expression.Constant(i))); + + // Whether that cell is NULL, asked only for a slot that keeps its value on NULL. + Expression? IsNull(int i, Slot slot) + => slot.KeepsValueOnNull ? Expression.Call(isNullCell, Expression.ArrayIndex(row, Expression.Constant(i))) : null; + // A property the type declares non-nullable (a value type, or a reference with nullable annotations on) is skipped when its cell is NULL; a constructor parameter of one gets its fallback. - return Compile(row, layout.Names, (i, slot) - => Expression.Call(convert.MakeGenericMethod(slot.Type), Expression.ArrayIndex(row, Expression.Constant(i))), - (i, slot) => slot.KeepsValueOnNull ? Expression.Call(isNullCell, Expression.ArrayIndex(row, Expression.Constant(i))) : null); + // A slot with a converter reads its column as the converter's type, and is skipped or defaulted by that (see Converted). + return Compile( + row, + layout.Names, + (i, slot) => ConverterOf(slot) is { } converter + ? Converted(converter, slot, Read(i, converter.Column), IsNull(i, converter.Column)) + : Read(i, slot), + (i, slot) => ConverterOf(slot) is { } converter + ? SkipsOnNull(slot, IsNull(i, converter.Column)) + : IsNull(i, slot)); } #endregion @@ -626,12 +789,45 @@ Func Build(Layout layout) // The general path reads a cell once into `cell`, tests it for NULL, and converts that same value. ParameterExpression cell = Expression.Variable(typeof(object), "cell"); - return Compile(r, layout.Names, (i, slot) - => ReadValue(r, Expression.Constant(i), layout.Types[i], slot, readerType, isDbNull, getValue, cell), - (i, slot) => NullTest(r, Expression.Constant(i), layout.Types[i], slot, readerType, isDbNull, getValue, cell), + // What column i gives a slot, and whether its NULL leaves the slot alone, as the type it is read as: + // the member's own, or the one the converter of the slot receives (see Converted). + Expression Read(int i, Slot slot) + => ReadValue(r, Expression.Constant(i), layout.Types[i], slot, readerType, isDbNull, getValue, cell); + + MethodCallExpression? IsNull(int i, Slot slot) + => NullTest(r, Expression.Constant(i), layout.Types[i], slot, readerType, isDbNull, getValue, cell); + + return Compile( + r, + layout.Names, + (i, slot) => ConverterOf(slot) is { } converter + ? Converted(converter, slot, Read(i, converter.Column), IsNull(i, converter.Column)) + : Read(i, slot), + (i, slot) => ConverterOf(slot) is { } converter + ? SkipsOnNull(slot, IsNull(i, converter.Column)) + : IsNull(i, slot), cell); } + // The slot's converter, or null for a slot without one (every slot of a map nothing was converted in). + Converter? ConverterOf(Slot slot) => _converters?[Slots.IndexOf(slot)]; + + // What a slot with a converter reads: the delegate applied to the column read as the delegate's own parameter type. + // The column is a slot of that type (Converter.Column), so it is read, and its NULL judged, as a member of that type is: + // only a Nullable is given NULL (columnIsNull is null then), and what the delegate returns is the value of the member, a non-nullable one's too. + // For anything else the delegate is not called on NULL: the slot is skipped (SkipsOnNull) when the member keeps its value on NULL, + // and is otherwise default, null for a nullable member. + static Expression Converted(Converter converter, Slot slot, Expression column, Expression? columnIsNull) + { + Expression value = converter.Invoke(column, slot.Type); + return columnIsNull is not null && !slot.KeepsValueOnNull + ? Expression.Condition(columnIsNull, Expression.Default(slot.Type), value) + : value; + } + + // The test that leaves a slot with a converter alone (a property keeps its value, a constructor parameter gets its fallback) when the cell is NULL, or null for none. + static Expression? SkipsOnNull(Slot slot, Expression? columnIsNull) => slot.KeepsValueOnNull ? columnIsNull : null; + // 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. @@ -1062,7 +1258,7 @@ static object GetConverted(object value, Type u) if (value is byte[]) throw new InvalidCastException("A byte array cannot be assigned to a string; map it to byte[] or convert it explicitly."); - return Convert.ToString(value, CultureInfo.InvariantCulture)!; + return System.Convert.ToString(value, CultureInfo.InvariantCulture)!; } // The date and time types are not IConvertible, so Convert.ChangeType cannot read them from what a provider returns: a DateTime for a date column, @@ -1112,7 +1308,7 @@ static object GetConverted(object value, Type u) } #endif - return Convert.ChangeType(value, u, CultureInfo.InvariantCulture); + return System.Convert.ChangeType(value, u, CultureInfo.InvariantCulture); } } @@ -1218,12 +1414,15 @@ public Slot(string name, Type type, MethodInfo setter, bool required, bool nonNu // The one value of a single-value model type (FirstColumn): no name, // nothing to set. - public Slot(Type type) + public Slot(Type type) : this(type, false) { } + + // The same, for a value that is not a member (the column a converter is given): a reference type of it is declared non-nullable when told so. + public Slot(Type type, bool nonNullableReference) { Name = ""; Type = type; Position = -1; - KeepsValueOnNull = KeepsOnNull(type, false); + KeepsValueOnNull = KeepsOnNull(type, nonNullableReference); } // A constructor parameter, named after the property of that name when there is one. @@ -1289,6 +1488,28 @@ public static Expression FallbackOf(ParameterInfo parameter) } } +// The converter of one slot: the delegate a caller gave RowMap.Convert, as the compiled row readers call it. +// One per Convert call, shared by every row, layout and thread of its map (the delegate is only called, never changed). +// It does not depend on the model type, so it is not generic. +sealed class Converter( + Delegate function, + Type functionType, + Type column, + Type member) +{ + // The column as a slot of the type the delegate receives, so the readers read it and judge its NULL as they do a member of that type. + // A NULL is a value only for a Nullable: a reference type is declared non-nullable here, as its annotations do not exist at run time (a Func is a Func). + public readonly Slot Column = new(column, true); + + // The call, typed as the delegate was declared (a delegate converted by variance keeps its own type), and converted to the type of the slot when that is not the one the delegate returns. + // The delegate is a constant of the expression tree: it is held once by the compiled row reader, and calling it allocates nothing. + public Expression Invoke(Expression value, Type slotType) + { + Expression call = Expression.Invoke(Expression.Constant(function, functionType), value); + return member == slotType ? call : Expression.Convert(call, slotType); + } +} + // Whether a property or a constructor parameter is a reference type the C# source declares non-nullable, which is when a NULL must not overwrite it (Slot.KeepsValueOnNull). // The compiler records nullable annotations in metadata: NullableAttribute on a member (or a parameter) whose annotation differs from its context, else NullableContextAttribute on the nearest // enclosing method or type that has one. Flag 1 is not-null, 2 is nullable, 0 or none is oblivious (nullable annotations off): only 1 counts. diff --git a/Source/Core/PublicAPI.Unshipped.txt b/Source/Core/PublicAPI.Unshipped.txt index 1b4b04a..bb4b01c 100644 --- a/Source/Core/PublicAPI.Unshipped.txt +++ b/Source/Core/PublicAPI.Unshipped.txt @@ -190,3 +190,5 @@ Open.Database.Extensions.ExpressiveDbCommandBase.RetrieveAsync(System.Collections.Generic.IEnumerable! ordinals, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> Open.Database.Extensions.ExpressiveDbCommandBase.RetrieveAsync(System.Collections.Generic.IEnumerable! columnNames, bool normalizeColumnOrder = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> static Open.Database.Extensions.MappedQueryExtensions.Select(this Open.Database.Extensions.IMappedQuery! query, System.Func! selector) -> Open.Database.Extensions.IMappedQuery! +Open.Database.Extensions.RowMap.Convert(string! member, string! column, System.Func! convert) -> Open.Database.Extensions.RowMap! +Open.Database.Extensions.RowMap.Convert(string! member, System.Func! convert) -> Open.Database.Extensions.RowMap! diff --git a/Tests/Open.Database.Extensions.Core.Tests/RowMapConverterTests.cs b/Tests/Open.Database.Extensions.Core.Tests/RowMapConverterTests.cs new file mode 100644 index 0000000..ce9f621 --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/RowMapConverterTests.cs @@ -0,0 +1,842 @@ +using Open.Database.Extensions.Core; +using System.Data.Common; +using System.Globalization; + +#nullable enable + +namespace Open.Database.Extensions.Tests; + +// A converter on a RowMap pair: a column that needs a trim, a parse or a format on its way to a member +// maps straight through the compiled row reader, and the rest of the type still maps by name. +[ExcludeFromCodeCoverage] +public static class RowMapConverterTests +{ + static CancellationToken Ct => TestContext.Current.CancellationToken; + + static readonly CultureInfo Invariant = CultureInfo.InvariantCulture; + static readonly DateTime Start = new(2024, 5, 6, 14, 5, 0, DateTimeKind.Utc); + + public sealed class Order + { + public int Id { get; set; } + public string? OrderNum { get; set; } + public string? FirstLoad { get; set; } + public string Customer { get; set; } = "unknown"; + public int Quantity { get; set; } = 5; + public int Count { get; set; } = 3; + public string? Note { get; set; } + + // Computed from the others: never mapped. + public string Summary => $"{OrderNum}@{FirstLoad}"; + } + + public sealed record Line(int Id, string Label) + { + public required string Code { get; init; } + public string? Note { get; init; } + } + + public sealed record Point(int? X, int Y = 4); + + public sealed class Measure + { + public int? Weight { get; set; } + public object? Tag { get; set; } + public IEnumerable? Letters { get; set; } + } + + static readonly ImmutableArray Columns = + [ + ("Id", typeof(int)), + ("order_num", typeof(long)), + ("first_load_datetime", typeof(DateTime)), + ("Customer", typeof(string)), + ("Quantity", typeof(string)), + ("Count", typeof(int)), + ("Note", typeof(string)), + ]; + + const int OrderNumAt = 1, CustomerAt = 3, QuantityAt = 4; + + static object?[] Valid() => [7, 42L, Start, " Ann ", "12", 9, "n"]; + + static object?[] With(int column, object? value) + { + object?[] row = Valid(); + row[column] = value; + return row; + } + + // The pair for the one column that cannot match by name. + static readonly RowMap LoadPair = [new(nameof(Order.FirstLoad), "first_load_datetime")]; + + // order_num becomes text, first_load_datetime becomes "HH:mm", the text is trimmed or parsed: no hand-written NULL check anywhere. + static readonly RowMap Full = LoadPair + .Convert(nameof(Order.OrderNum), n => n.ToString("D6", Invariant)) + .Convert(nameof(Order.FirstLoad), t => t.ToString("HH:mm", Invariant)) + .Convert(nameof(Order.Customer), s => s.Trim()) + .Convert(nameof(Order.Quantity), s => int.Parse(s, Invariant)) + .Convert(nameof(Order.Count), c => c ?? -1); + + static FakeExecuteReader Cmd(ImmutableArray columns, params object?[][] rows) + => new(() => TypedReader.Create(columns, rows)); + + static T One(RowMap map, ImmutableArray columns, object?[] row) + => Assert.Single(Cmd(columns, row).Map(map).ToList()); + + static void AssertFull(Order order, int count = 9) + { + Assert.Equal(7, order.Id); + Assert.Equal("000042", order.OrderNum); + Assert.Equal("14:05", order.FirstLoad); + Assert.Equal("Ann", order.Customer); + Assert.Equal(12, order.Quantity); + Assert.Equal(count, order.Count); + Assert.Equal("n", order.Note); + Assert.Equal("000042@14:05", order.Summary); + } + + // ---- the ask: a pair names a column, a member and a conversion; the rest of the type maps by name ---- + + [Fact] + public static void TrimParseAndFormat_MapTheRowStraightThrough() + => AssertFull(One(Full, Columns, Valid())); + + [Fact] + public static void ANamedPair_ConvertsThatColumn_AndTheRestMapsByName() + { + RowMap map = LoadPair.Convert( + nameof(Order.FirstLoad), + t => t.ToString("HH:mm", Invariant)); + + Order order = One(map, Columns, Valid()); + + Assert.Equal("14:05", order.FirstLoad); + Assert.Equal(7, order.Id); + Assert.Equal("42", order.OrderNum); // order_num by name, not converted + Assert.Equal(" Ann ", order.Customer); // not converted + Assert.Equal(12, order.Quantity); + Assert.Equal("n", order.Note); + } + + [Theory] + [InlineData("OrderNum")] + [InlineData("ordernum")] + [InlineData("order_num")] + [InlineData("ORDER_NUM")] + public static void ByNameOnly_NeedsNoPair(string column) + { + RowMap map = RowMap.Default.Convert( + nameof(Order.OrderNum), + n => n.ToString("D6", Invariant)); + ImmutableArray columns = [("Id", typeof(int)), (column, typeof(long))]; + + Order order = One(map, columns, [7, 42L]); + + Assert.Equal(7, order.Id); + Assert.Equal("000042", order.OrderNum); + Assert.Null(order.FirstLoad); + } + + [Fact] + public static void TheColumnForm_IsAPairWithAConverter() + { + RowMap map = RowMap.Default.Convert( + nameof(Order.FirstLoad), + "FIRST_LOAD_DATETIME", + t => t.ToString("HH:mm", Invariant)); + + Order order = One(map, Columns, Valid()); + + Assert.Equal("14:05", order.FirstLoad); + Assert.Equal("42", order.OrderNum); + + KeyValuePair pair = Assert.Single(map); + Assert.Equal(nameof(Order.FirstLoad), pair.Key); + Assert.Equal("FIRST_LOAD_DATETIME", pair.Value); + } + + [Fact] + public static void TheNameForm_DoesNotNameTheMemberInThePairs() + => Assert.Empty(RowMap.Default.Convert( + nameof(Order.OrderNum), + n => n.ToString(Invariant))); + + [Fact] + public static void AFormatThatPads_AndATrim_AndAParse() + { + Order order = One(Full, Columns, With(OrderNumAt, 7L)); + Assert.Equal("000007", order.OrderNum); + + Assert.Equal("Bo", One(Full, Columns, With(CustomerAt, "\t Bo \r\n")).Customer); + Assert.Equal(-3, One(Full, Columns, With(QuantityAt, "-3")).Quantity); + } + + [Fact] + public static void AValueTheConverterCannotParse_NamesThePropertyAndColumn() + { + var ex = Assert.Throws( + () => One(Full, Columns, With(QuantityAt, "twelve"))); + + Assert.Equal(nameof(Order.Quantity), ex.PropertyName); + Assert.Equal("Quantity", ex.ColumnName); + Assert.Equal(typeof(int), ex.TargetType); + Assert.IsType(ex.InnerException); + } + + // ---- NULL ---- + + [Fact] + public static void ANullCell_DoesNotCallTheConverter_AndFollowsTheNullRules() + { + int calls = 0; + RowMap map = LoadPair + .Convert(nameof(Order.OrderNum), _ => { calls++; return "x"; }) + .Convert(nameof(Order.FirstLoad), _ => { calls++; return "x"; }) + .Convert(nameof(Order.Customer), _ => { calls++; return "x"; }) + .Convert(nameof(Order.Quantity), _ => { calls++; return 1; }); + + Order order = One(map, Columns, [7, null, null, null, null, 9, null]); + + Assert.Equal(0, calls); + Assert.Null(order.OrderNum); // a nullable member: null + Assert.Null(order.FirstLoad); + Assert.Equal("unknown", order.Customer); // a non-nullable member keeps its initializer + Assert.Equal(5, order.Quantity); + Assert.Equal(9, order.Count); + } + + [Fact] + public static void ANullableTColumn_ReceivesNull_AndWhatItReturnsIsTheValue() + { + RowMap map = LoadPair + .Convert(nameof(Order.OrderNum), n => n is null ? "none" : "n" + n) + .Convert(nameof(Order.FirstLoad), t => t is null ? "-" : t.Value.ToString("HH:mm", Invariant)) + .Convert(nameof(Order.Count), c => c ?? -1); + + Order nulls = One(map, Columns, [7, null, null, "Ann", "12", null, null]); + Assert.Equal("none", nulls.OrderNum); + Assert.Equal("-", nulls.FirstLoad); + Assert.Equal(-1, nulls.Count); // a non-nullable member, set by the converter from NULL + + Order values = One(map, Columns, Valid()); + Assert.Equal("n42", values.OrderNum); + Assert.Equal("14:05", values.FirstLoad); + Assert.Equal(9, values.Count); + } + + [Fact] + public static void AReferenceTColumn_IsNeverGivenNull_WhateverItsAnnotation() + { + int calls = 0; + RowMap map = RowMap.Default + .Convert(nameof(Order.Customer), s => { calls++; return s ?? "null"; }) + .Convert(nameof(Order.Note), s => { calls++; return s ?? "null"; }); + + Order order = One(map, Columns, [7, 42L, Start, null, "12", 9, null]); + + Assert.Equal(0, calls); + Assert.Equal("unknown", order.Customer); + Assert.Null(order.Note); + } + + [Fact] + public static void ANullableTColumn_OverAColumnOfAnotherType_ConvertsAndReceivesNull() + { + RowMap map = RowMap.Default.Convert(nameof(Order.Count), c => c ?? -1); + ImmutableArray columns = [("Count", typeof(long))]; + + Assert.Equal(-1, One(map, columns, [null]).Count); + Assert.Equal(5, One(map, columns, [5L]).Count); + Assert.Throws(() => One(map, columns, [long.MaxValue])); + } + + // ---- a column of another type than TColumn ---- + + [Fact] + public static void ABigintColumn_AnIntConverter_ConvertsAndAnOverflowThrows() + { + RowMap map = RowMap.Default.Convert( + nameof(Order.OrderNum), + n => "#" + n.ToString(Invariant)); + + Assert.Equal("#42", One(map, Columns, Valid()).OrderNum); + + var ex = Assert.Throws( + () => One(map, Columns, With(OrderNumAt, long.MaxValue))); + Assert.IsType(ex.InnerException); + Assert.Equal(nameof(Order.OrderNum), ex.PropertyName); + Assert.Equal("order_num", ex.ColumnName); + } + + [Fact] + public static void ATextColumn_IsReadAsTColumn_ByTheGeneralConversion() + { + ImmutableArray columns = [("Id", typeof(int)), ("order_num", typeof(string))]; + RowMap map = RowMap.Default.Convert( + nameof(Order.OrderNum), + n => n.ToString("D6", Invariant)); + + Assert.Equal("000042", One(map, columns, [7, "42"]).OrderNum); + Assert.Null(One(map, columns, [7, null]).OrderNum); + Assert.Throws(() => One(map, columns, [7, "abc"])); + } + + [Fact] + public static void ADecimalColumn_IsWidenedToADoubleTColumn() + { + RowMap map = RowMap.Default.Convert( + nameof(Order.OrderNum), + d => d.ToString("0.0", Invariant)); + ImmutableArray columns = [("order_num", typeof(decimal))]; + + Assert.Equal("2.5", One(map, columns, [2.5m]).OrderNum); + } + + sealed class CountingReader(ImmutableArray columns, object?[][] rows) : TypedReader(columns, rows) + { + public int GetValueCalls { get; private set; } + + public override object GetValue(int ordinal) + { + GetValueCalls++; + return base.GetValue(ordinal); + } + } + + [Fact] + public static void AGeneralPathCell_IsReadOnce_ForTheNullTestAndTheConversion() + { + ImmutableArray columns = [("order_num", typeof(string))]; + RowMap map = RowMap.Default.Convert( + nameof(Order.OrderNum), + n => n.ToString("D6", Invariant)); + + CountingReader? reader = null; + var command = new FakeExecuteReader(() => reader = new CountingReader(columns, [["1"], ["2"], [null]])); + + List orders = command.Map(map).ToList(); + + Assert.Equal(new string?[] { "000001", "000002", null }, orders.Select(o => o.OrderNum)); + Assert.Equal(3, reader!.GetValueCalls); + } + + // ---- constructor parameters, required and init members ---- + + static readonly ImmutableArray LineColumns = + [ + ("Id", typeof(int)), + ("Label", typeof(string)), + ("Code", typeof(string)), + ("Note", typeof(string)), + ]; + + static readonly RowMap LineMap = RowMap.Default + .Convert("label", s => s.Trim().ToUpperInvariant()) // the constructor parameter, by the name it declares + .Convert(nameof(Line.Code), s => s.Trim()); // a required init property + + [Fact] + public static void AConstructorParameter_AndARequiredInitMember_AreConverted() + { + Line line = One(LineMap, LineColumns, [1, " ab ", " 7 ", "n"]); + + Assert.Equal(1, line.Id); + Assert.Equal("AB", line.Label); + Assert.Equal("7", line.Code); + Assert.Equal("n", line.Note); + } + + [Fact] + public static void ANullIntoAConstructorParameter_GetsItsDefault_NotTheConverter() + { + int calls = 0; + RowMap map = RowMap.Default + .Convert(nameof(Line.Label), _ => { calls++; return "x"; }) + .Convert(nameof(Line.Code), _ => { calls++; return "x"; }); + + Line line = One(map, LineColumns, [1, null, null, null]); + + Assert.Equal(0, calls); + Assert.Null(line.Label); + Assert.Null(line.Code); + } + + [Fact] + public static void ARequiredMemberWithAConverter_StillNeedsItsColumn() + { + ImmutableArray columns = [("Id", typeof(int)), ("Label", typeof(string))]; + + var ex = Assert.Throws(() => One(LineMap, columns, [1, "a"])); + + Assert.Equal(nameof(Line.Code), ex.PropertyName); + } + + [Fact] + public static void AConstructorParameter_TakesATMemberAssignableToIt() + { + RowMap map = RowMap.Default + .Convert(nameof(Point.X), s => int.Parse(s, Invariant)) // int into int? + .Convert(nameof(Point.Y), s => int.Parse(s, Invariant)); + ImmutableArray columns = [("X", typeof(string)), ("Y", typeof(string))]; + + Point point = One(map, columns, ["12", "3"]); + Assert.Equal(12, point.X); + Assert.Equal(3, point.Y); + + // NULL: a nullable parameter gets null, a non-nullable one its declared default; the converter is not called. + Point nulls = One(map, columns, [null, null]); + Assert.Null(nulls.X); + Assert.Equal(4, nulls.Y); + } + + [Fact] + public static void ATMemberAssignableToTheMember_IsAccepted() + { + ImmutableArray columns = [("Weight", typeof(string)), ("Tag", typeof(string)), ("Letters", typeof(string))]; + RowMap map = RowMap.Default + .Convert(nameof(Measure.Weight), s => int.Parse(s, Invariant)) // int into int? + .Convert(nameof(Measure.Tag), s => s) // string into object? + .Convert(nameof(Measure.Letters), s => s); // string into IEnumerable? + + Measure measure = One(map, columns, ["12", "t", "abc"]); + Assert.Equal(12, measure.Weight); + Assert.Equal("t", measure.Tag); + Assert.Equal("abc", string.Concat(measure.Letters!)); + + Measure nulls = One(map, columns, [null, null, null]); + Assert.Null(nulls.Weight); + Assert.Null(nulls.Tag); + Assert.Null(nulls.Letters); + } + + [Fact] + public static void ADelegateConvertedByVariance_IsCalledAsDeclared() + { + ImmutableArray columns = [("Tag", typeof(string))]; + static string Describe(object o) => "<" + o + ">"; + // Takes any object, returns a string: usable as a Func by variance. + Func describe = Describe; + static string Same(string s) => s + "!"; + Func same = Same; + + RowMap map = RowMap.Default + .Convert(nameof(Measure.Tag), describe); + Assert.Equal("", One(map, columns, ["t"]).Tag); + + map = RowMap.Default.Convert(nameof(Measure.Tag), same); + Assert.Equal("t!", One(map, columns, ["t"]).Tag); + } + + // ---- validation, at Convert ---- + + static ArgumentException Rejects(Func> convert) + => Assert.Throws(() => convert()); + + static readonly Func Same = s => s; + + [Fact] + public static void AnUnknownMember_Throws() + { + ArgumentException ex = Rejects(() => RowMap.Default.Convert("Nope", Same)); + + Assert.Contains("'Nope'", ex.Message); + Assert.Equal("member", ex.ParamName); + } + + [Fact] + public static void AnUnsettableMember_Throws() + { + ArgumentException ex = Rejects(() => RowMap.Default.Convert(nameof(Order.Summary), Same)); + + Assert.Contains("'Summary'", ex.Message); + } + + [Fact] + public static void AMemberWithAConverterAlready_Throws() + { + RowMap once = RowMap.Default.Convert(nameof(Order.Customer), Same); + + ArgumentException ex = Rejects(() => once.Convert(nameof(Order.Customer), s => s.Trim())); + Assert.Contains("'Customer'", ex.Message); + Rejects(() => once.Convert(nameof(Order.Customer), "Customer", s => s.Trim())); + + // the same member by another name (a constructor parameter, ignoring case) + Assert.Throws(() => LineMap.Convert("LABEL", Same)); + } + + [Fact] + public static void ATMemberThatCannotBeAssigned_Throws() + { + ArgumentException ex = Rejects(() => RowMap.Default.Convert(nameof(Order.Id), Same)); + + Assert.Contains("String", ex.Message); + Assert.Contains("Int32", ex.Message); + Assert.Contains("'Id'", ex.Message); + + // int? into int, and int into string, are no better + Rejects(() => RowMap.Default.Convert(nameof(Order.Count), n => n)); + Rejects(() => RowMap.Default.Convert(nameof(Order.Customer), n => n)); + } + + [Fact] + public static void NullArguments_Throw() + { + RowMap map = RowMap.Default; + + Assert.Equal("convert", Assert.Throws(() => map.Convert(nameof(Order.Customer), null!)).ParamName); + Assert.Equal("member", Assert.Throws(() => map.Convert(null!, Same)).ParamName); + Assert.Equal("convert", Assert.Throws(() => map.Convert(nameof(Order.Customer), "Customer", null!)).ParamName); + Assert.Equal("member", Assert.Throws(() => map.Convert(null!, "Customer", Same)).ParamName); + Assert.Equal("column", Assert.Throws(() => map.Convert(nameof(Order.Customer), null!, Same)).ParamName); + } + + [Fact] + public static void AnEmptyColumnName_Throws() + { + ArgumentException ex = Rejects(() => RowMap.Default.Convert(nameof(Order.Customer), "", Same)); + + Assert.Equal("column", ex.ParamName); + Assert.Contains("empty", ex.Message); + } + + [Fact] + public static void AColumnAnotherMemberReads_Throws() + { + // Id reads the column "Id" by name; a pair gives the column to another member. + ArgumentException byName = Rejects(() => RowMap.Default.Convert(nameof(Order.OrderNum), "Id", n => n.ToString(Invariant))); + Assert.Contains("'Id'", byName.Message); + + RowMap pair = [new(nameof(Order.Id), "order_id")]; + Rejects(() => pair.Convert(nameof(Order.OrderNum), "order_id", n => n.ToString(Invariant))); + } + + [Fact] + public static void AMemberAPairNamesAlready_IsConvertedByNameOnly() + { + ArgumentException ex = Rejects(() => LoadPair.Convert(nameof(Order.FirstLoad), "first_load_datetime", t => t.ToString("HH:mm", Invariant))); + Assert.Contains("'FirstLoad'", ex.Message); + + // the first form takes the column from the pair + Assert.Equal("14:05", One( + LoadPair.Convert(nameof(Order.FirstLoad), t => t.ToString("HH:mm", Invariant)), + Columns, + Valid()).FirstLoad); + } + + [Fact] + public static void AMemberMappedToNull_ReadsNoColumnToConvert() + { + RowMap ignored = [new(nameof(Order.Note), null)]; + + ArgumentException ex = Rejects(() => ignored.Convert(nameof(Order.Note), Same)); + + Assert.Contains("'Note'", ex.Message); + } + + [Fact] + public static void AFailedConvert_LeavesTheMapUsable() + { + RowMap map = LoadPair; + Rejects(() => map.Convert("Nope", Same)); + Rejects(() => map.Convert(nameof(Order.Id), Same)); + + Order order = One(map, Columns, Valid()); + Assert.Equal(7, order.Id); + Assert.Equal("42", order.OrderNum); + + // and the same member can still be converted + Assert.Equal("Ann", One(map.Convert(nameof(Order.Customer), s => s.Trim()), Columns, Valid()).Customer); + } + + // ---- immutability ---- + + [Fact] + public static void Convert_ReturnsANewMap_AndTheOriginalIsUnchanged() + { + RowMap original = LoadPair; + RowMap converted = original.Convert(nameof(Order.OrderNum), n => n.ToString("D6", Invariant)); + RowMap more = converted.Convert(nameof(Order.Customer), s => s.Trim()); + + Assert.NotSame(original, converted); + Assert.NotSame(converted, more); + + Order a = One(original, Columns, Valid()); + Order b = One(converted, Columns, Valid()); + Order c = One(more, Columns, Valid()); + Assert.Equal(("42", " Ann "), (a.OrderNum, a.Customer)); + Assert.Equal(("000042", " Ann "), (b.OrderNum, b.Customer)); + Assert.Equal(("000042", "Ann"), (c.OrderNum, c.Customer)); + + // The original can be converted again, differently. + RowMap other = original.Convert(nameof(Order.OrderNum), n => "#" + n); + Assert.Equal("#42", One(other, Columns, Valid()).OrderNum); + Assert.Equal("000042", One(converted, Columns, Valid()).OrderNum); + + // Neither is the shared default ever changed. + Assert.Equal("42", One(RowMap.Default, Columns, Valid()).OrderNum); + Assert.Empty(RowMap.Default); + Assert.Single(original); + } + + [Fact] + public static void EveryWayOfDeclaringAMap_TakesAConverter() + { + static string Pad(long n) => n.ToString("D6", Invariant); + Func pad = Pad; + KeyValuePair note = new(nameof(Order.Note), "Note"); + RowMap declared = [note]; + RowMap[] maps = + [ + RowMap.Default.Convert(nameof(Order.OrderNum), pad), + new RowMap().Convert(nameof(Order.OrderNum), pad), + declared.Convert(nameof(Order.OrderNum), pad), + new RowMap(note).Convert(nameof(Order.OrderNum), pad), + new RowMap(ImmutableArray.Create(note)).Convert(nameof(Order.OrderNum), pad), + new RowMap(new List> { note }).Convert(nameof(Order.OrderNum), pad), + RowMap.From([]).Convert(nameof(Order.OrderNum), pad), + RowMap.From([note]).Convert(nameof(Order.OrderNum), pad), + ]; + + foreach (RowMap map in maps) + Assert.Equal("000042", One(map, Columns, Valid()).OrderNum); + } + + // ---- layouts and threads ---- + + [Fact] + public static void OneMap_ManyResultSetLayouts() + { + ImmutableArray reordered = + [ + ("Quantity", typeof(string)), + ("order_num", typeof(string)), + ("Id", typeof(long)), + ]; + ImmutableArray few = [("order_num", typeof(long))]; + + for (int round = 0; round < 4; round++) + { + AssertFull(One(Full, Columns, Valid())); + + Order b = One(Full, reordered, ["12", "42", 7L]); + Assert.Equal(("000042", 12, 7), (b.OrderNum, b.Quantity, b.Id)); + Assert.Equal("unknown", b.Customer); + Assert.Null(b.FirstLoad); + Assert.Equal(3, b.Count); + + Order c = One(Full, few, [5L]); + Assert.Equal("000005", c.OrderNum); + Assert.Equal(("unknown", 5, 3), (c.Customer, c.Quantity, c.Count)); + } + } + + [Fact] + public static void OneMap_IsSafeOnManyThreads() + { + ImmutableArray few = [("order_num", typeof(long))]; + + Parallel.For(0, 600, new ParallelOptions { MaxDegreeOfParallelism = 8 }, i => + { + if (i % 2 == 0) + { + AssertFull(One(Full, Columns, Valid())); + } + else + { + Assert.Equal("000005", One(Full, few, [5L]).OrderNum); + } + }); + } + + // ---- the routes a map takes: a reader, a command, a table, buffered rows, a plain data reader ---- + + static DataTable Table(params object?[][] rows) + { + DataTable table = new(); + foreach (ColumnDef column in Columns) table.Columns.Add(column.Name, column.Type); + foreach (object?[] row in rows) table.Rows.Add(row!); + return table; + } + + [Fact] + public static async Task AReaderACommandAndATable_GiveTheSameRows() + { + using (TypedReader reader = TypedReader.Create(Columns, Valid())) + AssertFull(Assert.Single(reader.Map(Full).ToList())); + + AssertFull(Assert.Single(Cmd(Columns, Valid()).Map(Full).ToList())); + AssertFull(Assert.Single(await Cmd(Columns, Valid()).Map(Full).ToListAsync(Ct))); + AssertFull(Cmd(Columns, Valid()).Map(Full).ToArray().Single()); + AssertFull(Cmd(Columns, Valid()).Map(Full).FirstOrDefault()!); + + using DataTable table = Table(Valid()); + AssertFull(Assert.Single(table.Map(Full).ToList())); + AssertFull(Assert.Single(await table.Map(Full).ToListAsync(Ct))); + AssertFull(Assert.Single(table.Map(Full).Skip(0).Take(1).ToList())); + } + + [Fact] + public static void ANullRowThroughATable_FollowsTheNullRules() + { + using DataTable table = Table([7, null, null, null, null, null, null]); + + Order order = Assert.Single(table.Map(Full).ToList()); + + Assert.Null(order.OrderNum); + Assert.Null(order.FirstLoad); + Assert.Equal("unknown", order.Customer); + Assert.Equal(5, order.Quantity); + Assert.Equal(-1, order.Count); // the nullable TColumn was called with null + } + + static List Buffered(RowMap map, object?[] row) + { + string[] names = [.. Columns.Select(c => c.Name)]; + QueryResult> result = new([.. Enumerable.Range(0, names.Length)], [.. names], new Queue([row!])); + #pragma warning disable CS0618 // Obsolete in 11.0, removed in 12: kept covered until then. + return result.DequeueAs(map).ToList(); + #pragma warning restore CS0618 + } + + [Fact] + public static void BufferedRows_ApplyTheConverters_AndTheNullRules() + { + AssertFull(Assert.Single(Buffered(Full, Valid()))); + + int calls = 0; + RowMap map = LoadPair + .Convert(nameof(Order.OrderNum), _ => { calls++; return "x"; }) + .Convert(nameof(Order.Customer), _ => { calls++; return "x"; }) + .Convert(nameof(Order.Count), c => c ?? -1); + + Order order = Assert.Single(Buffered(map, [7, null, null, null, "12", null, null])); + + Assert.Equal(0, calls); + Assert.Null(order.OrderNum); + Assert.Equal("unknown", order.Customer); + Assert.Equal(-1, order.Count); + + var ex = Assert.Throws( + () => Buffered(RowMap.Default.Convert(nameof(Order.OrderNum), n => n.ToString(Invariant)), With(OrderNumAt, long.MaxValue))); + Assert.Equal(nameof(Order.OrderNum), ex.PropertyName); + } + + [Fact] + public static void APlainDataReader_TakesTheSameRoute() + { + string[] names = [.. Columns.Select(c => c.Name)]; + using IDataReader reader = FakeReader.Create(names, Valid()); + + AssertFull(Assert.Single(reader.Map(Full).ToList())); + } + + // ---- exceptions and cancellation ---- + + [Fact] + public static void AConverterThatThrows_IsReportedWithThePropertyAndColumn() + { + RowMap map = RowMap.Default.Convert( + nameof(Order.Customer), + _ => throw new InvalidOperationException("boom")); + + var ex = Assert.Throws(() => One(map, Columns, Valid())); + + Assert.Equal(nameof(Order.Customer), ex.PropertyName); + Assert.Equal("Customer", ex.ColumnName); + Assert.Equal("boom", ex.InnerException!.Message); + } + + [Fact] + public static void ACancellationInAConverter_PropagatesUnwrapped() + { + using CancellationTokenSource cts = new(); + RowMap map = RowMap.Default.Convert( + nameof(Order.Customer), + s => + { + cts.Cancel(); + cts.Token.ThrowIfCancellationRequested(); + return s; + }); + + Assert.ThrowsAny(() => Cmd(Columns, Valid()).Map(map).ToList()); + } + + [Fact] + public static async Task ACancelledToken_StopsTheQuery_WithAConverterMap() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await Cmd(Columns, Valid()).Map(Full).ToListAsync(cts.Token)); + } + + [Fact] + public static async Task NoRows_NeverCallTheConverter() + { + int calls = 0; + RowMap map = RowMap.Default + .Convert(nameof(Order.Customer), s => { calls++; return s; }); + + Assert.Empty(Cmd(Columns).Map(map).ToList()); + Assert.Empty(await Cmd(Columns).Map(map).ToListAsync(Ct)); + Assert.Null(Cmd(Columns).Map(map).FirstOrDefault()); + Assert.Equal(0, calls); + } + + [Fact] + public static void EveryRow_CallsTheConverterOnce() + { + int calls = 0; + RowMap map = RowMap.Default + .Convert(nameof(Order.Customer), s => { calls++; return s; }); + + Assert.Equal(3, Cmd(Columns, Valid(), Valid(), Valid()).Map(map).ToList().Count); + Assert.Equal(3, calls); + } + + // ---- cost: a converter adds nothing per row ---- + + 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; + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(100)] + public static void AConverter_AllocatesNothingBeyondItself_PerRow(int rows) + { + // A DataTableReader is a DbDataReader with typed getters: the typed path runs. + using DataTable table = new(); + table.Columns.Add("Id", typeof(int)); + table.Columns.Add("Customer", typeof(string)); + table.Columns.Add("Count", typeof(long)); + for (int i = 0; i < rows; i++) table.Rows.Add(i, "x", (long)i); + var command = new PlainReaderCommand(() => new DataTableReader(table)); + + RowMap plain = RowMap.Default; + RowMap converted = RowMap.Default + .Convert(nameof(Order.Id), static n => n) + .Convert(nameof(Order.Customer), static s => s) + .Convert(nameof(Order.Count), static n => n); // a bigint column: the checked conversion, then the delegate + + long mine = BytesPerCall(() => command.Map(converted).ToList()); + long theirs = BytesPerCall(() => command.Map(plain).ToList()); + + Assert.True( + mine <= theirs + 16, + $"{rows} rows: {mine} B per call with converters, {theirs} B without (allowed 16 more)."); + } +} diff --git a/docsite/articles/mapping.md b/docsite/articles/mapping.md index fbc4c85..65d31c2 100644 --- a/docsite/articles/mapping.md +++ b/docsite/articles/mapping.md @@ -214,6 +214,27 @@ A value that cannot be mapped (an unconvertible value, an overflow) throws `Valu [!code-csharp[](../samples/Mapping.cs#map_value_error)] +## Converting a column + +Some columns are not the type the model wants: a number the model keeps as text, a timestamp it shows as `HH:mm`, text that needs a trim or a parse. Without a converter you map the row into a type shaped like the table and copy it into the type you want. With one, the map names the member, the column and the conversion, and the rest of the type still maps by name. + +`Convert(member, convert)` returns a new `RowMap` (the one you call it on is unchanged), so it chains. `TColumn` is the type the column is read as, which the delegate receives; `TMember` is what it returns: the member's type, or one assignable to it. It uses the column the map already names for the member (a pair), else the column the member matches by name. `Convert(member, column, convert)` also names the column: a pair with a converter. Declare the map once, like any other: + +[!code-csharp[](../samples/Models.cs#order_model)] + +[!code-csharp[](../samples/Mapping.cs#map_converted)] + +[!code-csharp[](../samples/Mapping.cs#map_converted_use)] + +- **It runs inside the row reader.** The delegate is called once per row, as the value is read, not in a second pass over a row object. When the column's type is `TColumn` the value is read with the provider's typed getter, so nothing is boxed; when it is not, it is read the way `Map()` reads a single value (an integer converts within its range, and an overflow throws `ValueMappingException`). An exception the delegate throws surfaces as a `ValueMappingException` with the original as its inner exception, like any other failure while reading a value; a database exception and a cancellation pass through unchanged, so catch the wrapper, not `FormatException`. The delegate is the only cost. It works for a property, a constructor parameter and a `required` or `init` member (a `required` member still needs its column). +- **A NULL is not converted.** The delegate is not called for a NULL cell, and the member follows [the NULL rules](#null-in-a-non-nullable-column): `null` for a nullable member; for a non-nullable one its own value, or the constructor parameter's default. A trim or a parse needs no NULL check. A converter that must see NULL asks for a nullable value type as `TColumn` (`int?`, `DateTime?`): it is then called with `null`, and what it returns is the value of the member, a non-nullable member included. A nullable reference type does not do this: `string?` says nothing at run time, so a reference type is never null in a converter. + +[!code-csharp[](../samples/Mapping.cs#map_converted_null)] + +- **It is checked when you call `Convert`.** It throws `ArgumentException` for a member that does not exist or cannot be set, one that has a converter already (a second `Convert` replaces nothing), one the map ignores with a `null` column, a `TMember` that cannot be assigned to the member, and a column another member reads; and `ArgumentNullException` for a `null` argument. With a column, the member must not have a pair already: the first form takes the pair's column. +- **Name the types.** A lambda alone does not say what the column is read as: write `Convert(...)`, or give the lambda's parameter a type. +- **Pairs, not delegates, are enumerated.** A map enumerates the pairs it was declared with, and the column of a `Convert` that names one, so `new RowMap(map)` makes a copy without the converters. Call `Convert` on the copy again. + ## Terminals `ToArray`, `ToList`, `ToImmutableArray`, `ToDictionary`, `ToHashSet`, `FirstOrDefault`, `SingleOrDefault` and `Read`, each with an `Async` twin. There is no throwing `First` or `Single`: the `OrDefault` forms return `null` for no rows. Prefer `ToArray` for results you only read: it allocates one exactly sized array, and an empty result is the shared empty array. Use `ToList` when you add to or remove from the result afterwards. diff --git a/docsite/articles/whats-new-11.md b/docsite/articles/whats-new-11.md index 672a867..4debf78 100644 --- a/docsite/articles/whats-new-11.md +++ b/docsite/articles/whats-new-11.md @@ -11,6 +11,7 @@ Version 11 is a performance release and a tidy-up release. Result mapping is rew - `.SingleResult()` can be written before `Map`, so a query reads as behavior first, then shape: `command.SingleResult().Map().ToListAsync(cancellationToken)`. It works on an expressive command (`IExecuteReader`) and on a raw `IDbCommand` or `DbCommand`, with the same `Map` overloads as before, and it is the same as `command.Map().SingleResult()`, which still works. There is none on `IDataReader` or `DataTable`: their views never read through, so there is nothing to opt out of. Nothing changes by default: without it a `Map` terminal still reads through the later result sets. - **`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. - `Select(selector)` on an `IMappedQuery` (what `Map()` returns at every level) is a lazy projection that returns an `IMappedQuery`: building it runs nothing, and each terminal you pick runs the command once and the selector once per row. A data class can return the query and let the caller choose `ToListAsync`, `FirstOrDefaultAsync` or any other terminal. See [Query factories](mapping.md#query-factories-select). +- A `RowMap` takes a converter for a column that needs a trim, a parse or a format on its way to a member: `map.Convert(nameof(Order.OrderNum), n => n.ToString("D6"))`, or `Convert(member, column, convert)` to name the column as well. It returns a new map, and the delegate runs inside the compiled row reader, so a result maps straight into the type you want, with no row type to copy from. A converter is not called for a NULL cell (the member follows the NULL rules); a `Nullable<>` `TColumn` is called with null. See [Converting a column](mapping.md#converting-a-column). ## Breaking in rc.4 diff --git a/docsite/samples/Db.cs b/docsite/samples/Db.cs index 71eb2f0..9fa92fc 100644 --- a/docsite/samples/Db.cs +++ b/docsite/samples/Db.cs @@ -30,6 +30,9 @@ public static void Seed() Factory.Command("drop table if exists people").ExecuteNonQuery(); Factory.Command("create table posts (id integer primary key, author integer not null, title text not null, body text null, views integer not null)").ExecuteNonQuery(); Factory.Command("create table people (person_id integer primary key, first_name text not null, last_name text not null)").ExecuteNonQuery(); + Factory.Command("drop table if exists orders").ExecuteNonQuery(); + Factory.Command("create table orders (id integer primary key, order_num integer not null, first_load_datetime text null, customer text null)").ExecuteNonQuery(); + Factory.Command("insert into orders (id, order_num, first_load_datetime, customer) values (1, 42, '2024-05-06 14:05:00', ' Ada '), (2, 7, null, null)").ExecuteNonQuery(); for (int i = 1; i <= 5; i++) { diff --git a/docsite/samples/Mapping.cs b/docsite/samples/Mapping.cs index f2785b4..c821ffa 100644 --- a/docsite/samples/Mapping.cs +++ b/docsite/samples/Mapping.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Data; +using System.Globalization; using Microsoft.Data.Sqlite; using Open.Database.Extensions; @@ -393,6 +394,30 @@ public static async Task> ByIdAsync(SqliteConnection conne return byId; } + + public static async Task MapConverted( + SqliteConnection connection, + CancellationToken cancellationToken) + { + #region map_converted_use + // order_num (an integer column) becomes text, first_load_datetime (a timestamp) becomes "HH:mm" and customer is trimmed: + // the rows map straight into Order, with no row type to copy from. + Order[] orders = await connection + .Command("select id, order_num, first_load_datetime, customer from orders order by id") + .Map(OrderMaps.OrderMap) + .ToArrayAsync(cancellationToken); + #endregion + + return orders; + } + + public static async Task MapConvertedNull( + SqliteConnection connection, + CancellationToken cancellationToken) + => await connection + .Command("select id, order_num, first_load_datetime, customer from orders order by id") + .Map(OrderMaps.NullAwareOrderMap) + .ToArrayAsync(cancellationToken); } // Declared once and built on first use: a large application only ever builds the maps it actually uses. @@ -418,6 +443,26 @@ public static class PersonMaps #endregion } +public static class OrderMaps +{ + #region map_converted + // Convert(member, convert): the column is the one the member matches by name (order_num feeds OrderNum, ignoring underscores). + // Convert(member, column, convert) names the column too, for first_load_datetime, which matches no property. + // The delegate runs inside the compiled row reader, once per row, and is never called for a NULL cell: that property keeps its initial value. + public static RowMap OrderMap => field ??= RowMap.Default + .Convert(nameof(Order.OrderNum), number => number.ToString("D6", CultureInfo.InvariantCulture)) + .Convert(nameof(Order.FirstLoad), "first_load_datetime", loaded => loaded.ToString("HH:mm", CultureInfo.InvariantCulture)) + .Convert(nameof(Order.Customer), customer => customer.Trim()); + #endregion + + #region map_converted_null + // A converter that must see NULL asks for a nullable TColumn: it is called with null, + // and what it returns is the value of the property. + public static RowMap NullAwareOrderMap => field ??= RowMap.Default + .Convert(nameof(Order.FirstLoad), "first_load_datetime", loaded => loaded?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? "--:--"); + #endregion +} + public static class RecordMaps { #region map_record_declared diff --git a/docsite/samples/Models.cs b/docsite/samples/Models.cs index 5f5aa5a..53045d6 100644 --- a/docsite/samples/Models.cs +++ b/docsite/samples/Models.cs @@ -20,6 +20,20 @@ public class Person public string LastName { get; set; } = ""; } +// order_num is a number, first_load_datetime a timestamp: the model keeps the first as text and shows the second as "HH:mm" (see the converters in OrderMaps). +#region order_model +public class Order +{ + public required int Id { get; init; } + public string OrderNum { get; init; } = ""; + public string FirstLoad { get; init; } = ""; + public string Customer { get; init; } = ""; + + // Computed from the converted members: never mapped. + public string Summary => $"{OrderNum} at {FirstLoad}"; +} +#endregion + #region order_line_model public class OrderLine { diff --git a/docsite/samples/Program.cs b/docsite/samples/Program.cs index be05f0c..78fab3c 100644 --- a/docsite/samples/Program.cs +++ b/docsite/samples/Program.cs @@ -68,6 +68,10 @@ void Check(bool condition, string what) Check(declarationForms.All(form => connection.Command("select person_id, first_name, last_name from people order by person_id").Map(form).ToArray() is [{ Id: 1, FirstName: "Ada", LastName: "Lovelace" }, { Id: 2, FirstName: "Alan" }]), "a RowMap declared with the collection syntax (one pair or several), the constructor (one pair or several), an ImmutableArray or a copy maps that pair and matches the rest by name"); Check(declarationForms[0].Select(pair => pair.Key).SequenceEqual([nameof(Person.Id), nameof(Person.FirstName)]) && declarationForms[0].SequenceEqual(declarationForms[^1]), "a map enumerates the pairs it was declared with, and a copy built from them declares the same pairs"); Check(PersonMaps.PersonNamesOnlyMap.Count() == 3 && PersonMaps.PersonNamesOnlyMap.Single(pair => pair.Value is null).Key == nameof(Person.LastName), "an ignored property is enumerated with a null column"); +Order[] converted = await Mapping.MapConverted(connection, CancellationToken.None); +Check(converted is [{ Id: 1, OrderNum: "000042", FirstLoad: "14:05", Customer: "Ada" }, { Id: 2, OrderNum: "000007", FirstLoad: "", Customer: "" }] && converted[0].Summary == "000042 at 14:05", "converters turn order_num into text, the timestamp into HH:mm and trim the customer, a NULL cell is not converted and keeps the property's initial value, and the rest maps by name"); +Order[] convertedNull = await Mapping.MapConvertedNull(connection, CancellationToken.None); +Check(convertedNull is [{ OrderNum: "42", FirstLoad: "14:05", Customer: " Ada " }, { OrderNum: "7", FirstLoad: "--:--" }], "a converter whose TColumn is nullable is called with null and what it returns is the value of the property; the other columns map without a converter"); Person[] snake = Mapping.MapSnakeCase(connection); Check(snake[0] is { Id: 0, FirstName: "Ada", LastName: "Lovelace" }, "snake_case columns feed PascalCase properties with no map"); Post[] readerView = await Mapping.MapReaderView(connection);