Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ideas/457 map table directly to constructor for stepargumenttransformers #488

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Reqnroll/Assist/InstanceCreationOptions.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
namespace Reqnroll.Assist
{
public class InstanceCreationOptions
public class InstanceCreationOptions
{
public bool VerifyAllColumnsBound { get; set; }
public bool RequireTableToProvideAllConstructorParameters { get; set; }

internal bool AssumeInstanceIsAlreadyCreated { get; set; }
}
}
165 changes: 118 additions & 47 deletions Reqnroll/Assist/TEHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,85 +13,66 @@ internal static class TEHelpers
private static readonly Regex invalidPropertyNameRegex = new Regex(InvalidPropertyNamePattern, RegexOptions.Compiled);
private const string InvalidPropertyNamePattern = @"[^\p{Lu}\p{Ll}\p{Lt}\p{Lm}\p{Lo}\p{Nl}\p{Nd}_]";

internal static T CreateTheInstanceWithTheDefaultConstructor<T>(Table table, InstanceCreationOptions creationOptions)
internal static T CreateInstanceAndInitializeWithValuesFromTheTable<T>(Table table, InstanceCreationOptions creationOptions)
{
var instance = (T)Activator.CreateInstance(typeof(T));
LoadInstanceWithKeyValuePairs(table, instance, creationOptions);
return instance;
}

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

var membersThatNeedToBeSet = GetMembersThatNeedToBeSet(table, typeof(T));
var (memberHandlers, constructorInfo) = GetMembersThatNeedToBeSet(table, typeof(T), creationOptions);

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

var members = new List<string>(constructorParameters.Length);
for (var parameterIndex = 0; parameterIndex < constructorParameters.Length; parameterIndex++)
var parameters = constructorInfo.GetParameters();
var parameterValues = new object[parameters.Length];

for (var i = 0; i < parameters.Length; ++i)
{
var parameter = constructorParameters[parameterIndex];
var parameterName = parameter.Name;
var member = (from m in membersThatNeedToBeSet
where string.Equals(m.MemberName, parameterName, StringComparison.OrdinalIgnoreCase)
select m).FirstOrDefault();
if (member != null)
{
members.Add(member.MemberName);
parameterValues[parameterIndex] = member.GetValue();
}
else if (parameter.HasDefaultValue)
parameterValues[parameterIndex] = parameter.DefaultValue;
var parameter = parameters[i];
var matchingHandler = memberHandlers.FirstOrDefault(memberHandler => memberHandler.MatchesParameter(parameter));
parameterValues[i] = matchingHandler?.GetValue() ?? (parameter.HasDefaultValue ? parameter.DefaultValue : null);

memberHandlers.Remove(matchingHandler);
}

VerifyAllColumn(table, creationOptions, members);
return (T)constructor.Invoke(parameterValues);
var instance = (T)constructorInfo.Invoke(parameterValues);
memberHandlers.ForEach(x => x.Setter(instance, x.GetValue()));

return instance;
}

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

internal static ConstructorInfo GetConstructorMatchingToColumnNames<T>(Table table)
{
var projectedPropertyNames = from property in typeof(T).GetProperties()
from row in table.Rows
where IsMemberMatchingToColumnName(property, row.Id())
select property.Name;

return (from constructor in typeof(T).GetConstructors()
where !projectedPropertyNames.Except(
from parameter in constructor.GetParameters()
select parameter.Name, StringComparer.OrdinalIgnoreCase).Any()
select constructor).FirstOrDefault();
}

internal static bool IsMemberMatchingToColumnName(MemberInfo member, string columnName)
{
return member.Name.MatchesThisColumnName(columnName)
|| IsMatchingAlias(member, columnName);
}

internal static bool MatchesThisColumnName(this string propertyName, string columnName)
private static bool MatchesThisColumnName(this string propertyName, string columnName)
{
var normalizedColumnName = NormalizePropertyNameToMatchAgainstAColumnName(RemoveAllCharactersThatAreNotValidInAPropertyName(columnName));
var normalizedPropertyName = NormalizePropertyNameToMatchAgainstAColumnName(propertyName);

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

internal static string RemoveAllCharactersThatAreNotValidInAPropertyName(string name)
private static string RemoveAllCharactersThatAreNotValidInAPropertyName(string name)
{
//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
return invalidPropertyNameRegex.Replace(name, string.Empty);
}

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

internal static void LoadInstanceWithKeyValuePairs(Table table, object instance, InstanceCreationOptions creationOptions)
{
var membersThatNeedToBeSet = GetMembersThatNeedToBeSet(table, instance.GetType());
var memberHandlers = membersThatNeedToBeSet.ToList();
creationOptions ??= new InstanceCreationOptions();
creationOptions.AssumeInstanceIsAlreadyCreated = true;

var (memberHandlers, _) = GetMembersThatNeedToBeSet(table, instance.GetType(), creationOptions);
var memberNames = memberHandlers.Select(h => h.MemberName);

VerifyAllColumn(table, creationOptions, memberNames);
Expand All @@ -123,9 +106,13 @@ private static void VerifyAllColumn(Table table, InstanceCreationOptions creatio
}
}

internal static List<MemberHandler> GetMembersThatNeedToBeSet(Table table, Type type)

private static (List<MemberHandler>, ConstructorInfo) GetMembersThatNeedToBeSet(Table table, Type type, InstanceCreationOptions creationOptions)
{
if (creationOptions is null)
{
throw new ArgumentNullException(nameof(creationOptions));
}

var properties = (from property in type.GetProperties()
from row in table.Rows
where TheseTypesMatch(type, property.PropertyType, row)
Expand Down Expand Up @@ -174,7 +161,86 @@ where TheseTypesMatch(type, field.FieldType, row)
}
}

return memberHandlers;
if (creationOptions.AssumeInstanceIsAlreadyCreated)
{
return (memberHandlers, null);
}

var constructors = type.GetConstructors()
.Select(
c => new
{
Constructor = c,
Parameters = c.GetParameters()
})
.Where(i => i.Parameters.Length > 0);

if (!creationOptions.RequireTableToProvideAllConstructorParameters)
{
// Prefer constructor with the least parameters that takes all members
var candidateConstructors = constructors.OrderBy(c => c.Parameters.Length);
foreach (var candidate in candidateConstructors)
{
var resolvedMembers = memberHandlers.Where(m => m.AnyParameterMatchesThisMemberHandler(candidate.Parameters)).ToList();
if (resolvedMembers.Count == memberHandlers.Count)
{
return (memberHandlers, candidate.Constructor);
}
}
}
else
{
// Prefer constructor with the most parameters
var candidateConstructors = constructors
.OrderByDescending(i => i.Parameters.Length)
.ToList();

foreach (var candidate in candidateConstructors)
{
var unresolvedParameters = candidate.Parameters.Where(p => p.NoMemberHandlerMatchesThisParameter(memberHandlers)).ToList();
if (unresolvedParameters.Count == 0)
{
return (memberHandlers, candidate.Constructor);
}

var matchingHandlers = (from parameter in unresolvedParameters
from row in table.Rows
where parameter.Name.MatchesThisColumnName(row.Id()) && TheseTypesMatch(type, parameter.ParameterType, row)
select new MemberHandler
{
Type = type,
Row = row,
MemberName = parameter.Name,
PropertyType = parameter.ParameterType,
Setter = (_, _) => throw new InvalidOperationException($"This {nameof(MemberHandler)} is used for a constructor parameter only")
}).ToList();

if (matchingHandlers.Count == unresolvedParameters.Count)
{
// We found the correct constructor candidate
memberHandlers.AddRange(matchingHandlers);
return (memberHandlers, candidate.Constructor);
}
}
}

throw new MissingMethodException($"Unable to find a suitable constructor to create instance of {type}");
}

private static bool MatchesParameter(this MemberHandler memberHandler, ParameterInfo parameter)
{
return memberHandler.MemberName.Equals(parameter.Name, StringComparison.OrdinalIgnoreCase)
&& memberHandler.PropertyType == parameter.ParameterType;
}

private static bool AnyParameterMatchesThisMemberHandler(this MemberHandler memberHandler, ParameterInfo[] parameters)
{
return parameters.Any(memberHandler.MatchesParameter);
}

private static bool NoMemberHandlerMatchesThisParameter(this ParameterInfo parameter, List<MemberHandler> memberHandlers)
{
return !memberHandlers.Any(m => m.MatchesParameter(parameter));
}

private static bool IsMatchingAlias(MemberInfo field, string id)
Expand All @@ -199,6 +265,11 @@ internal class MemberHandler
public object GetValue()
{
var valueRetriever = Service.Instance.GetValueRetrieverFor(Row, Type, PropertyType);
if (valueRetriever is null)
{
throw new InvalidOperationException($"Unable to resolve value retriever for member {MemberName}");
}

return valueRetriever.Retrieve(new KeyValuePair<string, string>(Row[0], Row[1]), Type, PropertyType);
}
}
Expand Down Expand Up @@ -234,7 +305,7 @@ private static bool TheFirstRowValueIsTheNameOfAProperty(Table table, Type type)
.Any(property => IsMemberMatchingToColumnName(property, firstRowValue));
}

public static bool IsValueTupleType(Type type, bool checkBaseTypes = false)
private static bool IsValueTupleType(Type type, bool checkBaseTypes = false)
{
if (type == null)
throw new ArgumentNullException(nameof(type));
Expand Down
4 changes: 2 additions & 2 deletions Reqnroll/Assist/TableExtensionMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ public static T CreateInstance<T>(this Table table, InstanceCreationOptions crea
{
var instanceTable = TEHelpers.GetTheProperInstanceTable(table, typeof(T));
return TEHelpers.ThisTypeHasADefaultConstructor<T>()
? TEHelpers.CreateTheInstanceWithTheDefaultConstructor<T>(instanceTable, creationOptions)
: TEHelpers.CreateTheInstanceWithTheValuesFromTheTable<T>(instanceTable, creationOptions);
? TEHelpers.CreateInstanceAndInitializeWithValuesFromTheTable<T>(instanceTable, creationOptions)
: TEHelpers.ConstructInstanceWithValuesFromTheTable<T>(instanceTable, creationOptions);
}

public static T CreateInstance<T>(this Table table, Func<T> methodToCreateTheInstance)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using Reqnroll.RuntimeTests.AssistTests.ExampleEntities;
using Reqnroll.RuntimeTests.AssistTests.TestInfrastructure;


namespace Reqnroll.RuntimeTests.AssistTests
{

Expand Down Expand Up @@ -48,6 +47,51 @@ public void Create_instance_will_set_values_with_a_vertical_table_and_unbound_co
act.Should().Throw<ColumnCouldNotBeBoundException>();
}

[Fact]
public void Create_instance_will_use_strict_constructor_binding_when_option_use_strict_constructor_binding_is_true()
{
var table = new Table("MessageCreatedAt", "ProductCode", "ProductName", "StartOfSale");
table.AddRow("2025-02-22T13:29:14+01:00", "X0010001B", "Teddy Bear", "2025-03-01");

var options = new InstanceCreationOptions
{
RequireTableToProvideAllConstructorParameters = true
};

var expectedMessage = new ProductCreatedMessage(new DateTimeOffset(2025, 2, 22, 13, 29, 14, TimeSpan.FromHours(1)), "X0010001B", "Teddy Bear", new DateTime(2025, 3,1));
var actualMessage = table.CreateInstance<ProductCreatedMessage>(options);

actualMessage.Should().BeEquivalentTo(expectedMessage);
}

[Fact]
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()
{
var table = new Table("MessageCreatedAt", "ProductCode", "ProductName");
table.AddRow("2025-02-22T13:29:14+01:00", "X0010001B", "Teddy Bear");

var options = new InstanceCreationOptions
{
RequireTableToProvideAllConstructorParameters = true
};

Action act = () => table.CreateInstance<ProductCreatedMessage>(options);

act.Should().ThrowExactly<MissingMethodException>().WithMessage($"Unable to find a suitable constructor to create instance of {typeof(ProductCreatedMessage)}");
}

[Fact]
public void Create_instance_without_default_constructor_does_not_throw_if_all_projected_properties_have_matching_constructor_parameters()
{
var table = new Table("MessageCreatedAt", "ProductCode", "ProductName");
table.AddRow("2025-02-22T13:29:14+01:00", "X0010001B", "Teddy Bear");

Action act = () => table.CreateInstance<ProductCreatedMessage>();

// 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.
act.Should().NotThrow<MissingMethodException>();
}

[Fact]
public void When_one_row_exists_with_two_headers_and_the_first_row_value_is_not_a_property_then_treat_as_horizontal_table()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System;

namespace Reqnroll.RuntimeTests.AssistTests.ExampleEntities;

public abstract class AbstractMessage<TMessageContent>
{
public DateTimeOffset MessageCreatedAt { get; protected init; }
public string MessageContentType { get; } = nameof(TMessageContent);
public TMessageContent MessageContent { get; protected init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;

namespace Reqnroll.RuntimeTests.AssistTests.ExampleEntities;

public sealed class ProductCreatedMessage : AbstractMessage<ProductCreatedMessageContent>
{
public ProductCreatedMessage(DateTimeOffset messageCreatedAt, string productCode, string productName, DateTime startOfSale)
{
MessageCreatedAt = messageCreatedAt;
MessageContent = new ProductCreatedMessageContent(productCode, productName, startOfSale);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;

namespace Reqnroll.RuntimeTests.AssistTests.ExampleEntities;

public sealed class ProductCreatedMessageContent
{
public ProductCreatedMessageContent(string productCode, string productName, DateTime startOfSale)
{
ProductCode = productCode;
ProductName = productName;
StartOfSale = startOfSale;
}

public string ProductCode { get; init; }
public string ProductName { get; init; }
public DateTime StartOfSale { get; init; }
}