diff --git a/src/SuccincT/Functional/CachedConstructorInfo.cs b/src/SuccincT/Functional/CachedConstructorInfo.cs new file mode 100644 index 0000000..2b78eaf --- /dev/null +++ b/src/SuccincT/Functional/CachedConstructorInfo.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace SuccincT.Functional +{ + internal class CachedConstructorInfo + { + public ConstructorInfo Constructor { get; } + public List Parameters { get; } + + public CachedConstructorInfo(ConstructorInfo constructorInfo) + => (Constructor, Parameters) = (constructorInfo, constructorInfo.GetParameters().ToList()); + } +} \ No newline at end of file diff --git a/src/SuccincT/Functional/CachedTypeInfo.cs b/src/SuccincT/Functional/CachedTypeInfo.cs new file mode 100644 index 0000000..498f3e1 --- /dev/null +++ b/src/SuccincT/Functional/CachedTypeInfo.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace SuccincT.Functional +{ + internal class CachedTypeInfo + { + public List CachedPublicConstructors { get; } + public List Properties { get; } + public List ReadOnlyProperties { get; } + public List WriteOnlyProperties { get; } + + public CachedTypeInfo(Type type) + { + var typeInfo = type.GetTypeInfo(); + + var cachedConstructors = + typeInfo.DeclaredConstructors.Select(c => new CachedConstructorInfo(c)).ToList(); + + CachedPublicConstructors = cachedConstructors + .Where(cc => cc.Constructor.IsPublic && !cc.Constructor.IsStatic).ToList(); + + Properties = type.GetRuntimeProperties().ToList(); + ReadOnlyProperties = Properties.Where(p => p.CanRead && !p.CanWrite).ToList(); + WriteOnlyProperties = Properties.Where(p => !p.CanRead && p.CanWrite).ToList(); + } + } +} \ No newline at end of file diff --git a/src/SuccincT/Functional/CopyException.cs b/src/SuccincT/Functional/CopyException.cs new file mode 100644 index 0000000..8b58f1b --- /dev/null +++ b/src/SuccincT/Functional/CopyException.cs @@ -0,0 +1,11 @@ +using System; + +namespace SuccincT.Functional +{ + public sealed class CopyException : Exception + { + public CopyException(string message) : base(message) { } + + public CopyException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/src/SuccincT/Functional/WithExtensions.cs b/src/SuccincT/Functional/WithExtensions.cs new file mode 100644 index 0000000..61adc55 --- /dev/null +++ b/src/SuccincT/Functional/WithExtensions.cs @@ -0,0 +1,331 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using SuccincT.Options; + +namespace SuccincT.Functional +{ + public static class WithExtensions + { + private static readonly Dictionary CachedTypeInfoDetails = + new Dictionary(); + + public static Option TryCopy(this T @object) where T : notnull + { + var cachedTypeInfo = GetCachedTypeInfo(typeof(T)); + + var (hasValue, value) = cachedTypeInfo.CachedPublicConstructors + .OrderByDescending(cc => cc.Parameters.Count) + .TryFirst(); + + if (!hasValue) return Option.None(); + + var sourceReadProperties = cachedTypeInfo.Properties.Except(cachedTypeInfo.WriteOnlyProperties).ToList(); + var constructorParameters = value.Parameters; + + var constructorParameterValues = + GetConstructorParameterValuesForCopy(@object, sourceReadProperties, constructorParameters); + + if (constructorParameterValues.Length != constructorParameters.Count) return Option.None(); + + var newObject = Activator.CreateInstance(typeof(T), constructorParameterValues); + var destWriteProperties = cachedTypeInfo.Properties.Except(cachedTypeInfo.ReadOnlyProperties); + + var propertiesToOverwrite = sourceReadProperties + .Select(p => destWriteProperties.TryFirst(x => p.Name == x.Name)) + .Where(x => x.HasValue) + .Select(x => x.Value); + + foreach (var propertyToOverwrite in propertiesToOverwrite) + { + CopyPropertyValue(@object, propertyToOverwrite, newObject); + } + + return new Option((T)newObject); + } + + public static T Copy(this T @object) where T : notnull + { + var cachedTypeInfo = GetCachedTypeInfo(typeof(T)); + + var (hasValue, value) = cachedTypeInfo.CachedPublicConstructors + .OrderByDescending(cc => cc.Parameters.Count) + .TryFirst(); + + if (!hasValue) + { + throw new CopyException( + $"Type {typeof(T).Name} does not supply a public constructor for use with Copy."); + } + + var sourceReadProperties = cachedTypeInfo.Properties.Except(cachedTypeInfo.WriteOnlyProperties).ToList(); + var constructorParameters = value.Parameters; + + var constructorParameterValues = + GetConstructorParameterValuesForCopy(@object, sourceReadProperties, constructorParameters); + + if (constructorParameterValues.Length != constructorParameters.Count) + { + throw new CopyException( + $"Type {typeof(T).Name} does not supply a suitable constructor for use with Copy, which allows all " + + "non-writable properties to be set via that constructor."); + } + + var newObject = Activator.CreateInstance(typeof(T), constructorParameterValues); + var destWriteProperties = cachedTypeInfo.Properties.Except(cachedTypeInfo.ReadOnlyProperties); + + var propertiesToOverwrite = sourceReadProperties + .Select(p => destWriteProperties.TryFirst(x => p.Name == x.Name)) + .Where(x => x.HasValue) + .Select(x => x.Value); + + foreach (var propertyToOverwrite in propertiesToOverwrite) + { + CopyPropertyValue(@object, propertyToOverwrite, newObject); + } + + return (T)newObject; + } + + public static Option TryWith(this T itemToCopy, TProps propertiesToUpdate) + where T : notnull where TProps : class + { + if (propertiesToUpdate == null) return Option.None(); + + var cachedTypeInfo = GetCachedTypeInfo(typeof(T)); + var sourceReadProperties = cachedTypeInfo.Properties.Except(cachedTypeInfo.WriteOnlyProperties).ToList(); + var updateProperties = typeof(TProps).GetRuntimeProperties().Where(x => x.CanRead).ToList(); + var (hasValue, value) = ConstructorToUseForWith(cachedTypeInfo, updateProperties, sourceReadProperties); + + if (!hasValue) return Option.None(); + + var constructorParameters = value.Parameters; + var constructorParameterValues = MapUpdateValuesToConstructorParameters(itemToCopy, + propertiesToUpdate, + constructorParameters, + updateProperties, + sourceReadProperties); + + var destWriteProperties = cachedTypeInfo.Properties.Except(cachedTypeInfo.ReadOnlyProperties); + var propsToSetFromUpdateData = GetPropertiesToSetFromUpdateData(updateProperties, + constructorParameters, + sourceReadProperties); + + var propsToSetFromSourceObject = GetPropertiesToSetFromSourceObject(sourceReadProperties, + constructorParameters, + propsToSetFromUpdateData, + destWriteProperties); + + try + { + return Option.Some(CreateNewObjectApplyingUpdates(itemToCopy, + propertiesToUpdate, + constructorParameterValues, + propsToSetFromSourceObject, + propsToSetFromUpdateData)); + } + catch (Exception) + { + return Option.None(); + } + } + + public static T With(this T itemToCopy, TProps propertiesToUpdate) + where T : notnull where TProps : class + { + if (propertiesToUpdate == null) throw new ArgumentNullException(nameof(propertiesToUpdate)); + + var cachedTypeInfo = GetCachedTypeInfo(typeof(T)); + var sourceReadProperties = cachedTypeInfo.Properties.Except(cachedTypeInfo.WriteOnlyProperties).ToList(); + var updateProperties = typeof(TProps).GetRuntimeProperties().Where(x => x.CanRead).ToList(); + var (hasValue, value) = ConstructorToUseForWith(cachedTypeInfo, updateProperties, sourceReadProperties); + + if (!hasValue) + { + throw new CopyException( + $"Type {typeof(T).Name} does not supply a suitable constructor for use with With, which allows all " + + "non-writable properties to be set via that constructor."); + } + + var constructorParameters = value.Parameters; + var constructorParameterValues = MapUpdateValuesToConstructorParameters(itemToCopy, + propertiesToUpdate, + constructorParameters, + updateProperties, + sourceReadProperties); + + var destWriteProperties = cachedTypeInfo.Properties.Except(cachedTypeInfo.ReadOnlyProperties); + var propsToSetFromUpdateData = GetPropertiesToSetFromUpdateData(updateProperties, + constructorParameters, + sourceReadProperties); + + var propsToSetFromSourceObject = GetPropertiesToSetFromSourceObject(sourceReadProperties, + constructorParameters, + propsToSetFromUpdateData, + destWriteProperties); + + try + { + return CreateNewObjectApplyingUpdates(itemToCopy, + propertiesToUpdate, + constructorParameterValues, + propsToSetFromSourceObject, + propsToSetFromUpdateData); + } + catch (Exception ex) + { + throw new CopyException($"A problem occurred creating a new instance of {typeof(T).Name} using With." + + "See the inner exception for details of the problem.", + ex); + } + } + + private static object[] GetConstructorParameterValuesForCopy( + T @object, + IEnumerable sourceReadProperties, + IEnumerable + constructorParameters) where T : notnull + { + return constructorParameters.Select(p => sourceReadProperties.TryFirst(x => AreLinked(x, p))) + .Where(x => x.HasValue).Select(x => x.Value) + .Select(sourceReadProperty => sourceReadProperty.GetValue(@object, null)) + .ToArray(); + } + + private static T CreateNewObjectApplyingUpdates( + T itemToCopy, + TProps propertiesToUpdate, + object[] constructorParameterValues, + IEnumerable propsToSetFromSourceObject, + IEnumerable<(PropertyInfo Value, PropertyInfo PropToUpdate)> propsToSetFromUpdateData) + where T : notnull where TProps : class + { + var newObject = Activator.CreateInstance(typeof(T), constructorParameterValues); + + foreach (var propertyToOverwrite in propsToSetFromSourceObject) + { + CopyPropertyValue(itemToCopy, propertyToOverwrite, newObject); + } + + foreach (var (sourceProp, propToUpdate) in propsToSetFromUpdateData) + { + CopyPropertyValue(propertiesToUpdate, propToUpdate, newObject, sourceProp); + } + + return (T)newObject; + } + + private static List GetPropertiesToSetFromSourceObject( + IEnumerable sourceReadProperties, + List constructorParameters, + List<(PropertyInfo Value, PropertyInfo PropToUpdate)> propsToSetFromUpdateData, + IEnumerable destWriteProperties) + { + return sourceReadProperties + .Where(p => !constructorParameters.Any(cp => AreLinked(cp, p))) + .Where(p => !propsToSetFromUpdateData.Any(tp => AreLinked(p, tp.PropToUpdate))) + .Select(sourceProperty => + destWriteProperties.TryFirst( + destProperty => AreLinked(sourceProperty, destProperty))) + .Where(x => x.HasValue) + .Select(x => x.Value).ToList(); + } + + private static List<(PropertyInfo Value, PropertyInfo PropToUpdate)> GetPropertiesToSetFromUpdateData( + IEnumerable updateProperties, + List constructorParameters, + List sourceReadProperties) + { + return updateProperties + .Where(p => !constructorParameters.Any(cp => AreLinked(cp, p))) + .Select(propToUpdate => + ( + SourceProp: sourceReadProperties.TryFirst( + sp => AreLinked(sp, propToUpdate)), + PropToUpdate: propToUpdate + ) + ) + .Where(x => x.SourceProp.HasValue && x.SourceProp.Value.CanWrite) + .Select(x => (x.SourceProp.Value, x.PropToUpdate)).ToList(); + } + + private static object[] MapUpdateValuesToConstructorParameters( + T @object, + TProps propertiesToUpdate, + List constructorParameters, + List updateProperties, + List sourceReadProperties) where T : notnull where TProps : class + { + return constructorParameters + .Select(p => { + return updateProperties + .TryFirst(ptu => AreLinked(ptu, p)) + .Match>() + .Some().Do(ptu => Option.Some(ptu.GetValue(propertiesToUpdate, null))) + .None().Do(() => { + return sourceReadProperties + .TryFirst(sp => AreLinked(sp, p)) + .Match>() + .Some().Do(sp => Option.Some(sp.GetValue(@object, null))) + .None().Do(Option.None) + .Result(); + }).Result(); + }) + .Where(x => x.HasValue) + .Select(x => x.Value) + .ToArray(); + } + + private static Option ConstructorToUseForWith(CachedTypeInfo cachedTypeInfo, + IEnumerable updateProperties, + IEnumerable readProperties) + { + return (from constructor in cachedTypeInfo.CachedPublicConstructors + let paramsNotCoveredByUpdates = + constructor.Parameters.Where(p => !updateProperties.Any(ptu => AreLinked(ptu, p))) + let remainingParamsNotCoveredByProperties = + paramsNotCoveredByUpdates.Where(p => !readProperties.Any(rp => AreLinked(rp, p))).ToList() + where !remainingParamsNotCoveredByProperties.Any() + orderby constructor.Parameters.Count descending + select constructor).TryFirst(); + } + + private static CachedTypeInfo GetCachedTypeInfo(Type type) + => CachedTypeInfoDetails.GetOrAddValue(type.FullName, () => new CachedTypeInfo(type)); + + private static T GetOrAddValue(this Dictionary dictionary, string key, Func createValue) + { + return dictionary.TryGetValue(key) + .Match() + .Some().Do(value => value) + .None().Do(() => + { + var value = createValue(); + dictionary.Add(key, value); + return value; + }) + .Result(); + } + + private static bool AreLinked(MemberInfo memberInfo, ParameterInfo parameterInfo) + => string.Equals(memberInfo.Name, parameterInfo.Name, StringComparison.CurrentCultureIgnoreCase); + + private static bool AreLinked(MemberInfo memberInfo, PropertyInfo propertyInfo) => + string.Equals(memberInfo.Name, propertyInfo.Name, StringComparison.CurrentCultureIgnoreCase); + + private static bool AreLinked(ParameterInfo parameterInfo, PropertyInfo propertyInfo) => + string.Equals(parameterInfo.Name, propertyInfo.Name, StringComparison.CurrentCultureIgnoreCase); + + private static void CopyPropertyValue(T from, PropertyInfo property, T to) where T : class + => property.SetValue(to, property.GetValue(from, null)); + + private static void CopyPropertyValue(T1 from, + PropertyInfo fromProperty, + T2 to, + PropertyInfo toProperty) where T1 : class where T2 : class + { + toProperty.SetValue(to, fromProperty.GetValue(from, null)); + } + } +} \ No newline at end of file diff --git a/tests/SuccincT.Tests/SuccincT/Functional/WithExtensionsAdverseConditionsTests.cs b/tests/SuccincT.Tests/SuccincT/Functional/WithExtensionsAdverseConditionsTests.cs new file mode 100644 index 0000000..388fc27 --- /dev/null +++ b/tests/SuccincT.Tests/SuccincT/Functional/WithExtensionsAdverseConditionsTests.cs @@ -0,0 +1,203 @@ +using System.Diagnostics.CodeAnalysis; +using NUnit.Framework; +using SuccincT.Functional; +using static NUnit.Framework.Assert; + +namespace SuccincTTests.SuccincT.Functional +{ + [TestFixture] + public static class WithExtensionsAdverseConditionsTests + { + [Test] + public static void TryWithOfTypeWithConstructorAndNoGetters_FailsCleanly() + { + var x = new TypeWithConstructorAndNoGetters(1); + var y = x.TryWith(new { }); + + IsFalse(y.HasValue); + } + + [Test] + public static void WithOfTypeWithConstructorAndNoGetters_ThrowsCopyException() + { + var x = new TypeWithConstructorAndNoGetters(1); + + Throws(() => x.With(new { })); + } + + [Test] + public static void TryWithOfTypeWithConstructorAndNoGettersSuppliedWithWrongParam_FailsCleanly() + { + var x = new TypeWithConstructorAndNoGetters(1); + var y = x.TryWith(new { x = "2" }); + + IsFalse(y.HasValue); + } + + [Test] + public static void WithOfTypeWithConstructorAndNoGettersSuppliedWithWrongParam_ThrowsCopyException() + { + var x = new TypeWithConstructorAndNoGetters(1); + + Throws(() => x.With(new { x = "2" })); + } + + [Test] + public static void TryWithOfTypeWithConstructorAndNoGettersSuppliedWithWrongParamName_FailsCleanly() + { + var x = new TypeWithConstructorAndNoGetters(1); + var y = x.TryWith(new { y = 1 }); + + IsFalse(y.HasValue); + } + + [Test] + public static void WithOfTypeWithConstructorAndNoGettersSuppliedWithWrongParamName_ThrowsCopyException() + { + var x = new TypeWithConstructorAndNoGetters(1); + + Throws(() => x.With(new { y = 1 })); + } + + [Test] + public static void TryWithOfTypeWithConstructorAndNoMatchingGetter_CanHavePropSetViaConstructor() + { + var x = new TypeWithConstructorAndNoMatchingGetter(1); + var y = x.TryWith(new { a = 2 }); + + AreEqual(2, y.Value.B); + } + + [Test] + public static void WithOfTypeWithConstructorAndNoMatchingGetter_CanHavePropSetViaConstructor() + { + var x = new TypeWithConstructorAndNoMatchingGetter(1); + var y = x.With(new { a = 2 }); + + AreEqual(2, y.B); + } + + [Test] + public static void TryWithOfTypeWithConstructorAndNoMatchingGetter_FailsCleanlyWhenPropertyNameRatherThanParamNameIsSupplied() + { + var x = new TypeWithConstructorAndNoMatchingGetter(1); + var y = x.TryWith(new { b = 2 }); + + IsFalse(y.HasValue); + } + + [Test] + public static void WithOfTypeWithConstructorAndNoMatchingGetter_ThrowsCopyExceptionWhenPropertyNameRatherThanParamNameIsSupplied() + { + var x = new TypeWithConstructorAndNoMatchingGetter(1); + + Throws(() => x.With(new { b = 2 })); + } + + [Test] + public static void TryWithOfTypeWithMultipleConstructors_ChoosesCorrectConstructor() + { + var x = new TypeWithMultipleConstructors(1); + var (_, value) = x.TryWith(new { B = 3 }); + + AreEqual(1, value.A); + AreEqual(3, value.B); + } + + [Test] + public static void WithOfTypeWithMultipleConstructors_ChoosesCorrectConstructor() + { + var x = new TypeWithMultipleConstructors(1); + var y = x.With(new { B = 3 }); + + AreEqual(1, y.A); + AreEqual(3, y.B); + } + + [Test] + public static void TryWithOfTypeToTestPropertiesSetViaConstructorArentSetAfterConstruction_OnlySetsPropertyViaConstructor() + { + var x = new TypeToTestPropertiesSetViaConstructorArentSetAfterConstruction(2); + var y = x.TryWith(new { a = 3 }); + + AreEqual(3, y.Value.A); + } + + [Test] + public static void WithOfTypeToTestPropertiesSetViaConstructorArentSetAfterConstruction_OnlySetsPropertyViaConstructor() + { + var x = new TypeToTestPropertiesSetViaConstructorArentSetAfterConstruction(2); + var y = x.With(new { a = 3 }); + + AreEqual(3, y.A); + } + + [Test] + public static void TryWithOfTypeToTestPropertiesAreOnlySetOnceWhenNoConstructor_OnlySetsPropertyOnce() + { + var x = new TypeToTestPropertiesAreOnlySetOnceWhenNoConstructor { A = 2 }; + var y = x.TryWith(new { a = 3 }); + + AreEqual(3, y.Value.A); + } + + [Test] + public static void WithOfTypeToTestPropertiesAreOnlySetOnceWhenNoConstructor_OnlySetsPropertyOnce() + { + var x = new TypeToTestPropertiesAreOnlySetOnceWhenNoConstructor { A = 2 }; + var y = x.With(new { a = 3 }); + + AreEqual(3, y.A); + } + + private class TypeWithConstructorAndNoGetters + { + // ReSharper disable once UnusedParameter.Local + public TypeWithConstructorAndNoGetters(int x) { } + } + + [SuppressMessage("ReSharper", "UnusedMember.Local")] + [SuppressMessage("ReSharper", "UnusedParameter.Local")] + private class TypeWithConstructorAndNoMatchingGetter + { + public TypeWithConstructorAndNoMatchingGetter(int a, int c) { } + public TypeWithConstructorAndNoMatchingGetter(int a) => B = a; + public int B { get; } + } + + [SuppressMessage("ReSharper", "UnusedMember.Local")] + [SuppressMessage("ReSharper", "UnusedParameter.Local")] + private class TypeWithMultipleConstructors + { + public TypeWithMultipleConstructors(int a) => (A, B) = (a, 1); + public TypeWithMultipleConstructors(int a, int b) => (A, B) = (a, b); + public TypeWithMultipleConstructors(int a, int c, int b) => (A, B) = (a, b); + public int A { get; } + public int B { get; } + } + + private class TypeToTestPropertiesSetViaConstructorArentSetAfterConstruction + { + private int _a; + + public TypeToTestPropertiesSetViaConstructorArentSetAfterConstruction(int a) => A = a; + + public int A + { + get => _a; + set => _a += value; + } + } + + private class TypeToTestPropertiesAreOnlySetOnceWhenNoConstructor + { + private int _a; + + public int A + { + get => _a; + set => _a += value; + } + } + } +} \ No newline at end of file diff --git a/tests/SuccincT.Tests/SuccincT/Functional/WithExtensionsTests.cs b/tests/SuccincT.Tests/SuccincT/Functional/WithExtensionsTests.cs new file mode 100644 index 0000000..39946aa --- /dev/null +++ b/tests/SuccincT.Tests/SuccincT/Functional/WithExtensionsTests.cs @@ -0,0 +1,270 @@ +using NUnit.Framework; +using SuccincT.Functional; +using System; +using static NUnit.Framework.Assert; + +namespace SuccincTTests.SuccincT.Functional +{ + [TestFixture] + public static class WithExtensionsTests + { + [Test] + public static void TryWithWithNullUpdateParameters_ShouldFail() + { + var car = new Car(); + var optionResult = car.TryWith(null); + + IsFalse(optionResult.HasValue); + } + + [Test] + public static void WithWithNullUpdateParameters_ShouldFail() + { + var car = new Car(); + Throws(() => car.With(null)); + } + + [Test] + public static void TryWithWithoutProperties_ShouldReturnCopyOfOriginalObject() + { + var car = new Car(); + + var newCar = car.TryWith(new { }).Value; + + AreNotSame(car, newCar); + AreEqual(car.Constructor, newCar.Constructor); + AreEqual(car.Color, newCar.Color); + AreEqual(car.CreationDate, newCar.CreationDate); + } + + [Test] + public static void WithWithoutProperties_ShouldReturnCopyOfOriginalObject() + { + var car = new Car(); + + var newCar = car.With(new { }); + + AreNotSame(car, newCar); + AreEqual(car.Constructor, newCar.Constructor); + AreEqual(car.Color, newCar.Color); + AreEqual(car.CreationDate, newCar.CreationDate); + } + + [Test] + public static void TryWithWithOneProperty_ShouldCopyOtherPropertiesFromOriginalObject() + { + var car = new Car(); + var newCar = car.TryWith(new { Color = "Red" }).Value; + + AreNotSame(car, newCar); + AreEqual(car.Constructor, newCar.Constructor); + AreEqual("Red", newCar.Color); + AreEqual(car.CreationDate, newCar.CreationDate); + } + + [Test] + public static void WithWithOneProperty_ShouldCopyOtherPropertiesFromOriginalObject() + { + var car = new Car(); + var newCar = car.With(new { Color = "Red" }); + + AreNotSame(car, newCar); + AreEqual(car.Constructor, newCar.Constructor); + AreEqual("Red", newCar.Color); + AreEqual(car.CreationDate, newCar.CreationDate); + } + + [Test] + public static void TryWithUsedMultipleTimes_ShouldUseLastUpdatedPropertyForFinalObject() + { + var car = new Car(); + + var newCar1 = car.TryWith(new { Color = "Red" }).Value; + var newCar2 = newCar1.TryWith(new { Color = "Blue" }).Value; + var newCar3 = newCar2.TryWith(new { Color = "Green" }).Value; + + AreNotSame(car, newCar3); + AreEqual(car.Constructor, newCar3.Constructor); + AreEqual("Green", newCar3.Color); + AreEqual(car.CreationDate, newCar3.CreationDate); + } + + [Test] + public static void WithUsedMultipleTimes_ShouldUseLastUpdatedPropertyForFinalObject() + { + var car = new Car(); + + var newCar1 = car.With(new { Color = "Red" }); + var newCar2 = newCar1.With(new { Color = "Blue" }); + var newCar3 = newCar2.With(new { Color = "Green" }); + + AreNotSame(car, newCar3); + AreEqual(car.Constructor, newCar3.Constructor); + AreEqual("Green", newCar3.Color); + AreEqual(car.CreationDate, newCar3.CreationDate); + } + + [Test] + public static void TryWithWithAllProperties_ShouldReturnObjectWithAllPropertiesUpdated() + { + var car = new Car { Constructor = "Ford", Color = "Black", CreationDate = new DateTime(1908, 10, 1) }; + + var newCar = car.TryWith(new + { + Constructor = "BMW", + Color = "Red", + CreationDate = new DateTime(2017, 01, 01) + }).Value; + + AreNotSame(car, newCar); + AreEqual("BMW", newCar.Constructor); + AreEqual("Red", newCar.Color); + AreEqual(new DateTime(2017, 01, 01), newCar.CreationDate); + } + + [Test] + public static void WithWithAllProperties_ShouldReturnObjectWithAllPropertiesUpdated() + { + var car = new Car { Constructor = "Ford", Color = "Black", CreationDate = new DateTime(1908, 10, 1) }; + + var newCar = car.With(new + { + Constructor = "BMW", + Color = "Red", + CreationDate = new DateTime(2017, 01, 01) + }); + + AreNotSame(car, newCar); + AreEqual("BMW", newCar.Constructor); + AreEqual("Red", newCar.Color); + AreEqual(new DateTime(2017, 01, 01), newCar.CreationDate); + } + + [Test] + public static void TryWithWithNoMatchingProperty_ShouldReturnCopyOfOriginalObject() + { + var car = new Car { Color = "Blue" }; + var newCar = car.TryWith(new { Cost = "1,000$" }).Value; + + AreNotSame(car, newCar); + AreEqual(car.Constructor, newCar.Constructor); + AreEqual("Blue", newCar.Color); + AreEqual(car.CreationDate, newCar.CreationDate); + } + + [Test] + public static void WithWithNoMatchingProperty_ShouldReturnCopyOfOriginalObject() + { + var car = new Car { Color = "Blue" }; + var newCar = car.With(new { Cost = "1,000$" }); + + AreNotSame(car, newCar); + AreEqual(car.Constructor, newCar.Constructor); + AreEqual("Blue", newCar.Color); + AreEqual(car.CreationDate, newCar.CreationDate); + } + + [Test] + public static void TryWithUpdatingPropertyThatsNotHandledByConstructor_ShouldReturnNewObjectWithUpdatedProperty() + { + var book = new Book("Lewis Caroll", "Alice's Adventures In Wonderland"); + var publishedBook = book.TryWith(new { PublishDate = new DateTime(1865, 11, 26) }).Value; + + AreNotSame(book, publishedBook); + AreEqual(book.Author, publishedBook.Author); + AreEqual(book.Name, publishedBook.Name); + AreEqual(new DateTime(1865, 11, 26), publishedBook.PublishDate); + } + + [Test] + public static void WithUpdatingPropertyThatsNotHandledByConstructor_ShouldReturnNewObjectWithUpdatedProperty() + { + var book = new Book("Lewis Caroll", "Alice's Adventures In Wonderland"); + var publishedBook = book.With(new { PublishDate = new DateTime(1865, 11, 26) }); + + AreNotSame(book, publishedBook); + AreEqual(book.Author, publishedBook.Author); + AreEqual(book.Name, publishedBook.Name); + AreEqual(new DateTime(1865, 11, 26), publishedBook.PublishDate); + } + + [Test] + public static void TryWithUpdatingPropertyViaConstructorParameter_ShouldReturnNewObjectWithUpdatedProperty() + { + var book = new Book("J.R.R. Tolkien", "The Lord Of The Rings"); + var newBook = book.TryWith(new { Name = "The Hobbit" }).Value; + + AreNotSame(book, newBook); + AreEqual(book.Author, newBook.Author); + AreEqual("The Hobbit", newBook.Name); + AreEqual(book.PublishDate, newBook.PublishDate); + } + + [Test] + public static void WithUpdatingPropertyViaConstructorParameter_ShouldReturnNewObjectWithUpdatedProperty() + { + var book = new Book("J.R.R. Tolkien", "The Lord Of The Rings"); + var newBook = book.With(new { Name = "The Hobbit" }); + + AreNotSame(book, newBook); + AreEqual(book.Author, newBook.Author); + AreEqual("The Hobbit", newBook.Name); + AreEqual(book.PublishDate, newBook.PublishDate); + } + + [Test] + public static void TryWithThatResetsAllValues_ShouldReturnNewObjectWithResetValues() + { + var book = new Book("Lewis Caroll", "Alice's Adventures In Wonderland") + { + PublishDate = new DateTime(1865, 11, 26) + }; + + var emptyBook = book + .TryWith(new { Author = string.Empty, Name = string.Empty, PublishDate = default(DateTime?) }) + .Value; + + AreNotSame(book, emptyBook); + IsEmpty(emptyBook.Author); + IsEmpty(emptyBook.Name); + IsNull(emptyBook.PublishDate); + } + + [Test] + public static void WithThatResetsAllValues_ShouldReturnNewObjectWithResetValues() + { + var book = new Book("Lewis Caroll", "Alice's Adventures In Wonderland") + { + PublishDate = new DateTime(1865, 11, 26) + }; + + var emptyBook = book.With(new + { + Author = string.Empty, + Name = string.Empty, + PublishDate = default(DateTime?) + }); + + AreNotSame(book, emptyBook); + IsEmpty(emptyBook.Author); + IsEmpty(emptyBook.Name); + IsNull(emptyBook.PublishDate); + } + + private class Car + { + public string Constructor { get; set; } + public string Color { get; set; } + public DateTime? CreationDate { get; set; } + } + + private class Book + { + public Book(string author, string name) => (Author, Name) = (author, name); + + public string Author { get; } + public string Name { get; } + public DateTime? PublishDate { get; set; } + } + } +} \ No newline at end of file