diff --git a/AbstractionLayer.sln b/AbstractionLayer.sln index 6118c0b0..1d5428a6 100644 --- a/AbstractionLayer.sln +++ b/AbstractionLayer.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.28803.352 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StartProject", "src\StartProject\StartProject.csproj", "{08B7686D-63C7-498C-90F0-B756E93575DD}" EndProject @@ -45,6 +45,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Moryx.AbstractionLayer.Test EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Moryx.Resources.Wcf", "src\Moryx.Resources.Wcf\Moryx.Resources.Wcf.csproj", "{E1AD28CD-37A6-4AF6-80D7-756586A727B6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Moryx.Products.Management.Tests", "src\Tests\Moryx.Products.Management.Tests\Moryx.Products.Management.Tests.csproj", "{0CCA2AFB-1788-44C2-8919-F5CD46BC94AD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -111,6 +113,10 @@ Global {E1AD28CD-37A6-4AF6-80D7-756586A727B6}.Debug|Any CPU.Build.0 = Debug|Any CPU {E1AD28CD-37A6-4AF6-80D7-756586A727B6}.Release|Any CPU.ActiveCfg = Release|Any CPU {E1AD28CD-37A6-4AF6-80D7-756586A727B6}.Release|Any CPU.Build.0 = Release|Any CPU + {0CCA2AFB-1788-44C2-8919-F5CD46BC94AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0CCA2AFB-1788-44C2-8919-F5CD46BC94AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0CCA2AFB-1788-44C2-8919-F5CD46BC94AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0CCA2AFB-1788-44C2-8919-F5CD46BC94AD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -130,6 +136,7 @@ Global {93650069-26E9-4E0D-A336-9D02F1039346} = {D6D69517-7889-4E08-ABEA-D3E069D08A6B} {583CBD84-DD9F-4834-A89C-1625A05EE15D} = {BA183EBF-FAC1-45AE-9559-09879DB103AC} {E1AD28CD-37A6-4AF6-80D7-756586A727B6} = {BA183EBF-FAC1-45AE-9559-09879DB103AC} + {0CCA2AFB-1788-44C2-8919-F5CD46BC94AD} = {D6D69517-7889-4E08-ABEA-D3E069D08A6B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {23463631-1BA0-41B8-ABA3-1E9741037513} diff --git a/Directory.Build.targets b/Directory.Build.targets index 9bdcb1ff..645cb9e0 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -3,7 +3,7 @@ 3.2.0 - 3.1.0 + 3.2.0 3.1.1 diff --git a/VERSION b/VERSION index 4e32c7b1..c68d476c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.10.1 +5.11.0 diff --git a/src/Moryx.AbstractionLayer.TestTools/DummyProductPartLink.cs b/src/Moryx.AbstractionLayer.TestTools/DummyProductPartLink.cs new file mode 100644 index 00000000..1af2dae4 --- /dev/null +++ b/src/Moryx.AbstractionLayer.TestTools/DummyProductPartLink.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2020, Phoenix Contact GmbH & Co. KG +// Licensed under the Apache License, Version 2.0 + +using Moryx.AbstractionLayer.Products; +using System.Linq; + +namespace Moryx.AbstractionLayer.TestTools +{ + /// + /// Dummy implementation of a ProductPartLink. + /// + public class DummyProductPartLink : ProductPartLink + { + public override bool Equals(object obj) + { + var toCompareWith = obj as DummyProductPartLink; + if (toCompareWith == null) + return false; + + return GetType().GetProperties() + .All(prop => (prop.GetValue(toCompareWith) is null && prop.GetValue(this) is null) + || prop.GetValue(toCompareWith).Equals(prop.GetValue(this))); + } + } +} diff --git a/src/Moryx.AbstractionLayer.TestTools/DummyProductRecipe.cs b/src/Moryx.AbstractionLayer.TestTools/DummyProductRecipe.cs new file mode 100644 index 00000000..8205b33a --- /dev/null +++ b/src/Moryx.AbstractionLayer.TestTools/DummyProductRecipe.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2020, Phoenix Contact GmbH & Co. KG +// Licensed under the Apache License, Version 2.0 + +using Moryx.AbstractionLayer.Recipes; +using Moryx.Workflows; +using System.Collections.Generic; +using System.Linq; + +namespace Moryx.AbstractionLayer.TestTools +{ + /// + /// Dummy implementation of an product instance. Created by the + /// + public class DummyProductRecipe : ProductRecipe + { + public override bool Equals(object obj) + { + var toCompareWith = obj as DummyProductRecipe; + if (toCompareWith == null) + return false; + + return toCompareWith.Name == Name && toCompareWith.Revision == Revision + && toCompareWith.State == State && toCompareWith.Classification == Classification + && ((toCompareWith.Origin is null && Origin is null) || Origin.Equals(toCompareWith.Origin)) + && ((toCompareWith.Product is null && Product is null) || Product.Equals(toCompareWith.Product)) + && ((toCompareWith.Target is null && Target is null) || Target.Equals(toCompareWith.Target)); + } + } + + public class DummyProductWorkplanRecipe : DummyProductRecipe, IWorkplanRecipe + { + public IWorkplan Workplan { get; set; } + + public ICollection DisabledSteps { get; set; } + + public override bool Equals(object obj) + { + var toCompareWith = obj as DummyProductWorkplanRecipe; + if (toCompareWith == null) + return false; + + return base.Equals(toCompareWith) + && ((toCompareWith.Workplan is null && Workplan is null) || Workplan.Equals(toCompareWith.Workplan)) + && ((toCompareWith.DisabledSteps is null && DisabledSteps is null) || Enumerable.SequenceEqual(DisabledSteps, toCompareWith.DisabledSteps)); + } + } +} diff --git a/src/Moryx.AbstractionLayer.TestTools/DummyProductType.cs b/src/Moryx.AbstractionLayer.TestTools/DummyProductType.cs index a7fc424b..d7d5f653 100644 --- a/src/Moryx.AbstractionLayer.TestTools/DummyProductType.cs +++ b/src/Moryx.AbstractionLayer.TestTools/DummyProductType.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0 using Moryx.AbstractionLayer.Products; +using System.Collections.Generic; +using System.Linq; namespace Moryx.AbstractionLayer.TestTools { @@ -15,5 +17,87 @@ protected override ProductInstance Instantiate() { return new DummyProductInstance(); } + + public override bool Equals(object obj) + { + var toCompareWith = obj as DummyProductType; + if (toCompareWith == null) + return false; + + return toCompareWith.Id == Id && toCompareWith.Name == Name && toCompareWith.State == State + && ((toCompareWith.Identity is null && Identity is null) || toCompareWith.Identity.Equals(Identity)); + } + } + + + /// + /// Dummy implementation of a with Product Parts + /// + public class DummyProductTypeWithParts : DummyProductType + { + /// + protected override ProductInstance Instantiate() + { + return new DummyProductInstance(); + } + + /// + /// Dummy ProductPartLink + /// + public DummyProductPartLink ProductPartLink { get; set; } + + /// + /// Dummy ProductPartLink enumerable + /// + public IEnumerable ProductPartLinkEnumerable { get; set; } + + public override bool Equals(object obj) + { + var toCompareWith = obj as DummyProductTypeWithParts; + if (toCompareWith == null) + return false; + + return base.Equals(toCompareWith) && + ((toCompareWith.ProductPartLink is null && ProductPartLink is null) || + toCompareWith.ProductPartLink.Equals(ProductPartLink)) + && ((toCompareWith.ProductPartLinkEnumerable is null && ProductPartLinkEnumerable is null) || + Enumerable.SequenceEqual(toCompareWith.ProductPartLinkEnumerable, ProductPartLinkEnumerable)); + } + } + + + /// + /// Dummy implementation of a with Files + /// + public class DummyProductTypeWithFiles : DummyProductType + { + /// + protected override ProductInstance Instantiate() + { + return new DummyProductInstance(); + } + + /// + /// First dummy ProductFile + /// + public ProductFile FirstProductFile { get; set; } + + /// + /// Second dummy ProductFile + /// + public ProductFile SecondProductFile { get; set; } + + public override bool Equals(object obj) + { + var toCompareWith = obj as DummyProductTypeWithFiles; + if (toCompareWith == null) + return false; + + return base.Equals(toCompareWith) && + (toCompareWith.FirstProductFile is null && FirstProductFile is null || + FirstProductFile.GetType().GetProperties().All(prop => prop.GetValue(toCompareWith.FirstProductFile) == prop.GetValue(FirstProductFile))) + && (toCompareWith.SecondProductFile is null && SecondProductFile is null || + SecondProductFile.GetType().GetProperties().All(prop => prop.GetValue(toCompareWith.SecondProductFile) == prop.GetValue(SecondProductFile))); + } } } diff --git a/src/Moryx.AbstractionLayer.TestTools/DummyWorkplan.cs b/src/Moryx.AbstractionLayer.TestTools/DummyWorkplan.cs new file mode 100644 index 00000000..874773ed --- /dev/null +++ b/src/Moryx.AbstractionLayer.TestTools/DummyWorkplan.cs @@ -0,0 +1,23 @@ +// Copyright (c) 2020, Phoenix Contact GmbH & Co. KG +// Licensed under the Apache License, Version 2.0 + +using Moryx.Workflows; +using System.Linq; + +namespace Moryx.AbstractionLayer.TestTools +{ + public class DummyWorkplan : Workplan + { + public override bool Equals(object obj) + { + var toCompareWith = obj as DummyWorkplan; + if (toCompareWith == null) + return false; + + return toCompareWith.Id == Id && toCompareWith.Name == Name + && toCompareWith.Version == toCompareWith.Version && toCompareWith.State == State + && ((toCompareWith.Connectors is null && Connectors is null) || Enumerable.SequenceEqual(toCompareWith.Connectors, Connectors)) + && ((toCompareWith.Steps is null && Steps is null) || Enumerable.SequenceEqual(toCompareWith.Steps, Steps)); + } + } +} diff --git a/src/Moryx.AbstractionLayer/Products/IProductManagement.cs b/src/Moryx.AbstractionLayer/Products/IProductManagement.cs index 1bd02b85..7dc6c87f 100644 --- a/src/Moryx.AbstractionLayer/Products/IProductManagement.cs +++ b/src/Moryx.AbstractionLayer/Products/IProductManagement.cs @@ -123,4 +123,16 @@ TInstance GetInstance(Expression> selector) IReadOnlyList GetInstances(Expression> selector) where TInstance : IProductInstance; } + + /// + /// Additional interface for type storage to search for product types by expression + /// TODO: Remove in AL 6 + /// + public interface IProductManagementTypeSearch : IProductManagement + { + /// + /// Load types using filter expression + /// + IReadOnlyList LoadTypes(Expression> selector); + } } diff --git a/src/Moryx.AbstractionLayer/Products/ProductFacadeExtensions.cs b/src/Moryx.AbstractionLayer/Products/ProductFacadeExtensions.cs new file mode 100644 index 00000000..54c951aa --- /dev/null +++ b/src/Moryx.AbstractionLayer/Products/ProductFacadeExtensions.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2021, Phoenix Contact GmbH & Co. KG +// Licensed under the Apache License, Version 2.0 + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace Moryx.AbstractionLayer.Products +{ + /// + /// Extensions for the facade + /// + public static class ProductFacadeExtensions + { + /// + /// Bridge extension for LoadTypes using filter expression + /// + public static IReadOnlyList LoadTypes(this IProductManagement facade, Expression> selector) + { + if (facade is IProductManagementTypeSearch typeSearch) + return typeSearch.LoadTypes(selector); + + throw new NotSupportedException("Instance of product management does not support expression type search"); + } + } +} \ No newline at end of file diff --git a/src/Moryx.AbstractionLayer/Products/ProductQuery.cs b/src/Moryx.AbstractionLayer/Products/ProductQuery.cs index eab917a4..d36f7895 100644 --- a/src/Moryx.AbstractionLayer/Products/ProductQuery.cs +++ b/src/Moryx.AbstractionLayer/Products/ProductQuery.cs @@ -1,7 +1,9 @@ // Copyright (c) 2020, Phoenix Contact GmbH & Co. KG // Licensed under the Apache License, Version 2.0 +using System.Collections.Generic; using System.Runtime.Serialization; +using Moryx.Serialization; namespace Moryx.AbstractionLayer.Products { @@ -65,6 +67,52 @@ public class ProductQuery /// [DataMember] public Selector Selector { get; set; } + + /// + /// List of property filters + /// + [DataMember] + public List PropertyFilters { get; set; } + } + + /// + /// Property filter wrapper for the filtered entry + /// + [DataContract] + public class PropertyFilter + { + /// + /// Entry to filter + /// + [DataMember] + public Entry Entry { get; set; } + + /// + /// Operator for the filter expression + /// + [DataMember] + public PropertyFilterOperator Operator { get; set; } + } + + /// + /// Property filter operator expression + /// + public enum PropertyFilterOperator + { + /// + /// Value equals + /// + Equals, + + /// + /// Value is greater then + /// + GreaterThen, + + /// + /// Value is less then + /// + LessThen } /// @@ -76,10 +124,12 @@ public enum RevisionFilter /// Fetch all revisions, this is the default /// All = 0, + /// /// Fetch only the latest revision /// Latest = 1, + /// /// Fetch only specific revisions /// diff --git a/src/Moryx.AbstractionLayer/Products/ProductType.cs b/src/Moryx.AbstractionLayer/Products/ProductType.cs index 6773ce35..f9e9f114 100644 --- a/src/Moryx.AbstractionLayer/Products/ProductType.cs +++ b/src/Moryx.AbstractionLayer/Products/ProductType.cs @@ -30,7 +30,7 @@ public abstract class ProductType : IProductType /// public override string ToString() { - return Identity.ToString(); + return Identity?.ToString(); } /// diff --git a/src/Moryx.AbstractionLayer/Resources/IResourceGraph.cs b/src/Moryx.AbstractionLayer/Resources/IResourceGraph.cs index 1e976e00..b508574f 100644 --- a/src/Moryx.AbstractionLayer/Resources/IResourceGraph.cs +++ b/src/Moryx.AbstractionLayer/Resources/IResourceGraph.cs @@ -84,6 +84,7 @@ TResource Instantiate(string type) /// /// Remove a resource permanently and irreversible /// + [Obsolete("Permanent removal of resources will be removed in the next major")] bool Destroy(IResource resource, bool permanent); } } diff --git a/src/Moryx.AbstractionLayer/Resources/IResourceModification.cs b/src/Moryx.AbstractionLayer/Resources/IResourceModification.cs new file mode 100644 index 00000000..b0dbdec0 --- /dev/null +++ b/src/Moryx.AbstractionLayer/Resources/IResourceModification.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2021, Phoenix Contact GmbH & Co. KG +// Licensed under the Apache License, Version 2.0 + +using System; + +namespace Moryx.AbstractionLayer.Resources +{ + /// + /// Facade for unintercepted access to resource objects, creation, modification and removal + /// For lifecylce and memory reasons resources are not returned, instead access is granted through delegates + /// + // TODO: Move into IResourceManagement + public interface IResourceModification : IResourceManagement + { + /// + /// Create and initialize a resource + /// + long Create(Type resourceType, Action initializer); + + /// + /// Read data from a resource + /// + TResult Read(long id, Func accessor); + + /// + /// Modify the resource. + /// + /// Id of the resource + /// Modifier delegate, must return true in order to save changes + void Modify(long id, Func modifier); + + /// + /// Create and initialize a resource + /// + bool Delete(long id); + } +} \ No newline at end of file diff --git a/src/Moryx.AbstractionLayer/Resources/ResourceFacadeExtensions.cs b/src/Moryx.AbstractionLayer/Resources/ResourceFacadeExtensions.cs new file mode 100644 index 00000000..21955728 --- /dev/null +++ b/src/Moryx.AbstractionLayer/Resources/ResourceFacadeExtensions.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2021, Phoenix Contact GmbH & Co. KG +// Licensed under the Apache License, Version 2.0 + +using System; + +namespace Moryx.AbstractionLayer.Resources +{ + /// + /// Additional overloads for the resource facade APIs as well as facade version bridge + /// + public static class ResourceFacadeExtensions + { + /// + /// Read data from a resource + /// + public static TResult Read(this IResourceManagement facade, long resourceId, Func accessor) + { + if (facade is IResourceModification modification) + return modification.Read(resourceId, accessor); + + throw new NotSupportedException("Instance of resource management does not support resource modification"); + } + + /// + /// Read data from a resource + /// + public static TResult Read(this IResourceManagement facade, IResource proxy, Func accessor) + { + if(facade is IResourceModification modification) + return modification.Read(proxy.Id, accessor); + + throw new NotSupportedException("Instance of resource management does not support resource modification"); + } + + /// + /// Modify the resource. + /// + /// + /// Modifier delegate, must return true in order to save changes + /// + public static void Modify(this IResourceManagement facade, long resourceId, Func modifier) + { + if (facade is IResourceModification modification) + modification.Modify(resourceId, modifier); + + throw new NotSupportedException("Instance of resource management does not support resource modification"); + } + + /// + /// Modify the resource. + /// + /// + /// Modifier delegate, must return true in order to save changes + /// + public static void Modify(this IResourceManagement facade, IResource proxy, Func modifier) + { + if (facade is IResourceModification modification) + modification.Modify(proxy.Id, modifier); + + throw new NotSupportedException("Instance of resource management does not support resource modification"); + } + + /// + /// Modify the resource. + /// + /// + /// Modifier delegate, must return true in order to save changes + /// + /// + public static void Modify(this IResourceManagement facade, IResource proxy, Func modifier, TContext context) + { + if (facade is IResourceModification modification) + modification.Modify(proxy.Id, resource => modifier(resource, context)); + + throw new NotSupportedException("Instance of resource management does not support resource modification"); + } + } +} \ No newline at end of file diff --git a/src/Moryx.Products.Management/Components/IProductManager.cs b/src/Moryx.Products.Management/Components/IProductManager.cs index 12d9d7ac..4c160042 100644 --- a/src/Moryx.Products.Management/Components/IProductManager.cs +++ b/src/Moryx.Products.Management/Components/IProductManager.cs @@ -28,6 +28,11 @@ internal interface IProductManager : IPlugin /// IReadOnlyList LoadTypes(ProductQuery query); + /// + /// Load types using filter expression + /// + IReadOnlyList LoadTypes(Expression> selector); + /// /// Load product instance by id /// diff --git a/src/Moryx.Products.Management/Components/IProductStorage.cs b/src/Moryx.Products.Management/Components/IProductStorage.cs index 1d900d58..64763c91 100644 --- a/src/Moryx.Products.Management/Components/IProductStorage.cs +++ b/src/Moryx.Products.Management/Components/IProductStorage.cs @@ -4,12 +4,9 @@ using System; using System.Collections.Generic; using System.Linq.Expressions; -using Moryx.AbstractionLayer.Identity; using Moryx.AbstractionLayer.Products; using Moryx.AbstractionLayer.Recipes; -using Moryx.Model.Repositories; using Moryx.Modules; -using Moryx.Products.Model; namespace Moryx.Products.Management { @@ -74,4 +71,16 @@ public interface IProductStorage : IPlugin /// void SaveRecipes(long productId, ICollection recipes); } + + /// + /// Additional interface for type storage to search for product types by expression + /// TODO: Remove in AL 6 + /// + public interface IProductSearchStorage : IProductStorage + { + /// + /// Load types using filter expression + /// + IReadOnlyList LoadTypes(Expression> selector); + } } diff --git a/src/Moryx.Products.Management/Components/IProductTypeStrategy.cs b/src/Moryx.Products.Management/Components/IProductTypeStrategy.cs index da35ecf8..01cacc0c 100644 --- a/src/Moryx.Products.Management/Components/IProductTypeStrategy.cs +++ b/src/Moryx.Products.Management/Components/IProductTypeStrategy.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0 using System; +using System.Linq.Expressions; using Moryx.AbstractionLayer; using Moryx.AbstractionLayer.Products; using Moryx.Model; @@ -35,4 +36,16 @@ public interface IProductTypeStrategy : IConfiguredPlugin void LoadType(IGenericColumns source, IProductType target); } + + /// + /// Additional interface for type strategies to search for product types by expression + /// TODO: Remove in AL 6 + /// + public interface IProductTypeSearch : IProductTypeStrategy + { + /// + /// Transform a product class selector to a database compatible expression + /// + Expression> TransformSelector(Expression> selector); + } } diff --git a/src/Moryx.Products.Management/Facades/ProductManagementFacade.cs b/src/Moryx.Products.Management/Facades/ProductManagementFacade.cs index 2b5524cf..ac88750c 100644 --- a/src/Moryx.Products.Management/Facades/ProductManagementFacade.cs +++ b/src/Moryx.Products.Management/Facades/ProductManagementFacade.cs @@ -14,7 +14,7 @@ namespace Moryx.Products.Management { - internal class ProductManagementFacade : IFacadeControl, IProductManagement, IWorkplansVersions + internal class ProductManagementFacade : IFacadeControl, IWorkplansVersions, IProductManagementTypeSearch { // Use this delegate in every call for clean health state management public Action ValidateHealthState { get; set; } @@ -49,6 +49,12 @@ public IReadOnlyList LoadTypes(ProductQuery query) return ProductManager.LoadTypes(query); } + public IReadOnlyList LoadTypes(Expression> selector) + { + ValidateHealthState(); + return ProductManager.LoadTypes(selector); + } + public IProductType LoadType(long id) { ValidateHealthState(); diff --git a/src/Moryx.Products.Management/Implementation/ProductManager.cs b/src/Moryx.Products.Management/Implementation/ProductManager.cs index f6295b01..80d1b2bc 100644 --- a/src/Moryx.Products.Management/Implementation/ProductManager.cs +++ b/src/Moryx.Products.Management/Implementation/ProductManager.cs @@ -64,6 +64,14 @@ public IReadOnlyList LoadTypes(ProductQuery query) return Storage.LoadTypes(query); } + public IReadOnlyList LoadTypes(Expression> selector) + { + if (Storage is IProductSearchStorage searchStorage) + return searchStorage.LoadTypes(selector); + + throw new NotSupportedException("Current storage does not support type search"); + } + public IProductType LoadType(long id) { return Storage.LoadType(id); diff --git a/src/Moryx.Products.Management/Implementation/Storage/ProductExpressionHelpers.cs b/src/Moryx.Products.Management/Implementation/Storage/ProductExpressionHelpers.cs index 057b14f3..fd6b09a6 100644 --- a/src/Moryx.Products.Management/Implementation/Storage/ProductExpressionHelpers.cs +++ b/src/Moryx.Products.Management/Implementation/Storage/ProductExpressionHelpers.cs @@ -6,6 +6,7 @@ using System.Linq.Expressions; using System.Reflection; using Moryx.AbstractionLayer.Products; +using Moryx.Products.Model; namespace Moryx.Products.Management { @@ -28,21 +29,26 @@ public static object ExtractExpressionValue(Expression expression) throw new NotSupportedException("Expression type not supported yet"); } - public static bool IsTypeQuery(Expression> selector, out ProductType productType) + + + public static bool IsTypeQuery(Expression> selector, out MemberInfo typeMember, out object memberValue) { - productType = null; + typeMember = null; + memberValue = null; + var body = selector.Body; // Extract the property targeted by the expression switch (body) { case BinaryExpression binary when binary.NodeType == ExpressionType.Equal: - if (binary.Left is MemberExpression bLeft && bLeft.Member.Name == nameof(ProductInstance.Type)) + // Extract member and value + if (binary.Left is MemberExpression bLeft && IsTypeExpression(bLeft, out typeMember)) { - productType = ExtractExpressionValue(binary.Right) as ProductType; + memberValue = ExtractExpressionValue(binary.Right); } - if (binary.Right is MemberExpression bRight && bRight.Member.Name == nameof(ProductInstance.Type)) + else if (binary.Right is MemberExpression bRight && IsTypeExpression(bRight, out typeMember)) { - productType = ExtractExpressionValue(binary.Left) as ProductType; + memberValue = ExtractExpressionValue(binary.Left); } break; case MethodCallExpression call: @@ -50,20 +56,51 @@ public static bool IsTypeQuery(Expression> sele var method = call.Method; if (method.Name == nameof(Equals)) { - if (call.Object is MemberExpression callMemEx && callMemEx.Expression is ConstantExpression) + if (call.Object is MemberExpression callMemEx && IsTypeExpression(callMemEx, out typeMember)) { - productType = ExtractExpressionValue(call.Object) as ProductType; + memberValue = ExtractExpressionValue(call.Arguments.First()); } - else + else if (call.Arguments.First() is MemberExpression argMemEx && IsTypeExpression(argMemEx, out typeMember)) { - productType = ExtractExpressionValue(call.Arguments.First()) as ProductType; + memberValue = ExtractExpressionValue(call.Object); } } break; } - - return productType != null; + + return memberValue is not null; + } + + private static bool IsTypeExpression(MemberExpression expression, out MemberInfo typeMember) + { + typeMember = null; + do + { + if (expression.Member is PropertyInfo propertyInfo && typeof(IProductType).IsAssignableFrom(propertyInfo.PropertyType)) + return true; + typeMember = expression.Member; + expression = expression.Expression as MemberExpression; + } while (expression is not null); + + return false; + } + + internal static Expression> AsVersionExpression(Expression> expression) + { + // Extract lamda expression body and column + var lambda = (LambdaExpression)expression; + var binaryExpression = (BinaryExpression)lambda.Body; + var columnExpression = (MemberExpression)binaryExpression.Left; + + // Build new parameter expression + var rootEntity = Expression.Parameter(typeof(ProductTypeEntity)); + var versionExpression = Expression.Property(rootEntity, nameof(ProductTypeEntity.CurrentVersion)); + var versionColumn = Expression.Property(versionExpression, columnExpression.Member.Name); + + // Build new binary expression + var versionBinary = Expression.MakeBinary(binaryExpression.NodeType, versionColumn, binaryExpression.Right); + return Expression.Lambda(versionBinary, rootEntity) as Expression>; } } } diff --git a/src/Moryx.Products.Management/Implementation/Storage/ProductStorage.cs b/src/Moryx.Products.Management/Implementation/Storage/ProductStorage.cs index 8a90fd97..7580eedb 100644 --- a/src/Moryx.Products.Management/Implementation/Storage/ProductStorage.cs +++ b/src/Moryx.Products.Management/Implementation/Storage/ProductStorage.cs @@ -9,6 +9,7 @@ using System.Data.Entity; using System.Linq; using System.Linq.Expressions; +using System.Reflection; using System.Text.RegularExpressions; using Moryx.AbstractionLayer.Products; using Moryx.AbstractionLayer.Recipes; @@ -23,8 +24,8 @@ namespace Moryx.Products.Management /// Base class for product storage. Contains basic functionality to load and save a product. /// Also has the possibility to store a version to each save. /// - [Plugin(LifeCycle.Singleton, typeof(IProductStorage))] - internal class ProductStorage : IProductStorage + [Plugin(LifeCycle.Singleton, typeof(IProductStorage), typeof(IProductSearchStorage))] + internal class ProductStorage : IProductSearchStorage { /// /// Optimized constructor delegate for the different product types @@ -271,6 +272,23 @@ public IReadOnlyList LoadTypes(ProductQuery query) }).Select(t => t.Name); productsQuery = productsQuery.Where(p => allTypes.Contains(p.TypeName)); } + + // Filter by type properties properties + if (query.PropertyFilters != null && TypeStrategies[query.Type] is IProductTypeSearch typeSearch) + { + var targetType = typeSearch.TargetType; + // Make generic method for the target type + var genericMethod = typeof(IProductTypeSearch).GetMethod(nameof(IProductTypeSearch.TransformSelector)); + var method = genericMethod.MakeGenericMethod(targetType); + + foreach (var propertyFilter in query.PropertyFilters) + { + var expression = ConvertPropertyFilter(targetType, propertyFilter); + var columnExpression = (Expression>)method.Invoke(typeSearch, new object[]{ expression }); + var versionExpression = AsVersionExpression(columnExpression); + productsQuery = productsQuery.Where(versionExpression); + } + } } // Filter by identifier @@ -341,6 +359,71 @@ public IReadOnlyList LoadTypes(ProductQuery query) } } + private static Expression ConvertPropertyFilter(Type targetType, PropertyFilter filter) + { + // Product property expression + var productExpression = Expression.Parameter(targetType); + var propertyExpresssion = Expression.Property(productExpression, filter.Entry.Identifier); + + var property = targetType.GetProperty(filter.Entry.Identifier); + var value = Convert.ChangeType(filter.Entry.Value.Current, property.PropertyType); + var constantExpression = Expression.Constant(value); + + Expression expressionBody; + switch (filter.Operator) + { + case PropertyFilterOperator.Equals: + expressionBody = Expression.MakeBinary(ExpressionType.Equal, propertyExpresssion, constantExpression); + break; + case PropertyFilterOperator.GreaterThen: + expressionBody = Expression.MakeBinary(ExpressionType.GreaterThan, propertyExpresssion, constantExpression); + break; + case PropertyFilterOperator.LessThen: + expressionBody = Expression.MakeBinary(ExpressionType.LessThan, propertyExpresssion, constantExpression); + break; + default: + throw new ArgumentOutOfRangeException(); + } + + return Expression.Lambda(expressionBody, productExpression); + } + + public IReadOnlyList LoadTypes(Expression> selector) + { + using (var uow = Factory.Create()) + { + var repo = uow.GetRepository(); + var matchingStrategies = TypeStrategies.Values + .Where(i => typeof(TType).IsAssignableFrom(i.TargetType)); + + IQueryable query = null; + foreach (var typeStrategy in matchingStrategies) + { + var searchStrategy = typeStrategy as IProductTypeSearch; + if (searchStrategy == null) + throw new NotSupportedException($"Storage does not support expression search for {typeStrategy.TargetType}"); + + var columnExpression = searchStrategy.TransformSelector(selector); + var queryFilter = AsVersionExpression(columnExpression); + query = query == null + ? repo.Linq.Where(queryFilter) // Create query + : query.Union(repo.Linq.Where(queryFilter)); // Append query + } + + // No query or no result => Nothing to do + List entities; + if (query == null || (entities = query.ToList()).Count == 0) + return new TType[0]; + + var loadedProducts = new Dictionary(); + var instances = entities.Select(entity => Transform(uow, entity, false, loadedProducts)).OfType().ToArray(); + // Final check against compiled expression + var compiledSelector = selector.Compile(); + // Only return matches against compiled expression + return instances.Where(compiledSelector.Invoke).ToArray(); + } + } + /// public IProductType LoadType(long id) { @@ -383,12 +466,6 @@ public IProductType LoadType(ProductIdentity identity) } } - /// - public IProductType TransformType(IUnitOfWork context, ProductTypeEntity typeEntity, bool full) - { - return Transform(context, typeEntity, full); - } - private IProductType Transform(IUnitOfWork uow, ProductTypeEntity typeEntity, bool full, IDictionary loadedProducts = null, IProductPartLink parentLink = null) { // Build cache if this wasn't done before @@ -515,7 +592,7 @@ private ProductTypeEntity SaveProduct(ProductPartsSaverContext saverContext, IPr } strategy.SaveType(modifiedInstance, typeEntity.CurrentVersion); - saverContext.EntityCache.Add(new ProductIdentity(typeEntity.Identifier,typeEntity.Revision),typeEntity); + saverContext.EntityCache.Add(new ProductIdentity(typeEntity.Identifier, typeEntity.Revision), typeEntity); // And nasty again! var type = modifiedInstance.GetType(); @@ -586,10 +663,10 @@ where links.All(l => l.Id != link.Id) private ProductTypeEntity GetPartEntity(ProductPartsSaverContext saverContext, IProductPartLink link) { - if (saverContext.EntityCache.ContainsKey((ProductIdentity) link.Product.Identity)) + if (saverContext.EntityCache.ContainsKey((ProductIdentity)link.Product.Identity)) { - var part = saverContext.EntityCache[(ProductIdentity) link.Product.Identity]; - EntityIdListener.Listen(part,link.Product); + var part = saverContext.EntityCache[(ProductIdentity)link.Product.Identity]; + EntityIdListener.Listen(part, link.Product); return part; } @@ -630,21 +707,14 @@ public IReadOnlyList LoadInstances(params long[] id) public IReadOnlyList LoadInstances(ProductType productType) { - using (var uow = Factory.Create()) - { - var repo = uow.GetRepository(); - var entities = repo.Linq - .Where(e => e.ProductId == productType.Id) - .ToList(); - return TransformInstances(uow, entities); - } + return LoadInstances(productType.Id); } public IReadOnlyList LoadInstances(Expression> selector) { - if (IsTypeQuery(selector, out var productType)) + if (IsTypeQuery(selector, out var member, out var value)) { - return LoadInstances(productType).OfType().ToList(); + return LoadInstancesByType(selector, member, value).ToList(); } else { @@ -652,6 +722,42 @@ public IReadOnlyList LoadInstances(Expression LoadInstancesByType(Expression> selector, MemberInfo typeProperty, object value) + { + using (var uow = Factory.Create()) + { + Expression> instanceSelector; + // Select by type or type id + if (typeProperty == null || typeProperty.Name == nameof(IProductType.Id)) + { + var productTypeId = (value as IProductType)?.Id ?? (long)value; + instanceSelector = i => i.ProductId == productTypeId; + } + else if (typeProperty.Name == nameof(IProductType.Name)) + { + var productName = (string)value; + instanceSelector = i => i.Product.Name == productName; + } + else if (typeProperty.Name == nameof(IProductType.Identity)) + { + var productIdentity = (ProductIdentity)value; + instanceSelector = i => i.Product.Identifier == productIdentity.Identifier && i.Product.Revision == productIdentity.Revision; + } + else + { + // TODO: Filter by type specific properties + var productType = typeProperty.ReflectedType; + instanceSelector = i => false; + } + + var repo = uow.GetRepository(); + var entities = repo.Linq.Where(instanceSelector).ToList(); + + + return TransformInstances(uow, entities).OfType().ToList(); + } + } + public IReadOnlyList LoadWithStrategy(Expression> selector) { using (var uow = Factory.Create()) @@ -664,7 +770,7 @@ public IReadOnlyList LoadWithStrategy(Expression() // Create query : query.Union(repo.Linq.Where(queryFilter).Cast()); // Append query } @@ -673,7 +779,7 @@ public IReadOnlyList LoadWithStrategy(Expression entities; if (query == null || (entities = query.ToList()).Count == 0) return new TInstance[0]; - + var instances = TransformInstances(uow, entities).OfType().ToArray(); // Final check against compiled expression var compiledSelector = selector.Compile(); @@ -752,7 +858,7 @@ private void TransformInstance(IUnitOfWork uow, ProductInstanceEntity entity, Pr TransformInstance(uow, partEntity, part); } } - else if(linkStrategy.PartCreation == PartSourceStrategy.FromEntities) + else if (linkStrategy.PartCreation == PartSourceStrategy.FromEntities) { // Load part using the entity and assign PartLink afterwards var partCollection = partEntityGroups[partGroup.Key.Name].ToList(); diff --git a/src/Moryx.Products.Management/Modification/Endpoint/IProductInteraction.cs b/src/Moryx.Products.Management/Modification/Endpoint/IProductInteraction.cs index 45a81d0c..945f86ca 100644 --- a/src/Moryx.Products.Management/Modification/Endpoint/IProductInteraction.cs +++ b/src/Moryx.Products.Management/Modification/Endpoint/IProductInteraction.cs @@ -14,7 +14,7 @@ namespace Moryx.Products.Management.Modification { #if USE_WCF [ServiceContract] - [ServiceVersion("5.0.0")] + [ServiceVersion("5.1.0")] #endif internal interface IProductInteraction { diff --git a/src/Moryx.Products.Management/Modification/Endpoint/ProductInteraction.cs b/src/Moryx.Products.Management/Modification/Endpoint/ProductInteraction.cs index 67555d5d..bd0dc01c 100644 --- a/src/Moryx.Products.Management/Modification/Endpoint/ProductInteraction.cs +++ b/src/Moryx.Products.Management/Modification/Endpoint/ProductInteraction.cs @@ -62,12 +62,7 @@ public ProductCustomization GetCustomization() { ProductTypes = ReflectionTool .GetPublicClasses(new IsConfiguredFilter(Config.TypeStrategies).IsConfigured) - .Select(pt => new ProductDefinitionModel - { - Name = pt.Name, - DisplayName = pt.GetDisplayName() ?? pt.Name, - BaseDefinition = pt.BaseType?.Name - }).ToArray(), + .Select(Converter.ConvertProductType).ToArray(), RecipeTypes = ReflectionTool .GetPublicClasses(new IsConfiguredFilter(Config.RecipeStrategies).IsConfigured) .Select(rt => new RecipeDefinitionModel diff --git a/src/Moryx.Products.Management/Modification/IProductConverter.cs b/src/Moryx.Products.Management/Modification/IProductConverter.cs index 5ef492f3..f66ba2cc 100644 --- a/src/Moryx.Products.Management/Modification/IProductConverter.cs +++ b/src/Moryx.Products.Management/Modification/IProductConverter.cs @@ -1,7 +1,7 @@ // Copyright (c) 2020, Phoenix Contact GmbH & Co. KG // Licensed under the Apache License, Version 2.0 -using Moryx.AbstractionLayer; +using System; using Moryx.AbstractionLayer.Products; using Moryx.AbstractionLayer.Recipes; using Moryx.Workflows; @@ -12,6 +12,8 @@ internal interface IProductConverter { ProductModel ConvertProduct(IProductType productType, bool flat); + ProductDefinitionModel ConvertProductType(Type productType); + IProductType ConvertProductBack(ProductModel source, ProductType target); RecipeModel ConvertRecipe(IRecipe recipe); diff --git a/src/Moryx.Products.Management/Modification/Model/ProductDefinitionModel.cs b/src/Moryx.Products.Management/Modification/Model/ProductDefinitionModel.cs index a9b9e9e4..65a19fc0 100644 --- a/src/Moryx.Products.Management/Modification/Model/ProductDefinitionModel.cs +++ b/src/Moryx.Products.Management/Modification/Model/ProductDefinitionModel.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0 using System.Runtime.Serialization; +using Moryx.Serialization; namespace Moryx.Products.Management.Modification { @@ -16,5 +17,8 @@ internal class ProductDefinitionModel [DataMember] public string BaseDefinition { get; set; } + + [DataMember] + public Entry Properties { get; set; } } } diff --git a/src/Moryx.Products.Management/Modification/Model/ProductFileModel.cs b/src/Moryx.Products.Management/Modification/Model/ProductFileModel.cs new file mode 100644 index 00000000..da0ed6a8 --- /dev/null +++ b/src/Moryx.Products.Management/Modification/Model/ProductFileModel.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2020, Phoenix Contact GmbH & Co. KG +// Licensed under the Apache License, Version 2.0 + +using System.Runtime.Serialization; + +namespace Moryx.Products.Management.Modification +{ + [DataContract] + internal class ProductFileModel + { + [DataMember] + public string PropertyName { get; set; } + + [DataMember] + public string FileName { get; set; } + + [DataMember] + public string MimeType { get; set; } + + [DataMember] + public string FilePath { get; set; } + + [DataMember] + public string FileHash { get; set; } + } +} diff --git a/src/Moryx.Products.Management/Modification/Model/ProductModel.cs b/src/Moryx.Products.Management/Modification/Model/ProductModel.cs index 425fada7..d7c4ba0f 100644 --- a/src/Moryx.Products.Management/Modification/Model/ProductModel.cs +++ b/src/Moryx.Products.Management/Modification/Model/ProductModel.cs @@ -1,6 +1,7 @@ // Copyright (c) 2020, Phoenix Contact GmbH & Co. KG // Licensed under the Apache License, Version 2.0 +using System; using System.Runtime.Serialization; using Moryx.AbstractionLayer.Products; using Moryx.Serialization; @@ -31,9 +32,14 @@ internal class ProductModel [DataMember] public Entry Properties { get; set; } + // TODO: AL6 Remove Files and rename FileModels to Files [DataMember] + [Obsolete("Use FileModels Instead")] public ProductFile[] Files { get; set; } + [DataMember] + public ProductFileModel[] FileModels { get; set; } + [DataMember] public PartConnector[] Parts { get; set; } diff --git a/src/Moryx.Products.Management/Modification/Model/RecipeModel.cs b/src/Moryx.Products.Management/Modification/Model/RecipeModel.cs index 3b7aa4a0..227e8af7 100644 --- a/src/Moryx.Products.Management/Modification/Model/RecipeModel.cs +++ b/src/Moryx.Products.Management/Modification/Model/RecipeModel.cs @@ -62,5 +62,11 @@ internal class RecipeModel /// [DataMember] public RecipeClassificationModel Classification { get; set; } + + /// + /// Whether this Recipe is a clone or not + /// + [DataMember] + public bool IsClone{ get; set; } } } diff --git a/src/Moryx.Products.Management/Modification/ProductConverter.cs b/src/Moryx.Products.Management/Modification/ProductConverter.cs index 7d9a3bc3..2c26c9da 100644 --- a/src/Moryx.Products.Management/Modification/ProductConverter.cs +++ b/src/Moryx.Products.Management/Modification/ProductConverter.cs @@ -39,7 +39,7 @@ internal class ProductConverter : IProductConverter #endregion #region To Model - + public ProductModel ConvertProduct(IProductType productType, bool flat) { // Base object @@ -62,7 +62,10 @@ public ProductModel ConvertProduct(IProductType productType, bool flat) converted.Properties = EntryConvert.EncodeObject(productType, ProductSerialization); // Files - converted.Files = ConvertFiles(productType, properties); + converted.Files = (from property in properties + where property.PropertyType == typeof(ProductFile) + select (ProductFile)property.GetValue(productType)).ToArray(); + converted.FileModels = ConvertFiles(productType, properties); // Recipes var recipes = RecipeManagement.GetAllByProduct(productType); @@ -74,12 +77,34 @@ public ProductModel ConvertProduct(IProductType productType, bool flat) return converted; } - private static ProductFile[] ConvertFiles(IProductType productType, IEnumerable properties) + public ProductDefinitionModel ConvertProductType(Type productType) + { + return new() + { + Name = productType.Name, + DisplayName = productType.GetDisplayName() ?? productType.Name, + BaseDefinition = productType.BaseType?.Name, + Properties = EntryConvert.EncodeClass(productType, ProductSerialization) + }; + } + + private ProductFileModel[] ConvertFiles(IProductType productType, IEnumerable properties) { - var files = (from property in properties - where property.PropertyType == typeof(ProductFile) - select (ProductFile)property.GetValue(productType)).ToArray(); - return files; + var productFileProperties = properties.Where(p => p.PropertyType == typeof(ProductFile)).ToArray(); + var fileModels = new ProductFileModel[productFileProperties.Length]; + for (int i = 0; i < fileModels.Length; i++) + { + var value = (ProductFile)productFileProperties[i].GetValue(productType); + fileModels[i] = new ProductFileModel() + { + PropertyName = productFileProperties[i].Name, + FileName = value?.Name, + FileHash = value?.FileHash, + FilePath = value?.FilePath, + MimeType = value?.MimeType + }; + } + return fileModels; } private void ConvertParts(IProductType productType, IEnumerable properties, ProductModel converted) @@ -98,7 +123,7 @@ private void ConvertParts(IProductType productType, IEnumerable pr Name = property.Name, DisplayName = displayName, Type = FetchProductType(property.PropertyType), - Parts = partModel != null ? new[] { partModel } : new PartModel[0], + Parts = partModel is null ? new PartModel[0] : new[] { partModel }, PropertyTemplates = EntryConvert.EncodeClass(property.PropertyType, ProductSerialization) }; connectors.Add(connector); @@ -113,7 +138,7 @@ private void ConvertParts(IProductType productType, IEnumerable pr Name = property.Name, DisplayName = displayName, Type = FetchProductType(linkType), - Parts = links.Select(ConvertPart).ToArray(), + Parts = links?.Select(ConvertPart).ToArray(), PropertyTemplates = EntryConvert.EncodeClass(linkType, ProductSerialization) }; connectors.Add(connector); @@ -125,7 +150,7 @@ private void ConvertParts(IProductType productType, IEnumerable pr private PartModel ConvertPart(IProductPartLink link) { // No link, no DTO! - if (link == null) + if (link is null || link.Product is null) return null; var part = new PartModel @@ -155,6 +180,7 @@ public RecipeModel ConvertRecipe(IRecipe recipe) State = recipe.State, Revision = recipe.Revision, Properties = EntryConvert.EncodeObject(recipe, RecipeSerialization), + IsClone = recipe.Classification.HasFlag(RecipeClassification.Clone) }; switch (recipe.Classification & RecipeClassification.CloneFilter) @@ -251,16 +277,10 @@ public IProductType ConvertProductBack(ProductModel source, ProductType converte converted.Identity = new ProductIdentity(source.Identifier, source.Revision); converted.Name = source.Name; converted.State = source.State; - - // Copy extended properties - var properties = converted.GetType().GetProperties(); - EntryConvert.UpdateInstance(converted, source.Properties, ProductSerialization); - - ConvertFilesBack(converted, source, properties); - + // Save recipes - var recipes = new List(source.Recipes.Length); - foreach (var recipeModel in source.Recipes) + var recipes = new List(source.Recipes?.Length ?? 0); + foreach (var recipeModel in source.Recipes ?? Enumerable.Empty()) { IProductRecipe productRecipe; if (recipeModel.Id == 0) @@ -274,15 +294,36 @@ public IProductType ConvertProductBack(ProductModel source, ProductType converte ConvertRecipeBack(recipeModel, productRecipe, converted); recipes.Add(productRecipe); } - RecipeManagement.Save(source.Id, recipes); + if (recipes.Any()) + RecipeManagement.Save(source.Id, recipes); + + // Product is flat + if (source.Properties is null) + return converted; + + // Copy extended properties + var properties = converted.GetType().GetProperties(); + EntryConvert.UpdateInstance(converted, source.Properties, ProductSerialization); + + // Copy Files + ConvertFilesBack(converted, source, properties); // Convert parts - foreach (var partConnector in source.Parts) + foreach (var partConnector in source.Parts ?? Enumerable.Empty()) { + if (partConnector.Parts is null) + continue; + var prop = properties.First(p => p.Name == partConnector.Name); var value = prop.GetValue(converted); if (partConnector.IsCollection) { + if (value == null) + { + value = Activator.CreateInstance(typeof(List<>) + .MakeGenericType(prop.PropertyType.GetGenericArguments().First())); + prop.SetValue(converted, value); + } UpdateCollection((IList)value, partConnector.Parts); } else if (partConnector.Parts.Length == 1) @@ -309,21 +350,25 @@ private void UpdateCollection(IList value, IEnumerable parts) var unused = new List(value.OfType()); // Iterate over the part models // Create or update the part links - var elemType = value.GetType().GetGenericArguments()[0]; + var elemType = value.GetType().GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IList<>)) + .Select(i => i.GetGenericArguments()[0]).Single(); foreach (var partModel in parts) { - var match = unused.Find(r => r.Id == partModel.Id); + if (partModel is null) + continue; + + var match = unused.Find(r => r.Id == partModel?.Id); if (match == null) { match = (IProductPartLink)Activator.CreateInstance(elemType); value.Add(match); } else - { unused.Remove(match); - } + EntryConvert.UpdateInstance(match, partModel.Properties); - match.Product = (ProductType)ProductManager.LoadType(partModel.Product.Id); + match.Product = ProductManager.LoadType(partModel.Product.Id); } // Clear all values no longer present in the model @@ -334,15 +379,25 @@ private void UpdateCollection(IList value, IEnumerable parts) private void UpdateReference(IProductPartLink value, PartModel part) { EntryConvert.UpdateInstance(value, part.Properties); - value.Product = (ProductType)ProductManager.LoadType(part.Product.Id); + value.Product = part.Product is null ? null : ProductManager.LoadType(part.Product.Id); } private static void ConvertFilesBack(object converted, ProductModel product, PropertyInfo[] properties) { - foreach (var fileModel in product.Files) + foreach (var fileModel in product.FileModels) { - var prop = properties.First(p => p.Name == fileModel.Name); - prop.SetValue(converted, fileModel); + var prop = properties.Single(p => p.Name == fileModel.PropertyName); + var productFile = new ProductFile() + { + MimeType = fileModel.MimeType, + FilePath = fileModel.FilePath, + FileHash = fileModel.FileHash, + Name = fileModel.FileName + }; + if (productFile.GetType().GetProperties().All(p => p.GetValue(productFile) is null)) + prop.SetValue(converted, null); + else + prop.SetValue(converted, productFile); } } #endregion diff --git a/src/Moryx.Products.Management/Plugins/GenericStrategies/GenericTypeStrategy.cs b/src/Moryx.Products.Management/Plugins/GenericStrategies/GenericTypeStrategy.cs index 78b32776..a9dfb890 100644 --- a/src/Moryx.Products.Management/Plugins/GenericStrategies/GenericTypeStrategy.cs +++ b/src/Moryx.Products.Management/Plugins/GenericStrategies/GenericTypeStrategy.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Linq.Expressions; using Moryx.AbstractionLayer; using Moryx.AbstractionLayer.Products; using Moryx.Container; @@ -20,7 +21,7 @@ namespace Moryx.Products.Management [ExpectedConfig(typeof(GenericTypeConfiguration))] [StrategyConfiguration(typeof(IProductType), DerivedTypes = true)] [Plugin(LifeCycle.Transient, typeof(IProductTypeStrategy), Name = nameof(GenericTypeStrategy))] - internal class GenericTypeStrategy : TypeStrategyBase + internal class GenericTypeStrategy : TypeStrategyBase, IProductTypeSearch { /// /// Injected entity mapper @@ -37,6 +38,11 @@ public override void Initialize(ProductTypeConfiguration config) EntityMapper.Initialize(TargetType, Config); } + public Expression> TransformSelector(Expression> selector) + { + return EntityMapper.TransformSelector(selector); + } + public override bool HasChanged(IProductType current, IGenericColumns dbProperties) { return EntityMapper.HasChanged(dbProperties, current); diff --git a/src/Moryx.Products.Management/Properties/AssemblyInfo.cs b/src/Moryx.Products.Management/Properties/AssemblyInfo.cs index ba4929bd..963dc772 100644 --- a/src/Moryx.Products.Management/Properties/AssemblyInfo.cs +++ b/src/Moryx.Products.Management/Properties/AssemblyInfo.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] [assembly: InternalsVisibleTo("Moryx.Products.IntegrationTests")] +[assembly: InternalsVisibleTo("Moryx.Products.Management.Tests")] diff --git a/src/Moryx.Products.UI.Interaction/ModuleController/ModuleConfig.cs b/src/Moryx.Products.UI.Interaction/ModuleController/ModuleConfig.cs index c753a320..8d12be75 100644 --- a/src/Moryx.Products.UI.Interaction/ModuleController/ModuleConfig.cs +++ b/src/Moryx.Products.UI.Interaction/ModuleController/ModuleConfig.cs @@ -66,5 +66,11 @@ public ModuleConfig() /// [DataMember] public List DefaultAspects { get; set; } + + /// + /// Set to true to show the product types as a tree instead of a flat list + /// + [DataMember] + public bool ShowProductTypeTree { get; set; } } } diff --git a/src/Moryx.Products.UI.Interaction/Moryx.Products.UI.Interaction.csproj.DotSettings b/src/Moryx.Products.UI.Interaction/Moryx.Products.UI.Interaction.csproj.DotSettings index 78a064f5..b1f67e85 100644 --- a/src/Moryx.Products.UI.Interaction/Moryx.Products.UI.Interaction.csproj.DotSettings +++ b/src/Moryx.Products.UI.Interaction/Moryx.Products.UI.Interaction.csproj.DotSettings @@ -13,7 +13,9 @@ True True True + True True + True True True True diff --git a/src/Moryx.Products.UI.Interaction/Products/Aspects/Parts/CollectionPartConnectorViewModel.cs b/src/Moryx.Products.UI.Interaction/Products/Aspects/Parts/CollectionPartConnectorViewModel.cs index 4b643d83..20c25916 100644 --- a/src/Moryx.Products.UI.Interaction/Products/Aspects/Parts/CollectionPartConnectorViewModel.cs +++ b/src/Moryx.Products.UI.Interaction/Products/Aspects/Parts/CollectionPartConnectorViewModel.cs @@ -74,13 +74,14 @@ public override void BeginEdit() { base.BeginEdit(); - PartLinks.ForEach(p => p.BeginEdit()); + PartLinks.BeginEdit(); + PartConnector.BeginEdit(); } public override void EndEdit() { // End on part links - PartLinks.ForEach(p => p.EndEdit()); + PartLinks.EndEdit(); // Add new links var newLinks = _newLinks.Select(n => n.PartLink); @@ -91,6 +92,7 @@ public override void EndEdit() PartConnector.PartLinks.RemoveRange(_removedLinks.Select(l => l.PartLink)); _removedLinks.Clear(); + PartConnector.EndEdit(); base.EndEdit(); } @@ -105,8 +107,8 @@ public override void CancelEdit() _removedLinks.Clear(); // Cancel on existent - PartLinks.ForEach(p => p.CancelEdit()); - + PartLinks.CancelEdit(); + PartConnector.CancelEdit(); base.CancelEdit(); } diff --git a/src/Moryx.Products.UI.Interaction/Products/Aspects/Parts/SinglePartConnectorPartViewModel.cs b/src/Moryx.Products.UI.Interaction/Products/Aspects/Parts/SinglePartConnectorPartViewModel.cs index 5895bc97..bb03a854 100644 --- a/src/Moryx.Products.UI.Interaction/Products/Aspects/Parts/SinglePartConnectorPartViewModel.cs +++ b/src/Moryx.Products.UI.Interaction/Products/Aspects/Parts/SinglePartConnectorPartViewModel.cs @@ -68,12 +68,27 @@ public SinglePartConnectorPartViewModel(PartConnectorViewModel partConnector, Pa PartLink = partLink; } + public override void BeginEdit() + { + PartLink.BeginEdit(); + PartConnector.BeginEdit(); + base.BeginEdit(); + } + + public override void EndEdit() + { + PartLink.EndEdit(); + PartConnector.EndEdit(); + base.EndEdit(); + } + public override void CancelEdit() { // If single used, reset part link if (!PartConnector.IsCollection) PartLink = PartConnector.PartLinks.FirstOrDefault(); - + PartLink.CancelEdit(); + PartConnector.CancelEdit(); base.CancelEdit(); } diff --git a/src/Moryx.Products.UI.Interaction/Products/Filter/PropertyFilterDialogView.xaml b/src/Moryx.Products.UI.Interaction/Products/Filter/PropertyFilterDialogView.xaml new file mode 100644 index 00000000..0c0a639c --- /dev/null +++ b/src/Moryx.Products.UI.Interaction/Products/Filter/PropertyFilterDialogView.xaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Moryx.Products.UI.Interaction/Products/Filter/PropertyFilterDialogView.xaml.cs b/src/Moryx.Products.UI.Interaction/Products/Filter/PropertyFilterDialogView.xaml.cs new file mode 100644 index 00000000..a13dbe41 --- /dev/null +++ b/src/Moryx.Products.UI.Interaction/Products/Filter/PropertyFilterDialogView.xaml.cs @@ -0,0 +1,15 @@ +using System.Windows.Controls; + +namespace Moryx.Products.UI.Interaction +{ + /// + /// Interaction logic for PropertyFilterDialogView.xaml + /// + public partial class PropertyFilterDialogView : UserControl + { + public PropertyFilterDialogView() + { + InitializeComponent(); + } + } +} diff --git a/src/Moryx.Products.UI.Interaction/Products/Filter/PropertyFilterDialogViewModel.cs b/src/Moryx.Products.UI.Interaction/Products/Filter/PropertyFilterDialogViewModel.cs new file mode 100644 index 00000000..93201851 --- /dev/null +++ b/src/Moryx.Products.UI.Interaction/Products/Filter/PropertyFilterDialogViewModel.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.Linq; +using System.Windows.Input; +using Moryx.ClientFramework.Dialog; +using Moryx.Controls; +using Moryx.Products.UI.Interaction.Properties; +using Moryx.Serialization; +using Moryx.WpfToolkit; +using Entry = Moryx.Serialization.Entry; + +namespace Moryx.Products.UI.Interaction +{ + internal class PropertyFilterDialogViewModel : DialogScreen + { + private ProductDefinitionViewModel _productType; + private EntryViewModel _currentFilter; + + public ICommand ApplyCmd { get; } + + public ICommand CancelCmd { get; } + + public ICommand AddCmd { get; set; } + + public EntryViewModel[] PossibleProperties { get; set; } + + public ProductDefinitionViewModel ProductType + { + get => _productType; + set + { + _productType = value; + NotifyOfPropertyChange(); + } + } + + public EntryViewModel CurrentFilter + { + get => _currentFilter; + set + { + _currentFilter = value; + NotifyOfPropertyChange(); + } + } + + public PropertyFilterDialogViewModel(ProductDefinitionViewModel productType) + { + ProductType = productType; + CurrentFilter = new EntryViewModel(new List()); + + // Currently no collections or special units are not supported + PossibleProperties = productType.Properties.SubEntries + .Where(e => e.Entry.Value.Type != EntryValueType.Collection && + e.Entry.Value.UnitType == EntryUnitType.None).ToArray(); + + AddCmd = new RelayCommand(Add, CanAdd); + ApplyCmd = new RelayCommand(Apply); + CancelCmd = new RelayCommand(Cancel); + } + + protected override void OnInitialize() + { + base.OnInitialize(); + + DisplayName = string.Format(Strings.PropertyFilterDialogViewModel_DisplayName, ProductType.DisplayName); + } + + private bool CanAdd(object parameters) => + parameters is EntryViewModel; + + private void Add(object obj) + { + var entry = ((EntryViewModel) obj).Entry; + CurrentFilter.SubEntries.Add(new EntryViewModel(entry)); + } + + private void Apply(object obj) + { + TryClose(true); + } + + private void Cancel(object obj) + { + TryClose(false); + } + } +} diff --git a/src/Moryx.Products.UI.Interaction/Products/Import/ImportParameterViewModel.cs b/src/Moryx.Products.UI.Interaction/Products/Import/ImportParameterViewModel.cs deleted file mode 100644 index cf202b4b..00000000 --- a/src/Moryx.Products.UI.Interaction/Products/Import/ImportParameterViewModel.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2020, Phoenix Contact GmbH & Co. KG -// Licensed under the Apache License, Version 2.0 - -using System; -using System.ComponentModel; -using Moryx.Controls; -using Entry = Moryx.Serialization.Entry; - -namespace Moryx.Products.UI.Interaction -{ - /// - /// Class holding importer parameter - /// - internal class ImportParameterViewModel : EntryViewModel - { - /// - /// Constructor - /// - /// Importer parameter - public ImportParameterViewModel(Entry property) : base(property) - { - Model = property; - - PropertyChanged += OnPropertyChanged; - } - - /// - /// Listen to property changed and check if the value was modified. In that case - /// invoke the value changed event - /// - /// - /// - private void OnPropertyChanged(object sender, PropertyChangedEventArgs propertyChangedEventArgs) - { - if (propertyChangedEventArgs.PropertyName == nameof(Value)) - ValueChanged?.Invoke(this, Model); - } - - /// - /// Base importer parameter - /// - internal Entry Model { get; } - - /// - /// Event raised when the values was changed - /// - public event EventHandler ValueChanged; - } -} diff --git a/src/Moryx.Products.UI.Interaction/Products/Import/ImporterViewModel.cs b/src/Moryx.Products.UI.Interaction/Products/Import/ImporterViewModel.cs index d8f01693..3bc95e8e 100644 --- a/src/Moryx.Products.UI.Interaction/Products/Import/ImporterViewModel.cs +++ b/src/Moryx.Products.UI.Interaction/Products/Import/ImporterViewModel.cs @@ -1,12 +1,13 @@ // Copyright (c) 2020, Phoenix Contact GmbH & Co. KG // Licensed under the Apache License, Version 2.0 -using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using Caliburn.Micro; using Moryx.Controls; using Moryx.Products.UI.ProductService; +using Moryx.Tools; namespace Moryx.Products.UI.Interaction { @@ -26,31 +27,30 @@ public ImporterViewModel(ProductImporter importer, IProductServiceModel productS private void CreateParameterViewModel(Entry parameters) { - if (Parameters != null) - { - foreach (var entry in Parameters.SubEntries.Cast()) - { - entry.ValueChanged -= OnUpdateTriggerChanged; - } - } + Parameters = new EntryViewModel(parameters.ToSerializationEntry()); + Parameters.SubEntries.ForEach(e => e.PropertyChanged += OnUpdateTriggerChanged); + } - Parameters = new EntryViewModel(new Serialization.Entry { DisplayName = "Root" }); - foreach (var parameter in parameters.SubEntries) - { - var viewModel = new ImportParameterViewModel(parameter.ToSerializationEntry()); - viewModel.ValueChanged += OnUpdateTriggerChanged; - Parameters.SubEntries.Add(viewModel); - } + private void UpdateParameterViewModel(Entry parameters) + { + Parameters.SubEntries.ForEach(e => e.PropertyChanged -= OnUpdateTriggerChanged); + Parameters.UpdateModel(parameters.ToSerializationEntry()); + Parameters.SubEntries.ForEach(e => e.PropertyChanged += OnUpdateTriggerChanged); } /// - /// Update parameters if a was modified + /// Update parameters if a was modified /// - private async void OnUpdateTriggerChanged(object sender, Serialization.Entry importParameter) + private async void OnUpdateTriggerChanged(object sender, PropertyChangedEventArgs propertyChangedEventArgs) { + var entry = sender as EntryViewModel; + if (sender is null || propertyChangedEventArgs.PropertyName != nameof(EntryViewModel.Value)) + return; + + Parameters.EndEdit(); var parameters = Parameters.Entry; var updatedParameters = await _productServiceModel.UpdateImportParameters(_importer.Name, parameters.ToServiceEntry()); - CreateParameterViewModel(updatedParameters); + UpdateParameterViewModel(updatedParameters); } /// @@ -69,8 +69,13 @@ public EntryViewModel Parameters { if (Equals(value, _parameters)) return; - _parameters = value; - NotifyOfPropertyChange(); + if (_parameters is null || value is null) + { + _parameters = value; + NotifyOfPropertyChange(); + } + else + _parameters.UpdateModel(value.Entry); } } diff --git a/src/Moryx.Products.UI.Interaction/Products/ProductsWorkspaceView.xaml b/src/Moryx.Products.UI.Interaction/Products/ProductsWorkspaceView.xaml index b7f68504..cfd1e384 100644 --- a/src/Moryx.Products.UI.Interaction/Products/ProductsWorkspaceView.xaml +++ b/src/Moryx.Products.UI.Interaction/Products/ProductsWorkspaceView.xaml @@ -105,7 +105,7 @@ - - - - - - + + + + + + + + + + + - + - + + + + + + + + + @@ -230,7 +258,7 @@ CancelEditCmd="{Binding CancelEditCmd}" CancelEditContent="{x:Static properties:Strings.ProductsWorkspaceView_Cancel}" SaveCmd="{Binding SaveCmd}" - SaveContent="{x:Static properties:Strings.ProductsWorkspaceView_Save}" + SaveContent="{x:Static properties:Strings.ProductsWorkspaceView_Save}" Visibility="{principals:VisibilityPermission Action={x:Static interaction:Permissions.CanEdit}}"/> -