Skip to content

Commit 7d0c4ca

Browse files
authored
Ideas/457 map table directly to constructor for stepargumenttransformers (#488)
* First attempt * Add tests and fix logic error * Prove odd behaviour of GetConstructorMatchingToColumnNames * Refactor handling of instance creation
1 parent c61386f commit 7d0c4ca

File tree

7 files changed

+208
-51
lines changed

7 files changed

+208
-51
lines changed
+4-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
namespace Reqnroll.Assist
22
{
3-
public class InstanceCreationOptions
3+
public class InstanceCreationOptions
44
{
55
public bool VerifyAllColumnsBound { get; set; }
6+
public bool RequireTableToProvideAllConstructorParameters { get; set; }
7+
8+
internal bool AssumeInstanceIsAlreadyCreated { get; set; }
69
}
710
}

Reqnroll/Assist/TEHelpers.cs

+118-47
Original file line numberDiff line numberDiff line change
@@ -13,85 +13,66 @@ internal static class TEHelpers
1313
private static readonly Regex invalidPropertyNameRegex = new Regex(InvalidPropertyNamePattern, RegexOptions.Compiled);
1414
private const string InvalidPropertyNamePattern = @"[^\p{Lu}\p{Ll}\p{Lt}\p{Lm}\p{Lo}\p{Nl}\p{Nd}_]";
1515

16-
internal static T CreateTheInstanceWithTheDefaultConstructor<T>(Table table, InstanceCreationOptions creationOptions)
16+
internal static T CreateInstanceAndInitializeWithValuesFromTheTable<T>(Table table, InstanceCreationOptions creationOptions)
1717
{
1818
var instance = (T)Activator.CreateInstance(typeof(T));
1919
LoadInstanceWithKeyValuePairs(table, instance, creationOptions);
2020
return instance;
2121
}
2222

23-
internal static T CreateTheInstanceWithTheValuesFromTheTable<T>(Table table, InstanceCreationOptions creationOptions)
23+
internal static T ConstructInstanceWithValuesFromTheTable<T>(Table table, InstanceCreationOptions creationOptions)
2424
{
25-
var constructor = GetConstructorMatchingToColumnNames<T>(table);
26-
if (constructor == null)
27-
throw new MissingMethodException($"Unable to find a suitable constructor to create instance of {typeof(T).Name}");
25+
creationOptions ??= new InstanceCreationOptions();
26+
creationOptions.AssumeInstanceIsAlreadyCreated = false;
2827

29-
var membersThatNeedToBeSet = GetMembersThatNeedToBeSet(table, typeof(T));
28+
var (memberHandlers, constructorInfo) = GetMembersThatNeedToBeSet(table, typeof(T), creationOptions);
3029

31-
var constructorParameters = constructor.GetParameters();
32-
var parameterValues = new object[constructorParameters.Length];
30+
VerifyAllColumn(table, creationOptions, memberHandlers.Select(m => m.MemberName));
3331

34-
var members = new List<string>(constructorParameters.Length);
35-
for (var parameterIndex = 0; parameterIndex < constructorParameters.Length; parameterIndex++)
32+
var parameters = constructorInfo.GetParameters();
33+
var parameterValues = new object[parameters.Length];
34+
35+
for (var i = 0; i < parameters.Length; ++i)
3636
{
37-
var parameter = constructorParameters[parameterIndex];
38-
var parameterName = parameter.Name;
39-
var member = (from m in membersThatNeedToBeSet
40-
where string.Equals(m.MemberName, parameterName, StringComparison.OrdinalIgnoreCase)
41-
select m).FirstOrDefault();
42-
if (member != null)
43-
{
44-
members.Add(member.MemberName);
45-
parameterValues[parameterIndex] = member.GetValue();
46-
}
47-
else if (parameter.HasDefaultValue)
48-
parameterValues[parameterIndex] = parameter.DefaultValue;
37+
var parameter = parameters[i];
38+
var matchingHandler = memberHandlers.FirstOrDefault(memberHandler => memberHandler.MatchesParameter(parameter));
39+
parameterValues[i] = matchingHandler?.GetValue() ?? (parameter.HasDefaultValue ? parameter.DefaultValue : null);
40+
41+
memberHandlers.Remove(matchingHandler);
4942
}
5043

51-
VerifyAllColumn(table, creationOptions, members);
52-
return (T)constructor.Invoke(parameterValues);
44+
var instance = (T)constructorInfo.Invoke(parameterValues);
45+
memberHandlers.ForEach(x => x.Setter(instance, x.GetValue()));
46+
47+
return instance;
5348
}
5449

5550
internal static bool ThisTypeHasADefaultConstructor<T>()
5651
{
5752
return typeof(T).GetConstructors().Any(c => c.GetParameters().Length == 0);
5853
}
5954

60-
internal static ConstructorInfo GetConstructorMatchingToColumnNames<T>(Table table)
61-
{
62-
var projectedPropertyNames = from property in typeof(T).GetProperties()
63-
from row in table.Rows
64-
where IsMemberMatchingToColumnName(property, row.Id())
65-
select property.Name;
66-
67-
return (from constructor in typeof(T).GetConstructors()
68-
where !projectedPropertyNames.Except(
69-
from parameter in constructor.GetParameters()
70-
select parameter.Name, StringComparer.OrdinalIgnoreCase).Any()
71-
select constructor).FirstOrDefault();
72-
}
73-
7455
internal static bool IsMemberMatchingToColumnName(MemberInfo member, string columnName)
7556
{
7657
return member.Name.MatchesThisColumnName(columnName)
7758
|| IsMatchingAlias(member, columnName);
7859
}
7960

80-
internal static bool MatchesThisColumnName(this string propertyName, string columnName)
61+
private static bool MatchesThisColumnName(this string propertyName, string columnName)
8162
{
8263
var normalizedColumnName = NormalizePropertyNameToMatchAgainstAColumnName(RemoveAllCharactersThatAreNotValidInAPropertyName(columnName));
8364
var normalizedPropertyName = NormalizePropertyNameToMatchAgainstAColumnName(propertyName);
8465

8566
return normalizedPropertyName.Equals(normalizedColumnName, StringComparison.OrdinalIgnoreCase);
8667
}
8768

88-
internal static string RemoveAllCharactersThatAreNotValidInAPropertyName(string name)
69+
private static string RemoveAllCharactersThatAreNotValidInAPropertyName(string name)
8970
{
9071
//Unicode groups allowed: Lu, Ll, Lt, Lm, Lo, Nl or Nd see https://msdn.microsoft.com/en-us/library/aa664670%28v=vs.71%29.aspx
9172
return invalidPropertyNameRegex.Replace(name, string.Empty);
9273
}
9374

94-
internal static string NormalizePropertyNameToMatchAgainstAColumnName(string name)
75+
private static string NormalizePropertyNameToMatchAgainstAColumnName(string name)
9576
{
9677
// we remove underscores, because they should be equivalent to spaces that were removed too from the column names
9778
// we also ignore accents
@@ -100,8 +81,10 @@ internal static string NormalizePropertyNameToMatchAgainstAColumnName(string nam
10081

10182
internal static void LoadInstanceWithKeyValuePairs(Table table, object instance, InstanceCreationOptions creationOptions)
10283
{
103-
var membersThatNeedToBeSet = GetMembersThatNeedToBeSet(table, instance.GetType());
104-
var memberHandlers = membersThatNeedToBeSet.ToList();
84+
creationOptions ??= new InstanceCreationOptions();
85+
creationOptions.AssumeInstanceIsAlreadyCreated = true;
86+
87+
var (memberHandlers, _) = GetMembersThatNeedToBeSet(table, instance.GetType(), creationOptions);
10588
var memberNames = memberHandlers.Select(h => h.MemberName);
10689

10790
VerifyAllColumn(table, creationOptions, memberNames);
@@ -123,9 +106,13 @@ private static void VerifyAllColumn(Table table, InstanceCreationOptions creatio
123106
}
124107
}
125108

126-
internal static List<MemberHandler> GetMembersThatNeedToBeSet(Table table, Type type)
127-
109+
private static (List<MemberHandler>, ConstructorInfo) GetMembersThatNeedToBeSet(Table table, Type type, InstanceCreationOptions creationOptions)
128110
{
111+
if (creationOptions is null)
112+
{
113+
throw new ArgumentNullException(nameof(creationOptions));
114+
}
115+
129116
var properties = (from property in type.GetProperties()
130117
from row in table.Rows
131118
where TheseTypesMatch(type, property.PropertyType, row)
@@ -174,7 +161,86 @@ where TheseTypesMatch(type, field.FieldType, row)
174161
}
175162
}
176163

177-
return memberHandlers;
164+
if (creationOptions.AssumeInstanceIsAlreadyCreated)
165+
{
166+
return (memberHandlers, null);
167+
}
168+
169+
var constructors = type.GetConstructors()
170+
.Select(
171+
c => new
172+
{
173+
Constructor = c,
174+
Parameters = c.GetParameters()
175+
})
176+
.Where(i => i.Parameters.Length > 0);
177+
178+
if (!creationOptions.RequireTableToProvideAllConstructorParameters)
179+
{
180+
// Prefer constructor with the least parameters that takes all members
181+
var candidateConstructors = constructors.OrderBy(c => c.Parameters.Length);
182+
foreach (var candidate in candidateConstructors)
183+
{
184+
var resolvedMembers = memberHandlers.Where(m => m.AnyParameterMatchesThisMemberHandler(candidate.Parameters)).ToList();
185+
if (resolvedMembers.Count == memberHandlers.Count)
186+
{
187+
return (memberHandlers, candidate.Constructor);
188+
}
189+
}
190+
}
191+
else
192+
{
193+
// Prefer constructor with the most parameters
194+
var candidateConstructors = constructors
195+
.OrderByDescending(i => i.Parameters.Length)
196+
.ToList();
197+
198+
foreach (var candidate in candidateConstructors)
199+
{
200+
var unresolvedParameters = candidate.Parameters.Where(p => p.NoMemberHandlerMatchesThisParameter(memberHandlers)).ToList();
201+
if (unresolvedParameters.Count == 0)
202+
{
203+
return (memberHandlers, candidate.Constructor);
204+
}
205+
206+
var matchingHandlers = (from parameter in unresolvedParameters
207+
from row in table.Rows
208+
where parameter.Name.MatchesThisColumnName(row.Id()) && TheseTypesMatch(type, parameter.ParameterType, row)
209+
select new MemberHandler
210+
{
211+
Type = type,
212+
Row = row,
213+
MemberName = parameter.Name,
214+
PropertyType = parameter.ParameterType,
215+
Setter = (_, _) => throw new InvalidOperationException($"This {nameof(MemberHandler)} is used for a constructor parameter only")
216+
}).ToList();
217+
218+
if (matchingHandlers.Count == unresolvedParameters.Count)
219+
{
220+
// We found the correct constructor candidate
221+
memberHandlers.AddRange(matchingHandlers);
222+
return (memberHandlers, candidate.Constructor);
223+
}
224+
}
225+
}
226+
227+
throw new MissingMethodException($"Unable to find a suitable constructor to create instance of {type}");
228+
}
229+
230+
private static bool MatchesParameter(this MemberHandler memberHandler, ParameterInfo parameter)
231+
{
232+
return memberHandler.MemberName.Equals(parameter.Name, StringComparison.OrdinalIgnoreCase)
233+
&& memberHandler.PropertyType == parameter.ParameterType;
234+
}
235+
236+
private static bool AnyParameterMatchesThisMemberHandler(this MemberHandler memberHandler, ParameterInfo[] parameters)
237+
{
238+
return parameters.Any(memberHandler.MatchesParameter);
239+
}
240+
241+
private static bool NoMemberHandlerMatchesThisParameter(this ParameterInfo parameter, List<MemberHandler> memberHandlers)
242+
{
243+
return !memberHandlers.Any(m => m.MatchesParameter(parameter));
178244
}
179245

180246
private static bool IsMatchingAlias(MemberInfo field, string id)
@@ -199,6 +265,11 @@ internal class MemberHandler
199265
public object GetValue()
200266
{
201267
var valueRetriever = Service.Instance.GetValueRetrieverFor(Row, Type, PropertyType);
268+
if (valueRetriever is null)
269+
{
270+
throw new InvalidOperationException($"Unable to resolve value retriever for member {MemberName}");
271+
}
272+
202273
return valueRetriever.Retrieve(new KeyValuePair<string, string>(Row[0], Row[1]), Type, PropertyType);
203274
}
204275
}
@@ -234,7 +305,7 @@ private static bool TheFirstRowValueIsTheNameOfAProperty(Table table, Type type)
234305
.Any(property => IsMemberMatchingToColumnName(property, firstRowValue));
235306
}
236307

237-
public static bool IsValueTupleType(Type type, bool checkBaseTypes = false)
308+
private static bool IsValueTupleType(Type type, bool checkBaseTypes = false)
238309
{
239310
if (type == null)
240311
throw new ArgumentNullException(nameof(type));

Reqnroll/Assist/TableExtensionMethods.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ public static T CreateInstance<T>(this Table table, InstanceCreationOptions crea
1616
{
1717
var instanceTable = TEHelpers.GetTheProperInstanceTable(table, typeof(T));
1818
return TEHelpers.ThisTypeHasADefaultConstructor<T>()
19-
? TEHelpers.CreateTheInstanceWithTheDefaultConstructor<T>(instanceTable, creationOptions)
20-
: TEHelpers.CreateTheInstanceWithTheValuesFromTheTable<T>(instanceTable, creationOptions);
19+
? TEHelpers.CreateInstanceAndInitializeWithValuesFromTheTable<T>(instanceTable, creationOptions)
20+
: TEHelpers.ConstructInstanceWithValuesFromTheTable<T>(instanceTable, creationOptions);
2121
}
2222

2323
public static T CreateInstance<T>(this Table table, Func<T> methodToCreateTheInstance)

Tests/Reqnroll.RuntimeTests/AssistTests/CreateInstanceHelperMethodTests.cs

+45-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
using Reqnroll.RuntimeTests.AssistTests.ExampleEntities;
88
using Reqnroll.RuntimeTests.AssistTests.TestInfrastructure;
99

10-
1110
namespace Reqnroll.RuntimeTests.AssistTests
1211
{
1312

@@ -48,6 +47,51 @@ public void Create_instance_will_set_values_with_a_vertical_table_and_unbound_co
4847
act.Should().Throw<ColumnCouldNotBeBoundException>();
4948
}
5049

50+
[Fact]
51+
public void Create_instance_will_use_strict_constructor_binding_when_option_use_strict_constructor_binding_is_true()
52+
{
53+
var table = new Table("MessageCreatedAt", "ProductCode", "ProductName", "StartOfSale");
54+
table.AddRow("2025-02-22T13:29:14+01:00", "X0010001B", "Teddy Bear", "2025-03-01");
55+
56+
var options = new InstanceCreationOptions
57+
{
58+
RequireTableToProvideAllConstructorParameters = true
59+
};
60+
61+
var expectedMessage = new ProductCreatedMessage(new DateTimeOffset(2025, 2, 22, 13, 29, 14, TimeSpan.FromHours(1)), "X0010001B", "Teddy Bear", new DateTime(2025, 3,1));
62+
var actualMessage = table.CreateInstance<ProductCreatedMessage>(options);
63+
64+
actualMessage.Should().BeEquivalentTo(expectedMessage);
65+
}
66+
67+
[Fact]
68+
public void Create_instance_will_use_strict_constructor_binding_when_option_use_strict_constructor_binding_is_true_and_throw_when_no_suitable_constructor_is_provided()
69+
{
70+
var table = new Table("MessageCreatedAt", "ProductCode", "ProductName");
71+
table.AddRow("2025-02-22T13:29:14+01:00", "X0010001B", "Teddy Bear");
72+
73+
var options = new InstanceCreationOptions
74+
{
75+
RequireTableToProvideAllConstructorParameters = true
76+
};
77+
78+
Action act = () => table.CreateInstance<ProductCreatedMessage>(options);
79+
80+
act.Should().ThrowExactly<MissingMethodException>().WithMessage($"Unable to find a suitable constructor to create instance of {typeof(ProductCreatedMessage)}");
81+
}
82+
83+
[Fact]
84+
public void Create_instance_without_default_constructor_does_not_throw_if_all_projected_properties_have_matching_constructor_parameters()
85+
{
86+
var table = new Table("MessageCreatedAt", "ProductCode", "ProductName");
87+
table.AddRow("2025-02-22T13:29:14+01:00", "X0010001B", "Teddy Bear");
88+
89+
Action act = () => table.CreateInstance<ProductCreatedMessage>();
90+
91+
// This is odd behaviour! The constructor requires more parameters than the number of matching members. Missing constructor parameters get the default value for their Type.
92+
act.Should().NotThrow<MissingMethodException>();
93+
}
94+
5195
[Fact]
5296
public void When_one_row_exists_with_two_headers_and_the_first_row_value_is_not_a_property_then_treat_as_horizontal_table()
5397
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System;
2+
3+
namespace Reqnroll.RuntimeTests.AssistTests.ExampleEntities;
4+
5+
public abstract class AbstractMessage<TMessageContent>
6+
{
7+
public DateTimeOffset MessageCreatedAt { get; protected init; }
8+
public string MessageContentType { get; } = nameof(TMessageContent);
9+
public TMessageContent MessageContent { get; protected init; }
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using System;
2+
3+
namespace Reqnroll.RuntimeTests.AssistTests.ExampleEntities;
4+
5+
public sealed class ProductCreatedMessage : AbstractMessage<ProductCreatedMessageContent>
6+
{
7+
public ProductCreatedMessage(DateTimeOffset messageCreatedAt, string productCode, string productName, DateTime startOfSale)
8+
{
9+
MessageCreatedAt = messageCreatedAt;
10+
MessageContent = new ProductCreatedMessageContent(productCode, productName, startOfSale);
11+
}
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System;
2+
3+
namespace Reqnroll.RuntimeTests.AssistTests.ExampleEntities;
4+
5+
public sealed class ProductCreatedMessageContent
6+
{
7+
public ProductCreatedMessageContent(string productCode, string productName, DateTime startOfSale)
8+
{
9+
ProductCode = productCode;
10+
ProductName = productName;
11+
StartOfSale = startOfSale;
12+
}
13+
14+
public string ProductCode { get; init; }
15+
public string ProductName { get; init; }
16+
public DateTime StartOfSale { get; init; }
17+
}

0 commit comments

Comments
 (0)