diff --git a/.editorconfig b/.editorconfig index 2250cdba0..e0d8c1a2e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,5 @@ [*.cs] +file_header_template = Copyright (c) MASA Stack All rights reserved.\nLicensed under the MIT License. See LICENSE.txt in the project root for license information. # CS8618: 在退出构造函数时,不可为 null 的字段必须包含非 null 值。请考虑声明为可以为 null。 dotnet_diagnostic.CS8618.severity = none @@ -29,4 +30,4 @@ indent_size = 2 # JSON files [*.json] -indent_size = 2 \ No newline at end of file +indent_size = 2 diff --git a/Masa.Contrib.sln b/Masa.Contrib.sln index 8c8c7781c..401db2919 100644 --- a/Masa.Contrib.sln +++ b/Masa.Contrib.sln @@ -172,6 +172,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Storage", "Storage", "{1653 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Masa.Contrib.Configuration.AutoMap.NoArgumentConstructor.Tests", "test\Masa.Contrib.Configuration.AutoMap.NoArgumentConstructor.Tests\Masa.Contrib.Configuration.AutoMap.NoArgumentConstructor.Tests.csproj", "{B8358ED1-C95A-4EC0-9756-FB32C931F204}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Masa.BuildingBlocks.Data.Mapping", "src\BuildingBlocks\MASA.BuildingBlocks\src\Data\Masa.BuildingBlocks.Data.Mapping\Masa.BuildingBlocks.Data.Mapping.csproj", "{5A3338F1-9963-4CAC-85A3-7AB263CB15B0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Masa.Contrib.Data.Mapping.Mapster.Tests", "test\Masa.Contrib.Data.Mapping.Mapster.Tests\Masa.Contrib.Data.Mapping.Mapster.Tests.csproj", "{834A12D0-FBED-45B3-86EA-5EA114C516B5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mapping", "Mapping", "{4AC23B67-52F9-44E5-9586-79A1DB73E6F7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Masa.Contrib.Data.Mapping.Mapster", "src\Data\Mapping\Masa.Contrib.Data.Mapping.Mapster\Masa.Contrib.Data.Mapping.Mapster.csproj", "{D5EA7A25-0FD2-4545-9C1C-FF96E5E35145}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -676,6 +684,30 @@ Global {B8358ED1-C95A-4EC0-9756-FB32C931F204}.Release|Any CPU.Build.0 = Release|Any CPU {B8358ED1-C95A-4EC0-9756-FB32C931F204}.Release|x64.ActiveCfg = Release|Any CPU {B8358ED1-C95A-4EC0-9756-FB32C931F204}.Release|x64.Build.0 = Release|Any CPU + {5A3338F1-9963-4CAC-85A3-7AB263CB15B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A3338F1-9963-4CAC-85A3-7AB263CB15B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A3338F1-9963-4CAC-85A3-7AB263CB15B0}.Debug|x64.ActiveCfg = Debug|Any CPU + {5A3338F1-9963-4CAC-85A3-7AB263CB15B0}.Debug|x64.Build.0 = Debug|Any CPU + {5A3338F1-9963-4CAC-85A3-7AB263CB15B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A3338F1-9963-4CAC-85A3-7AB263CB15B0}.Release|Any CPU.Build.0 = Release|Any CPU + {5A3338F1-9963-4CAC-85A3-7AB263CB15B0}.Release|x64.ActiveCfg = Release|Any CPU + {5A3338F1-9963-4CAC-85A3-7AB263CB15B0}.Release|x64.Build.0 = Release|Any CPU + {834A12D0-FBED-45B3-86EA-5EA114C516B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {834A12D0-FBED-45B3-86EA-5EA114C516B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {834A12D0-FBED-45B3-86EA-5EA114C516B5}.Debug|x64.ActiveCfg = Debug|Any CPU + {834A12D0-FBED-45B3-86EA-5EA114C516B5}.Debug|x64.Build.0 = Debug|Any CPU + {834A12D0-FBED-45B3-86EA-5EA114C516B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {834A12D0-FBED-45B3-86EA-5EA114C516B5}.Release|Any CPU.Build.0 = Release|Any CPU + {834A12D0-FBED-45B3-86EA-5EA114C516B5}.Release|x64.ActiveCfg = Release|Any CPU + {834A12D0-FBED-45B3-86EA-5EA114C516B5}.Release|x64.Build.0 = Release|Any CPU + {D5EA7A25-0FD2-4545-9C1C-FF96E5E35145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5EA7A25-0FD2-4545-9C1C-FF96E5E35145}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5EA7A25-0FD2-4545-9C1C-FF96E5E35145}.Debug|x64.ActiveCfg = Debug|Any CPU + {D5EA7A25-0FD2-4545-9C1C-FF96E5E35145}.Debug|x64.Build.0 = Debug|Any CPU + {D5EA7A25-0FD2-4545-9C1C-FF96E5E35145}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5EA7A25-0FD2-4545-9C1C-FF96E5E35145}.Release|Any CPU.Build.0 = Release|Any CPU + {D5EA7A25-0FD2-4545-9C1C-FF96E5E35145}.Release|x64.ActiveCfg = Release|Any CPU + {D5EA7A25-0FD2-4545-9C1C-FF96E5E35145}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -761,6 +793,10 @@ Global {97532A33-A591-4DF5-A2C0-72527B78ED82} = {38E6C400-90C0-493E-9266-C1602E229F1B} {165391A5-034E-4894-8084-8DF7D4AA7518} = {42DF7AAC-362C-48F4-B76A-BDEEEFF17CC9} {B8358ED1-C95A-4EC0-9756-FB32C931F204} = {9EEE31DA-3165-4CB3-AAE9-27CC3A4DE669} + {5A3338F1-9963-4CAC-85A3-7AB263CB15B0} = {DC578D74-98F0-4F19-A230-CFA8DAEE0AF1} + {834A12D0-FBED-45B3-86EA-5EA114C516B5} = {38E6C400-90C0-493E-9266-C1602E229F1B} + {4AC23B67-52F9-44E5-9586-79A1DB73E6F7} = {E33ADF54-4D35-49B7-BDA6-412587CA39FF} + {D5EA7A25-0FD2-4545-9C1C-FF96E5E35145} = {4AC23B67-52F9-44E5-9586-79A1DB73E6F7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {40383055-CC50-4600-AD9A-53C14F620D03} diff --git a/src/BuildingBlocks/MASA.BuildingBlocks b/src/BuildingBlocks/MASA.BuildingBlocks index d0443a3b2..0abbf7f2a 160000 --- a/src/BuildingBlocks/MASA.BuildingBlocks +++ b/src/BuildingBlocks/MASA.BuildingBlocks @@ -1 +1 @@ -Subproject commit d0443a3b2c1a82d1b2b4b4b3e6d163fb472f4960 +Subproject commit 0abbf7f2aab54d8cbb7b8be7fb064c7749f30c05 diff --git a/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/DefaultMapper.cs b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/DefaultMapper.cs new file mode 100644 index 000000000..ed893e326 --- /dev/null +++ b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/DefaultMapper.cs @@ -0,0 +1,34 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster; + +public class DefaultMapper : IMapper +{ + private readonly IMappingConfigProvider _provider; + + public DefaultMapper(IMappingConfigProvider provider) + => _provider = provider; + + public TDestination Map(TSource source, MapOptions? options = null) + { + ArgumentNullException.ThrowIfNull(source, nameof(source)); + + return source.Adapt(_provider.GetConfig(source.GetType(), typeof(TDestination), options)); + } + + public TDestination Map(object source, MapOptions? options = null) + { + ArgumentNullException.ThrowIfNull(source, nameof(source)); + + return source.Adapt(_provider.GetConfig(source.GetType(), typeof(TDestination), options)); + } + + public TDestination Map(TSource source, TDestination destination, MapOptions? options = null) + { + ArgumentNullException.ThrowIfNull(source, nameof(source)); + + Type destinationType = destination?.GetType() ?? typeof(TDestination); + return source.Adapt(destination, _provider.GetConfig(source.GetType(), destinationType, options)); + } +} diff --git a/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/DefaultMappingConfigProvider.cs b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/DefaultMappingConfigProvider.cs new file mode 100644 index 000000000..2bc9e9e48 --- /dev/null +++ b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/DefaultMappingConfigProvider.cs @@ -0,0 +1,187 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster; + +public class DefaultMappingConfigProvider : IMappingConfigProvider +{ + private readonly ConcurrentDictionary<(Type SourceType, Type DestinationType, MapOptions? MapOptions), TypeAdapterConfig?> _store = new(); + + private readonly MapOptions _options; + + public DefaultMappingConfigProvider(MapOptions options) => _options = options; + + public TypeAdapterConfig GetConfig(Type sourceType, Type destinationType, MapOptions? options = null) + => GetConfigByCache(sourceType, destinationType, options); + + protected virtual TypeAdapterConfig GetConfigByCache(Type sourceType, Type destinationType, MapOptions? options) + { + TypeAdapterConfig? config = _store.GetOrAdd( + (sourceType, destinationType, options), + type => GetAdapterConfig(type.SourceType, type.DestinationType, options)); + + return config ?? GetDefaultConfig(options); + } + + protected virtual TypeAdapterConfig? GetAdapterConfig(Type sourceType, Type destinationType, MapOptions? options) + { + TypeAdapterConfig adapterConfig = GetDefaultConfig(options); + + var mapTypes = GetMapAndSelectorTypes(adapterConfig, sourceType, destinationType, options, true); + + foreach (var item in mapTypes) + { + var methodExecutor = InvokeBuilder.Build(item.SourceType, item.DestinationType); + methodExecutor.Invoke(adapterConfig, item.Constructor); + } + + return IsShare(options) ? null : adapterConfig; //When in shared mode, Config returns empty to save memory space + } + + //todo: In the follow-up, according to the situation, consider whether the configuration requires Fork, which is not processed for the time being + private List GetMapTypes( + TypeAdapterConfig adapterConfig, + Type sourceType, + Type destinationType, + MapOptions? options) + { + if (!NeedAutomaticMap(sourceType, destinationType)) + return new List(); + + List mapTypes = new(); + var sourceProperties = sourceType.GetProperties().ToList(); + var destinationProperties = destinationType.GetProperties().ToList(); + + List destinationConstructors = destinationType + .GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(c => c.GetParameters().Length <= sourceProperties.Count) + .OrderByDescending(c => c.GetParameters().Length) + .ToList(); + + MapTypeOptions mapTypeOption = new(sourceType, destinationType) + { + Constructor = GetBestConstructor(destinationConstructors, sourceProperties) + }; + if (!RuleMapIsExist(adapterConfig, sourceType, destinationType)) + { + mapTypes.Add(mapTypeOption); + } + + List<(string Name, Type DdestinationPropertyType)> destinationPropertyList = destinationProperties + .Select(p => (p.Name.ToLower(), p.PropertyType)) + .Concat(mapTypeOption.Constructor.GetParameters().Select(p => (p.Name!.ToLower(), p.ParameterType))!) + .Distinct() + .ToList(); + + foreach (var sourceProperty in sourceProperties) + { + if (!sourceProperty.CanRead) + continue; + + var destinationProperty = destinationPropertyList.FirstOrDefault(p + => p.Name.Equals(sourceProperty.Name, StringComparison.OrdinalIgnoreCase)); + if (destinationProperty != default) + { + var subMapTypes = GetMapAndSelectorTypes(adapterConfig, sourceProperty.PropertyType, + destinationProperty.DdestinationPropertyType, options, false); + + if (!subMapTypes.Any() || mapTypes.Any(option => subMapTypes.Any(subOption + => subOption.SourceType == option.SourceType && subOption.DestinationType == option.DestinationType))) + continue; + + mapTypes.AddRange(subMapTypes); + } + } + + return mapTypes; + } + + private List GetMapAndSelectorTypes(TypeAdapterConfig adapterConfig, Type sourceType, Type destinationType, + MapOptions? options, bool isFirst) + { + bool sourcePropertyIsEnumerable = IsCollection(sourceType); + bool destinationPropertyIsEnumerable = IsCollection(destinationType); + if (!sourcePropertyIsEnumerable && !destinationPropertyIsEnumerable) + { + var subMapTypes = GetMapTypes( + adapterConfig, + sourceType, + destinationType, + options); + if (subMapTypes.Any()) return subMapTypes; + } + else if (sourcePropertyIsEnumerable && destinationPropertyIsEnumerable) + { + var subMapTypes = GetMapTypes(adapterConfig, + sourceType.GetGenericArguments()[0], + destinationType.GetGenericArguments()[0], + options); + + if (subMapTypes.Any()) return subMapTypes; + } + return new(); + } + + protected virtual bool IsCollection(Type type) + => type.IsGenericType && type.GetInterfaces().Any(x => x.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + + protected virtual ConstructorInfo GetBestConstructor(List destinationConstructors, List sourceProperties) + { + if (destinationConstructors.Count <= 1) + return destinationConstructors.First(); + + foreach (var constructor in destinationConstructors) + { + if (IsPreciseMatch(constructor, sourceProperties)) + return constructor; + } + + throw new Exception("Failed to get the best constructor"); + } + + protected virtual bool IsPreciseMatch(ConstructorInfo destinationConstructor, List sourceProperties) + { + foreach (var parameter in destinationConstructor.GetParameters()) + { + if (!sourceProperties.Any(p + => p.Name.Equals(parameter.Name, StringComparison.OrdinalIgnoreCase) && p.PropertyType == parameter.ParameterType)) + { + return false; + } + } + return true; + } + + protected virtual List NotNeedAutomaticMapTypes => new() + { + typeof(string) + }; + + protected virtual bool NeedAutomaticMap(Type sourceType, Type destinationType) + => sourceType.IsClass && + !IsCollection(sourceType) && + (sourceType != destinationType || (sourceType != typeof(object) || destinationType != typeof(object))) && + !NotNeedAutomaticMapTypes.Contains(sourceType); + + protected virtual bool RuleMapIsExist(TypeAdapterConfig adapterConfig, Type sourceType, Type destinationType) + => adapterConfig.RuleMap.Any(r => r.Key == new TypeTuple(sourceType, destinationType)); + + protected virtual bool IsShare(MapOptions? options) => (options?.Mode ?? _options.Mode) == MapMode.Shared; + + /// + /// Get initial configuration + /// When currently in shared mode, return the default global settings + /// + /// + protected virtual TypeAdapterConfig GetDefaultConfig(MapOptions? options) + { + //todo: Other modes are currently not supported, and will be added in the future according to the situation + switch (options?.Mode ?? _options.Mode) + { + case MapMode.Shared: + return TypeAdapterConfig.GlobalSettings; + default: + throw new ArgumentException("Only shared configuration is supported", nameof(MapOptions.Mode)); + } + } +} diff --git a/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/IMappingConfigProvider.cs b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/IMappingConfigProvider.cs new file mode 100644 index 000000000..60e9aa742 --- /dev/null +++ b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/IMappingConfigProvider.cs @@ -0,0 +1,9 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster; + +public interface IMappingConfigProvider +{ + TypeAdapterConfig GetConfig(Type sourceType, Type destinationType, MapOptions? options = null); +} diff --git a/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Internal/InvokeBuilder.cs b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Internal/InvokeBuilder.cs new file mode 100644 index 000000000..9ae65c460 --- /dev/null +++ b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Internal/InvokeBuilder.cs @@ -0,0 +1,40 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Internal; + +internal class InvokeBuilder +{ + private static readonly MethodInfo _newConfigMethodInfo; + private static readonly Type _typeAdapterConfigType; + + static InvokeBuilder() + { + var typeAdapterSetterExpandType = typeof(TypeAdapterSetterExpand); + _newConfigMethodInfo = typeAdapterSetterExpandType.GetMethod(nameof(TypeAdapterSetterExpand.NewConfigByConstructor))!; + _typeAdapterConfigType = typeof(TypeAdapterConfig); + } + + internal delegate TypeAdapterSetter MethodExecutor(TypeAdapterConfig target, object parameter); + + public static MethodExecutor Build( + Type sourceType, + Type destinationType) + { + var methodInfo = _newConfigMethodInfo.MakeGenericMethod(sourceType, destinationType); + + ParameterExpression[] parameters = + { + Expression.Parameter(_typeAdapterConfigType, "adapterConfigParameter"), + Expression.Parameter(typeof(object), "constructorInfoParameter") + }; + var newConfigMethodCall = Expression.Call( + null, + methodInfo, + parameters + ); + + var lambda = Expression.Lambda(newConfigMethodCall, parameters); + return lambda.Compile(); + } +} diff --git a/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Internal/Options/MapTypeOptions.cs b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Internal/Options/MapTypeOptions.cs new file mode 100644 index 000000000..7f93638c2 --- /dev/null +++ b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Internal/Options/MapTypeOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Internal.Options; + +internal class MapTypeOptions +{ + public Type SourceType { get; } = default!; + + public Type DestinationType { get; } = default!; + + public ConstructorInfo Constructor { get; set; } = default!; + + public MapTypeOptions(Type sourceType, Type destinationType) + { + SourceType = sourceType; + DestinationType = destinationType; + } +} diff --git a/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Internal/TypeAdapterSetterExpand.cs b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Internal/TypeAdapterSetterExpand.cs new file mode 100644 index 000000000..f119e2d95 --- /dev/null +++ b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Internal/TypeAdapterSetterExpand.cs @@ -0,0 +1,15 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Internal; + +internal class TypeAdapterSetterExpand +{ + public static TypeAdapterSetter NewConfigByConstructor(TypeAdapterConfig adapterConfig, + object constructorInfo) + { + return adapterConfig + .NewConfig() + .MapToConstructor((constructorInfo as ConstructorInfo)!); + } +} diff --git a/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Masa.Contrib.Data.Mapping.Mapster.csproj b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Masa.Contrib.Data.Mapping.Mapster.csproj new file mode 100644 index 000000000..5fe32eab6 --- /dev/null +++ b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/Masa.Contrib.Data.Mapping.Mapster.csproj @@ -0,0 +1,18 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/README.md b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/README.md new file mode 100644 index 000000000..6be488089 --- /dev/null +++ b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/README.md @@ -0,0 +1,85 @@ +[中](README.zh-CN.md) | EN + +## Masa.Contrib.Data.Mapping.Mapster + +Masa.Contrib.Data.Mapping.Mapster is an object-to-object mapper based on [Mapster](https://github.com/MapsterMapper/Mapster). It adds automatic acquisition and uses the best constructor mapping on the original basis. Nested mapping is supported to reduce the workload of mapping. + +## Mapping Rules + +* When the target object has no constructor: use an empty constructor, which maps to fields and properties. + +* Target object has multiple constructors: get the best constructor map + + > Best constructor: The number of parameters of the target object constructor is searched in descending order, the parameter names are the same (case-insensitive), and the parameter types are the same as the source object properties + +## Example: + +1. Install `Masa.Contrib.Data.Mapping.Mapster` + + ````c# + Install-Package Masa.Contrib.Data.Mapping.Mapster + ```` + +2. Using `Mapping` + + ```` C# + builder.Services.AddMapping(); + ```` + +3. Mapping objects + + ```` + IMapper mapper;// Get through DI + + var request = new + { + Name = "Teach you to learn Dapr...", + OrderItem = new OrderItem("Teach you to learn Dapr hand by hand", 49.9m) + }; + var order = mapper.Map(request);// Map the request to a new object, Parameters with the same attribute name and type of the source object and the target object will be automatically mapped, or the constructor parameter name (case-insensitive) and type of the target object are the same as those of the source object, and they will be mapped through the constructor + ```` + + Mapping class `Order`: + + ```` Order.cs + public class Order + { + public string Name { get; set; } + + public decimal TotalPrice { get; set; } + + public List OrderItems { get; set; } + + public Order(string name) + { + Name = name; + } + + public Order(string name, OrderItem orderItem) : this(name) + { + OrderItems = new List { orderItem }; + TotalPrice = OrderItems.Sum(item => item.Price * item.Number); + } + } + + public class OrderItem + { + public string Name { get; set; } + + public decimal Price { get; set; } + + public int Number { get; set; } + + public OrderItem(string name, decimal price) : this(name, price, 1) + { + + } + + public OrderItem(string name, decimal price, int number) + { + Name = name; + Price = price; + Number = number; + } + } + ```` \ No newline at end of file diff --git a/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/README.zh-CN.md b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/README.zh-CN.md new file mode 100644 index 000000000..4fc18741b --- /dev/null +++ b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/README.zh-CN.md @@ -0,0 +1,86 @@ +中 | [EN](README.md) + +## Masa.Contrib.Data.Mapping.Mapster + +Masa.Contrib.Data.Mapping.Mapster是基于[Mapster](https://github.com/MapsterMapper/Mapster)的一个对象到对象的映射器,在原来的基础上增加自动获取并使用最佳构造函数映射,支持嵌套映射,减轻映射的工作量。 + +## 映射规则 + +* 目标对象没有构造函数时:使用空构造函数,映射到字段和属性。 + +* 目标对象存在多个构造函数:获取最佳构造函数映射 + + > 最佳构造函数: 目标对象构造函数参数数量从大到小降序查找,参数名称一致(不区分大小写)且参数类型与源对象属性一致 + +## 用例: + +1. 安装`Masa.Contrib.Data.Mapping.Mapster` + + ```c# + Install-Package Masa.Contrib.Data.Mapping.Mapster + ``` + +2. 使用`Mapping` + + ``` C# + builder.Services.AddMapping(); + ``` + +3. 映射对象 + + ``` + IMapper mapper;// 通过DI获取 + + var request = new + { + Name = "Teach you to learn Dapr ……", + OrderItem = new OrderItem("Teach you to learn Dapr hand by hand", 49.9m) + }; + var order = mapper.Map(request);// 将request映射到新的对象 + + ``` + + 映射类`Order`: + + ``` Order.cs + public class Order + { + public string Name { get; set; } + + public decimal TotalPrice { get; set; } + + public List OrderItems { get; set; } + + public Order(string name) + { + Name = name; + } + + public Order(string name, OrderItem orderItem) : this(name) + { + OrderItems = new List { orderItem }; + TotalPrice = OrderItems.Sum(item => item.Price * item.Number); + } + } + + public class OrderItem + { + public string Name { get; set; } + + public decimal Price { get; set; } + + public int Number { get; set; } + + public OrderItem(string name, decimal price) : this(name, price, 1) + { + + } + + public OrderItem(string name, decimal price, int number) + { + Name = name; + Price = price; + Number = number; + } + } + ``` \ No newline at end of file diff --git a/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/ServiceCollectionExtensions.cs b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..9bf9f05fd --- /dev/null +++ b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddMapping(this IServiceCollection services) + => services.AddMapping(MapMode.Shared); + + public static IServiceCollection AddMapping(this IServiceCollection services, MapMode mode) + => services.AddMapping(new MapOptions() + { + Mode = mode + }); + + public static IServiceCollection AddMapping(this IServiceCollection services, MapOptions mapOptions) + { + if (services.Any(service => service.ImplementationType == typeof(MappingProvider))) + return services; + + services.AddSingleton(); + + services.TryAddSingleton(_ => new DefaultMappingConfigProvider(mapOptions)); + services.TryAddSingleton(); + return services; + } + + private class MappingProvider + { + } +} diff --git a/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/_Imports.cs b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/_Imports.cs new file mode 100644 index 000000000..b9b142b57 --- /dev/null +++ b/src/Data/Mapping/Masa.Contrib.Data.Mapping.Mapster/_Imports.cs @@ -0,0 +1,15 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +global using Mapster; +global using Mapster.Models; +global using Masa.BuildingBlocks.Data.Mapping; +global using Masa.BuildingBlocks.Data.Mapping.Options; +global using Masa.BuildingBlocks.Data.Mapping.Options.Enum; +global using Masa.Contrib.Data.Mapping.Mapster.Internal; +global using Masa.Contrib.Data.Mapping.Mapster.Internal.Options; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.DependencyInjection.Extensions; +global using System.Collections.Concurrent; +global using System.Linq.Expressions; +global using System.Reflection; diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/BaseMappingTest.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/BaseMappingTest.cs new file mode 100644 index 000000000..89fdba96e --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/BaseMappingTest.cs @@ -0,0 +1,20 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests; + +[TestClass] +public class BaseMappingTest +{ + protected IServiceCollection _services; + protected IMapper _mapper = default!; + + [TestInitialize] + public void Initialize() + { + _services = new ServiceCollection(); + _services.AddMapping(); + var serviceProvider = _services.BuildServiceProvider(); + _mapper = serviceProvider.GetRequiredService(); + } +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/Aggregates/Orders/Order.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/Aggregates/Orders/Order.cs new file mode 100644 index 000000000..cf21dff84 --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/Aggregates/Orders/Order.cs @@ -0,0 +1,36 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests.Domain.Aggregates.Orders; + +public class Order +{ + public Guid Id { get; set; } + + public string Name { get; set; } + + public decimal TotalPrice { get; set; } + + public List OrderItems { get; set; } + + private Order() + { + Id = Guid.NewGuid(); + } + + public Order(string name) : this() + { + Name = name; + } + + public Order(string name, OrderItem orderItem) : this(name, new List { orderItem }) + { + } + + public Order(string name, List orderItems) : this(name) + { + Name = name; + OrderItems = orderItems; + TotalPrice = orderItems.Sum(item => item.Price * item.Number); + } +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/Aggregates/Orders/OrderItem.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/Aggregates/Orders/OrderItem.cs new file mode 100644 index 000000000..ff48919c7 --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/Aggregates/Orders/OrderItem.cs @@ -0,0 +1,25 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests.Domain.Aggregates.Orders; + +public class OrderItem +{ + public string Name { get; set; } + + public decimal Price { get; set; } + + public int Number { get; set; } + + public OrderItem(string name, decimal price) : this(name, price, 1) + { + + } + + public OrderItem(string name, decimal price, int number) + { + Name = name; + Price = price; + Number = number; + } +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/Aggregates/Users/User.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/Aggregates/Users/User.cs new file mode 100644 index 000000000..ac3609192 --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/Aggregates/Users/User.cs @@ -0,0 +1,41 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests.Domain.Aggregates.Users; + +public class User +{ + public string Name { get; set; } + + public int Age { get; set; } + + public string Description { get; set; } + + public DateTime Birthday { get; set; } + + public AddressItem Hometown { get; set; } + + + public User() + { + + } + public User(string name) + { + Name = name; + } + + public User(string name, int age, string description, DateTime birthday) + : this(name) + { + Age = age; + Description = description; + Birthday = birthday; + } + + public User(string name, int age, string description, DateTime birthday, AddressItem hometown) + : this(name, age, description, birthday) + { + Hometown = hometown; + } +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/ValueObjects/AddressItem.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/ValueObjects/AddressItem.cs new file mode 100644 index 000000000..b99b089b9 --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Domain/ValueObjects/AddressItem.cs @@ -0,0 +1,25 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests.Domain.ValueObjects; + +public class AddressItem +{ + public string Province { get; set; } + + public string City { get; set; } + + public string Address { get; set; } + + public AddressItem(string fullAddress) : this(fullAddress.Split(',')[0], fullAddress.Split(',')[1], fullAddress.Split(',')[2]) + { + + } + + public AddressItem(string province, string city, string address) + { + Province = province; + City = city; + Address = address; + } +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/MappingFormTest.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/MappingFormTest.cs new file mode 100644 index 000000000..485a85c4a --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/MappingFormTest.cs @@ -0,0 +1,27 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests; + +[TestClass] +public class MappingFormTest : BaseMappingTest +{ + [TestMethod] + public void TestUseShareModeReturnMapRuleCountIs1() + { + var request = new CreateUserRequest() + { + Name = "Jim", + }; + _mapper.Map(request); + Assert.IsTrue(TypeAdapterConfig.GlobalSettings.RuleMap.Count == 1); + } + + [TestMethod] + public void TestAddMultiMapping() + { + _services.AddMapping(); + var mappers = _services.BuildServiceProvider().GetServices(); + Assert.IsTrue(mappers.Count() == 1); + } +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/MappingTest.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/MappingTest.cs new file mode 100644 index 000000000..ceb5d22af --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/MappingTest.cs @@ -0,0 +1,191 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests; + +[TestClass] +public class MappingTest : BaseMappingTest +{ + [TestMethod] + public void TestCreateUserRequestMapToUserReturnUserNameEqualRequestName() + { + var request = new CreateUserRequest() + { + Name = "Jim", + }; + var user = _mapper.Map(request); + Assert.IsNotNull(user); + Assert.IsTrue(user.Name == request.Name); + } + + [TestMethod] + public void TestObjectMapToUserReturnUserIsNotNull() + { + var request = new + { + Name = "Jim", + Age = 18, + Birthday = DateTime.Now, + Description = "i am jim" + }; + + var user = _mapper.Map(request); + Assert.IsNotNull(user); + Assert.AreEqual(request.Name, user.Name); + Assert.AreEqual(request.Age, user.Age); + Assert.AreEqual(request.Birthday, user.Birthday); + Assert.AreEqual(request.Description, user.Description); + } + + [TestMethod] + public void TestObjectMapToUserAndSourceParameterGreatherThanDestinationControllerParameterLength() + { + var request = new + { + Name = "Jim", + Age = 18, + Birthday = DateTime.Now, + Description = "i am jim", + Tag = Array.Empty() + }; + + var user = _mapper.Map(request); + Assert.IsNotNull(user); + Assert.AreEqual(request.Name, user.Name); + Assert.AreEqual(request.Age, user.Age); + Assert.AreEqual(request.Birthday, user.Birthday); + Assert.AreEqual(request.Description, user.Description); + } + + [TestMethod] + public void TestCreateFullUserRequestMapToUserReturnHometownIsNotNull() + { + var request = new CreateFullUserRequest() + { + Name = "Jim", + Age = 18, + Birthday = DateTime.Now, + Hometown = new AddressItemRequest() + { + Province = "BeiJing", + City = "BeiJing", + Address = "National Sport Stadium" + } + }; + + var user = _mapper.Map(request); + Assert.IsNotNull(user); + Assert.AreEqual(request.Name, user.Name); + Assert.AreEqual(request.Age, user.Age); + Assert.AreEqual(request.Birthday, user.Birthday); + Assert.AreEqual(request.Description, user.Description); + Assert.IsNotNull(request.Hometown); + Assert.AreEqual(request.Hometown.Province, user.Hometown.Province); + Assert.AreEqual(request.Hometown.City, user.Hometown.City); + Assert.AreEqual(request.Hometown.Address, user.Hometown.Address); + } + + [TestMethod] + public void TestOrderRequestMapToOrderReturnTotalPriceIs10() + { + var request = new OrderRequest() + { + Name = "orderName", + OrderItem = new OrderItem("apple", 10) + }; + + var order = _mapper.Map(request); + Assert.IsNotNull(order); + Assert.AreEqual(order.Name, request.Name); + Assert.AreEqual(order.OrderItems.Count, 1); + Assert.AreEqual(order.OrderItems[0].Name, request.OrderItem.Name); + Assert.AreEqual(order.OrderItems[0].Price, request.OrderItem.Price); + Assert.AreEqual(order.OrderItems[0].Number, 1); + Assert.AreEqual(order.TotalPrice, 1 * 10); + } + + [TestMethod] + public void TestOrderMultiRequestMapToOrderReturnOrderItemsCountIs1AndTotalPriceIs10() + { + var request = new + { + Name = "Order Name", + OrderItems = new List() + { + new("Apple", 10) + } + }; + + var order = _mapper.Map(request); + Assert.IsNotNull(order); + Assert.AreEqual(order.Name, request.Name); + Assert.AreEqual(order.OrderItems.Count, 1); + Assert.AreEqual(order.OrderItems[0].Name, request.OrderItems[0].Name); + Assert.AreEqual(order.OrderItems[0].Price, request.OrderItems[0].Price); + Assert.AreEqual(order.OrderItems[0].Number, 1); + Assert.AreEqual(order.TotalPrice, 10); + } + + [TestMethod] + public void TestOrderMultiRequestMapToOrderReturnOrderItemsCountIs1() + { + var request = new OrderMultiRequest() + { + Name = "Order Name", + OrderItems = new List() + { + new() + { + Name = "Apple", + Price = 10, + Number = 1 + } + } + }; + + var order = _mapper.Map(request); + Assert.IsNotNull(order); + Assert.AreEqual(order.Name, request.Name); + Assert.AreEqual(order.OrderItems.Count, 1); + Assert.AreEqual(order.OrderItems[0].Name, request.OrderItems[0].Name); + Assert.AreEqual(order.OrderItems[0].Price, request.OrderItems[0].Price); + Assert.AreEqual(order.OrderItems[0].Number, 1); + Assert.AreEqual(order.TotalPrice, 0); + } + + [TestMethod] + public void TestMapToExistingObject() + { + var request = new + { + Name = "Jim", + Age = 18 + }; + User user = new User("Time") + { + Description = "Description", + }; + + var newUser = _mapper.Map(request, user); + Assert.IsNotNull(newUser); + Assert.IsTrue(newUser.Description == "Description"); + Assert.IsTrue(newUser.Name == "Jim"); + Assert.IsTrue(newUser.Age == 18); + } + + [TestMethod] + public void TestCreateUserRequestListMapToUsers() + { + List requests = new List() + { + new() + { + Name = "Jim" + } + }; + List users = new(); + var newUsers = _mapper.Map(requests, users); + Assert.IsTrue(newUsers.Count == 1); + Assert.IsTrue(newUsers[0].Name == "Jim"); + } +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Masa.Contrib.Data.Mapping.Mapster.Tests.csproj b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Masa.Contrib.Data.Mapping.Mapster.Tests.csproj new file mode 100644 index 000000000..9590f54b8 --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Masa.Contrib.Data.Mapping.Mapster.Tests.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + enable + + false + + + + + + + + + + + + + + + diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/AddressItemRequest.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/AddressItemRequest.cs new file mode 100644 index 000000000..8981f95dc --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/AddressItemRequest.cs @@ -0,0 +1,13 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests.Requests; + +public class AddressItemRequest +{ + public string Province { get; set; } + + public string City { get; set; } + + public string Address { get; set; } +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Orders/OrderItemRequest.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Orders/OrderItemRequest.cs new file mode 100644 index 000000000..afdf4f0b5 --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Orders/OrderItemRequest.cs @@ -0,0 +1,13 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests.Requests.Orders; + +public class OrderItemRequest +{ + public string Name { get; set; }= default!; + + public decimal Price { get; set; } + + public int Number { get; set; } +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Orders/OrderMultiRequest.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Orders/OrderMultiRequest.cs new file mode 100644 index 000000000..a3a3a1e39 --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Orders/OrderMultiRequest.cs @@ -0,0 +1,13 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests.Requests.Orders; + +public class OrderMultiRequest +{ + public Guid Id { get; set; } + + public string Name { get; set; } = default!; + + public List OrderItems { get; set; } = default!; +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Orders/OrderRequest.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Orders/OrderRequest.cs new file mode 100644 index 000000000..9c8139dc2 --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Orders/OrderRequest.cs @@ -0,0 +1,11 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests.Requests.Orders; + +public class OrderRequest +{ + public string Name { get; set; }= default!; + + public OrderItem OrderItem { get; set; }= default!; +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Users/CreateFullUserRequest.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Users/CreateFullUserRequest.cs new file mode 100644 index 000000000..e6ccb82b6 --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Users/CreateFullUserRequest.cs @@ -0,0 +1,15 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests.Requests.Users; + +public class CreateFullUserRequest : CreateUserRequest +{ + public int Age { get; set; } + + public string Description { get; set; } = default!; + + public DateTime Birthday { get; set; } + + public AddressItemRequest Hometown { get; set; } = default!; +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Users/CreateUserRequest.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Users/CreateUserRequest.cs new file mode 100644 index 000000000..feb0041f9 --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/Requests/Users/CreateUserRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +namespace Masa.Contrib.Data.Mapping.Mapster.Tests.Requests.Users; + +public class CreateUserRequest +{ + public string Name { get; set; } = default!; +} diff --git a/test/Masa.Contrib.Data.Mapping.Mapster.Tests/_Imports.cs b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/_Imports.cs new file mode 100644 index 000000000..0f0e72afa --- /dev/null +++ b/test/Masa.Contrib.Data.Mapping.Mapster.Tests/_Imports.cs @@ -0,0 +1,16 @@ +// Copyright (c) MASA Stack All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for license information. + +global using Mapster; +global using Masa.BuildingBlocks.Data.Mapping; +global using Masa.Contrib.Data.Mapping.Mapster.Tests.Domain.Aggregates.Orders; +global using Masa.Contrib.Data.Mapping.Mapster.Tests.Domain.Aggregates.Users; +global using Masa.Contrib.Data.Mapping.Mapster.Tests.Domain.ValueObjects; +global using Masa.Contrib.Data.Mapping.Mapster.Tests.Requests; +global using Masa.Contrib.Data.Mapping.Mapster.Tests.Requests.Orders; +global using Masa.Contrib.Data.Mapping.Mapster.Tests.Requests.Users; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.VisualStudio.TestTools.UnitTesting; +global using System; +global using System.Collections.Generic; +global using System.Linq;