From b62d79367569f51edab8639e774e6f2d1e6f36bd Mon Sep 17 00:00:00 2001 From: Michail Atamanuk Date: Mon, 30 Mar 2026 20:47:32 +0300 Subject: [PATCH 1/2] TASK-1 --- .gitignore | 2 + Mapper.sln | 27 ++ README.md | 41 +++ TASK-1.md | 1 + src/Directory.Build.props | 8 + src/Mapper.Tests/Mapper.Tests.csproj | 20 ++ src/Mapper.Tests/MapperTests.cs | 180 ++++++++++ src/Mapper.sln | 27 ++ src/Mapper/MapProfile.cs | 170 ++++++++++ src/Mapper/Mapper.cs | 490 +++++++++++++++++++++++++++ src/Mapper/Mapper.csproj | 10 + 11 files changed, 976 insertions(+) create mode 100644 Mapper.sln create mode 100644 README.md create mode 100644 TASK-1.md create mode 100644 src/Directory.Build.props create mode 100644 src/Mapper.Tests/Mapper.Tests.csproj create mode 100644 src/Mapper.Tests/MapperTests.cs create mode 100644 src/Mapper.sln create mode 100644 src/Mapper/MapProfile.cs create mode 100644 src/Mapper/Mapper.cs create mode 100644 src/Mapper/Mapper.csproj diff --git a/.gitignore b/.gitignore index f6b6248bb..fc4d6782c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .obsidian/ +**/bin/ +**/obj/ diff --git a/Mapper.sln b/Mapper.sln new file mode 100644 index 000000000..0410ad6a1 --- /dev/null +++ b/Mapper.sln @@ -0,0 +1,27 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mapper", "src/Mapper/Mapper.csproj", "{3FA4B596-73B9-4C77-8B7C-C6E1449BE3EB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mapper.Tests", "src/Mapper.Tests/Mapper.Tests.csproj", "{CB54888B-6954-449C-8FF4-0A1F95A4F4C0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3FA4B596-73B9-4C77-8B7C-C6E1449BE3EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FA4B596-73B9-4C77-8B7C-C6E1449BE3EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FA4B596-73B9-4C77-8B7C-C6E1449BE3EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FA4B596-73B9-4C77-8B7C-C6E1449BE3EB}.Release|Any CPU.Build.0 = Release|Any CPU + {CB54888B-6954-449C-8FF4-0A1F95A4F4C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB54888B-6954-449C-8FF4-0A1F95A4F4C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB54888B-6954-449C-8FF4-0A1F95A4F4C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB54888B-6954-449C-8FF4-0A1F95A4F4C0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 000000000..31657e9d0 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Mapper + +Библиотека на C# для преобразования `JsonPatchDocument` в `JsonPatchDocument`. + +## Что реализовано + +- мэппинг одноимённых полей по умолчанию; +- автоматическая конвертация значения при различии типов; +- явное переименование целевого поля через `ForMember(...).MapFrom(...)`; +- игнорирование поля через `ForMember(...).Ignore()`; +- вычисляемое преобразование значения из одного исходного поля. + +## Структура + +- [src/Mapper.sln](/home/me/projects/mapper/src/Mapper.sln) — solution в папке исходников; +- [src/Mapper](/home/me/projects/mapper/src/Mapper) — библиотека; +- [src/Mapper.Tests](/home/me/projects/mapper/src/Mapper.Tests) — тесты `xUnit`. + +## Использование + +```csharp +public sealed class RequestProfile : MapProfile +{ + public RequestProfile() + { + CreateMap() + .ForMember(target => target.DisplayName, options => options.MapFrom(source => source.Name)); + } +} + +var mapper = new Mapper(new RequestProfile()); +var targetPatch = mapper.Map(sourcePatch); +``` + +## Тесты + +Из корня репозитория: + +```bash +dotnet test +``` diff --git a/TASK-1.md b/TASK-1.md new file mode 100644 index 000000000..c98e72470 --- /dev/null +++ b/TASK-1.md @@ -0,0 +1 @@ +Создай проект по описанию из PROJECT.md diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 000000000..ca9a00a97 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,8 @@ + + + latest + enable + enable + false + + diff --git a/src/Mapper.Tests/Mapper.Tests.csproj b/src/Mapper.Tests/Mapper.Tests.csproj new file mode 100644 index 000000000..0487c7082 --- /dev/null +++ b/src/Mapper.Tests/Mapper.Tests.csproj @@ -0,0 +1,20 @@ + + + net8.0 + false + true + Mapper.Tests + + + + + + + + + + + + + + diff --git a/src/Mapper.Tests/MapperTests.cs b/src/Mapper.Tests/MapperTests.cs new file mode 100644 index 000000000..4fc0a69f4 --- /dev/null +++ b/src/Mapper.Tests/MapperTests.cs @@ -0,0 +1,180 @@ +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.AspNetCore.JsonPatch.Operations; +using Xunit; + +namespace Mapper.Tests; + +public sealed class MapperTests +{ + [Fact] + public void Map_Maps_Add_Replace_Remove_And_Test_For_Same_Named_Members() + { + var mapper = new global::Mapper.Mapper(new DefaultProfile()); + var patch = CreatePatch( + Operation("add", "/Name", value: "Alice"), + Operation("replace", "/Age", value: 42), + Operation("remove", "/Name"), + Operation("test", "/Age", value: 42)); + + var mapped = mapper.Map(patch); + + Assert.Collection( + mapped.Operations, + operation => + { + Assert.Equal("add", operation.op); + Assert.Equal("/Name", operation.path); + Assert.Equal("Alice", operation.value); + }, + operation => + { + Assert.Equal("replace", operation.op); + Assert.Equal("/Age", operation.path); + Assert.Equal(42L, operation.value); + }, + operation => + { + Assert.Equal("remove", operation.op); + Assert.Equal("/Name", operation.path); + }, + operation => + { + Assert.Equal("test", operation.op); + Assert.Equal("/Age", operation.path); + Assert.Equal(42L, operation.value); + }); + } + + [Fact] + public void Map_Maps_To_Target_Member_With_Different_Name() + { + var mapper = new global::Mapper.Mapper(new RenameProfile()); + var patch = CreatePatch(Operation("replace", "/Name", value: "Bob")); + + var mapped = mapper.Map(patch); + + var operation = Assert.Single(mapped.Operations); + Assert.Equal("/DisplayName", operation.path); + Assert.Equal("Bob", operation.value); + } + + [Fact] + public void Map_Converts_Value_When_Target_Type_Differs() + { + var mapper = new global::Mapper.Mapper(new ComputedProfile()); + var patch = CreatePatch(Operation("replace", "/Status", value: "Active")); + + var mapped = mapper.Map(patch); + + var operation = Assert.Single(mapped.Operations); + Assert.Equal("/Status", operation.path); + Assert.Equal(TargetStatus.Active, operation.value); + } + + [Fact] + public void Map_Skips_Ignored_Target_Member() + { + var mapper = new global::Mapper.Mapper(new IgnoreProfile()); + var patch = CreatePatch(Operation("replace", "/Name", value: "Ignored")); + + var mapped = mapper.Map(patch); + + Assert.Empty(mapped.Operations); + } + + private static JsonPatchDocument CreatePatch(params Operation[] operations) + where TModel : class + { + var patch = new JsonPatchDocument(); + foreach (var operation in operations) + { + patch.Operations.Add(operation); + } + + return patch; + } + + private static Operation Operation(string op, string path, string? from = null, object? value = null) + where TModel : class => + new() + { + op = op, + path = path, + from = from, + value = value + }; + + private sealed class DefaultProfile : global::Mapper.MapProfile + { + public DefaultProfile() + { + CreateMap(); + } + } + + private sealed class RenameProfile : global::Mapper.MapProfile + { + public RenameProfile() + { + CreateMap() + .ForMember(target => target.DisplayName, options => options.MapFrom(source => source.Name)); + } + } + + private sealed class ComputedProfile : global::Mapper.MapProfile + { + public ComputedProfile() + { + CreateMap() + .ForMember( + target => target.Status, + options => options.MapFrom(source => string.IsNullOrWhiteSpace(source.Status) + ? null + : Enum.Parse(source.Status, true))); + } + } + + private sealed class IgnoreProfile : global::Mapper.MapProfile + { + public IgnoreProfile() + { + CreateMap() + .ForMember(target => target.Name, options => options.Ignore()); + } + } + + private sealed class SourceModel + { + public string? Name { get; set; } + + public int Age { get; set; } + } + + private sealed class TargetModel + { + public string? Name { get; set; } + + public long Age { get; set; } + } + + private sealed class RenamedTargetModel + { + public string? DisplayName { get; set; } + } + + private sealed class SourceStatusModel + { + public string? Status { get; set; } + } + + private sealed class TargetStatusModel + { + public TargetStatus? Status { get; set; } + } + + private enum TargetStatus + { + Unknown = 0, + Active = 1 + } +} diff --git a/src/Mapper.sln b/src/Mapper.sln new file mode 100644 index 000000000..25150a7a0 --- /dev/null +++ b/src/Mapper.sln @@ -0,0 +1,27 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mapper", "Mapper/Mapper.csproj", "{3FA4B596-73B9-4C77-8B7C-C6E1449BE3EB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mapper.Tests", "Mapper.Tests/Mapper.Tests.csproj", "{CB54888B-6954-449C-8FF4-0A1F95A4F4C0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3FA4B596-73B9-4C77-8B7C-C6E1449BE3EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FA4B596-73B9-4C77-8B7C-C6E1449BE3EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FA4B596-73B9-4C77-8B7C-C6E1449BE3EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FA4B596-73B9-4C77-8B7C-C6E1449BE3EB}.Release|Any CPU.Build.0 = Release|Any CPU + {CB54888B-6954-449C-8FF4-0A1F95A4F4C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB54888B-6954-449C-8FF4-0A1F95A4F4C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB54888B-6954-449C-8FF4-0A1F95A4F4C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB54888B-6954-449C-8FF4-0A1F95A4F4C0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/Mapper/MapProfile.cs b/src/Mapper/MapProfile.cs new file mode 100644 index 000000000..29d0677a4 --- /dev/null +++ b/src/Mapper/MapProfile.cs @@ -0,0 +1,170 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace Mapper; + +public abstract class MapProfile +{ + private readonly List _definitions = new(); + + internal IReadOnlyList Definitions => _definitions; + + protected MapBuilder CreateMap() + where TSource : class + where TTarget : class + { + var definition = new MapDefinition(); + _definitions.Add(definition); + return new MapBuilder(definition); + } +} + +public sealed class MapBuilder + where TSource : class + where TTarget : class +{ + private readonly MapDefinition _definition; + + internal MapBuilder(MapDefinition definition) + { + _definition = definition; + } + + public MapBuilder ForMember( + Expression> targetMember, + Action> configure) + { + if (targetMember is null) + { + throw new ArgumentNullException(nameof(targetMember)); + } + + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } + + var targetPath = ExpressionPath.GetPath(targetMember); + var optionsBuilder = new MemberOptionsBuilder(targetPath); + configure(optionsBuilder); + _definition.MemberConfigurations[targetPath] = optionsBuilder.Build(); + + return this; + } +} + +public sealed class MemberOptionsBuilder +{ + private readonly string _targetPath; + private MemberConfiguration? _configuration; + + internal MemberOptionsBuilder(string targetPath) + { + _targetPath = targetPath; + } + + public void Ignore() + { + _configuration = MemberConfiguration.CreateIgnored(_targetPath); + } + + public void MapFrom(Expression> sourceMember) + { + if (sourceMember is null) + { + throw new ArgumentNullException(nameof(sourceMember)); + } + + _configuration = MemberConfiguration.ForMapFrom(_targetPath, sourceMember); + } + + internal MemberConfiguration Build() => _configuration ?? MemberConfiguration.Direct(_targetPath); +} + +internal interface IMapDefinition +{ + Type SourceType { get; } + Type TargetType { get; } + ICompiledMap Compile(); +} + +internal sealed class MapDefinition : IMapDefinition + where TSource : class + where TTarget : class +{ + public Dictionary MemberConfigurations { get; } = new(StringComparer.OrdinalIgnoreCase); + + public Type SourceType => typeof(TSource); + + public Type TargetType => typeof(TTarget); + + public ICompiledMap Compile() => PatchMapCompiler.Compile(MemberConfigurations); +} + +internal sealed class MemberConfiguration +{ + private MemberConfiguration(string targetPath, bool ignored, LambdaExpression? sourceExpression) + { + TargetPath = targetPath; + Ignored = ignored; + SourceExpression = sourceExpression; + } + + public string TargetPath { get; } + + public bool Ignored { get; } + + public LambdaExpression? SourceExpression { get; } + + public static MemberConfiguration Direct(string targetPath) => new(targetPath, false, null); + + public static MemberConfiguration CreateIgnored(string targetPath) => new(targetPath, true, null); + + public static MemberConfiguration ForMapFrom(string targetPath, LambdaExpression sourceExpression) => + new(targetPath, false, sourceExpression); +} + +internal static class ExpressionPath +{ + public static string GetPath(LambdaExpression expression) + { + var segments = GetMemberSegments(expression.Body); + return "/" + string.Join("/", segments); + } + + public static IReadOnlyList GetMemberSegments(Expression expression) + { + var segments = new Stack(); + var current = UnwrapConvert(expression); + + while (current is MemberExpression memberExpression) + { + if (memberExpression.Member is not PropertyInfo) + { + throw new NotSupportedException("Only property access expressions are supported."); + } + + segments.Push(memberExpression.Member.Name); + current = UnwrapConvert(memberExpression.Expression); + } + + if (current is not ParameterExpression) + { + throw new NotSupportedException("Only direct property access expressions are supported."); + } + + return segments.ToArray(); + } + + public static Expression UnwrapConvert(Expression? expression) + { + while (expression is UnaryExpression unaryExpression && + (unaryExpression.NodeType == ExpressionType.Convert || + unaryExpression.NodeType == ExpressionType.ConvertChecked)) + { + expression = unaryExpression.Operand; + } + + return expression ?? throw new ArgumentNullException(nameof(expression)); + } +} diff --git a/src/Mapper/Mapper.cs b/src/Mapper/Mapper.cs new file mode 100644 index 000000000..d270f1ba7 --- /dev/null +++ b/src/Mapper/Mapper.cs @@ -0,0 +1,490 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.AspNetCore.JsonPatch.Operations; + +namespace Mapper; + +public sealed class Mapper +{ + private readonly ConcurrentDictionary _compiledMaps; + + public Mapper(params MapProfile[] profiles) + { + if (profiles is null) + { + throw new ArgumentNullException(nameof(profiles)); + } + + _compiledMaps = new ConcurrentDictionary( + profiles + .SelectMany(static profile => profile.Definitions) + .Select(static definition => new KeyValuePair( + new TypePair(definition.SourceType, definition.TargetType), + definition.Compile()))); + } + + public JsonPatchDocument Map(JsonPatchDocument sourcePatch) + where TSource : class + where TTarget : class + { + if (sourcePatch is null) + { + throw new ArgumentNullException(nameof(sourcePatch)); + } + + if (!_compiledMaps.TryGetValue(new TypePair(typeof(TSource), typeof(TTarget)), out var compiledMap)) + { + throw new InvalidOperationException( + $"Mapping from {typeof(TSource).FullName} to {typeof(TTarget).FullName} is not configured."); + } + + var targetPatch = new JsonPatchDocument(); + + foreach (var operation in sourcePatch.Operations) + { + var mappedOperation = compiledMap.MapOperation(operation); + if (mappedOperation is Operation typedOperation) + { + targetPatch.Operations.Add(typedOperation); + } + else if (mappedOperation is not null) + { + throw new InvalidOperationException("Compiled mapping returned an operation of unexpected type."); + } + } + + return targetPatch; + } +} + +internal interface ICompiledMap +{ + object? MapOperation(Operation operation); +} + +internal sealed class CompiledMap : ICompiledMap + where TSource : class + where TTarget : class +{ + private readonly Dictionary _rules; + + public CompiledMap(Dictionary rules) + { + _rules = rules; + } + + public object? MapOperation(Operation operation) + { + if (operation is null) + { + throw new ArgumentNullException(nameof(operation)); + } + + var pathRule = ResolveRule(operation.path); + if (pathRule?.Ignored == true) + { + return null; + } + + var fromRule = string.IsNullOrWhiteSpace(operation.from) ? null : ResolveRule(operation.from); + if (fromRule?.Ignored == true) + { + return null; + } + + var mapped = new Operation + { + op = operation.op, + path = pathRule?.TargetPath ?? operation.path, + from = fromRule?.TargetPath ?? operation.from, + value = ConvertValueIfNeeded(operation.op, operation.value, pathRule) + }; + + return mapped; + } + + private MemberRule? ResolveRule(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + return _rules.TryGetValue(path, out var directRule) ? directRule : null; + } + + private static object? ConvertValueIfNeeded(string op, object? value, MemberRule? rule) + { + if (value is null) + { + return null; + } + + if (!OperationRequiresValue(op)) + { + return value; + } + + return rule?.Convert is null ? value : rule.Convert(value); + } + + private static bool OperationRequiresValue(string op) => + string.Equals(op, "add", StringComparison.OrdinalIgnoreCase) || + string.Equals(op, "replace", StringComparison.OrdinalIgnoreCase) || + string.Equals(op, "test", StringComparison.OrdinalIgnoreCase); +} + +internal sealed class MemberRule +{ + public string SourcePath { get; set; } = string.Empty; + public string TargetPath { get; set; } = string.Empty; + public bool Ignored { get; set; } + public Func? Convert { get; set; } +} + +internal static class PatchMapCompiler +{ + public static ICompiledMap Compile(IReadOnlyDictionary memberConfigurations) + where TSource : class + where TTarget : class + { + var rules = BuildDefaultRules(); + + foreach (var configuration in memberConfigurations.Values) + { + ApplyConfiguration(rules, configuration); + } + + return new CompiledMap(rules); + } + + private static Dictionary BuildDefaultRules() + where TSource : class + where TTarget : class + { + var targetProperties = typeof(TTarget) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(static property => property.CanWrite) + .ToDictionary(static property => property.Name, StringComparer.OrdinalIgnoreCase); + + var rules = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var sourceProperty in typeof(TSource).GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!sourceProperty.CanRead || !targetProperties.TryGetValue(sourceProperty.Name, out var targetProperty)) + { + continue; + } + + var path = "/" + sourceProperty.Name; + rules[path] = new MemberRule + { + SourcePath = path, + TargetPath = "/" + targetProperty.Name, + Ignored = false, + Convert = BuildValueConverter(sourceProperty.PropertyType, targetProperty.PropertyType) + }; + } + + return rules; + } + + private static void ApplyConfiguration( + IDictionary rules, + MemberConfiguration configuration) + where TSource : class + where TTarget : class + { + if (configuration.Ignored) + { + var targetPath = configuration.TargetPath; + var existing = rules.Values.FirstOrDefault(rule => string.Equals(rule.TargetPath, targetPath, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + { + rules[existing.SourcePath] = new MemberRule + { + SourcePath = existing.SourcePath, + TargetPath = existing.TargetPath, + Ignored = true, + Convert = existing.Convert + }; + } + + return; + } + + if (configuration.SourceExpression is null) + { + return; + } + + var sourceDescriptor = SourceMemberDescriptor.Create(configuration.SourceExpression); + rules[sourceDescriptor.Path] = new MemberRule + { + SourcePath = sourceDescriptor.Path, + TargetPath = configuration.TargetPath, + Ignored = false, + Convert = sourceDescriptor.ValueConverter + }; + } + + private static Func BuildValueConverter(Type sourceType, Type targetType) + { + return value => ValueConversion.Convert(value, sourceType, targetType); + } +} + +internal sealed class SourceMemberDescriptor +{ + private SourceMemberDescriptor(string path, Func valueConverter) + { + Path = path; + ValueConverter = valueConverter; + } + + public string Path { get; } + + public Func ValueConverter { get; } + + public static SourceMemberDescriptor Create(LambdaExpression expression) + { + var memberAccess = SingleMemberAccessVisitor.Find(expression.Body, expression.Parameters[0]); + if (memberAccess is null) + { + throw new NotSupportedException("MapFrom expression must depend on exactly one source member."); + } + + var sourcePath = "/" + string.Join("/", memberAccess.PathSegments); + var valueParameter = Expression.Parameter(typeof(object), "value"); + var replacement = ReplaceExpressionVisitor.Replace( + expression.Body, + memberAccess.Expression, + Expression.Convert(valueParameter, memberAccess.MemberType)); + + var lambda = Expression.Lambda>( + Expression.Convert(replacement, typeof(object)), + valueParameter); + + var compiled = lambda.Compile(); + return new SourceMemberDescriptor(sourcePath, compiled); + } +} + +internal sealed class SingleMemberAccessVisitor : ExpressionVisitor +{ + private readonly ParameterExpression _rootParameter; + private MemberAccessMatch? _match; + private bool _multipleMatches; + + private SingleMemberAccessVisitor(ParameterExpression rootParameter) + { + _rootParameter = rootParameter; + } + + public static MemberAccessMatch? Find(Expression expression, ParameterExpression rootParameter) + { + var visitor = new SingleMemberAccessVisitor(rootParameter); + visitor.Visit(expression); + return visitor._multipleMatches ? null : visitor._match; + } + + protected override Expression VisitMember(MemberExpression node) + { + var unwrapped = ExpressionPath.UnwrapConvert(node.Expression); + if (DependsOnRoot(unwrapped)) + { + var pathSegments = ExpressionPath.GetMemberSegments(node); + var match = new MemberAccessMatch(node, pathSegments, node.Type); + if (_match is null) + { + _match = match; + } + else if (!string.Equals(_match.Path, match.Path, StringComparison.Ordinal)) + { + _multipleMatches = true; + } + } + + return base.VisitMember(node); + } + + private bool DependsOnRoot(Expression expression) + { + while (expression is MemberExpression memberExpression) + { + expression = ExpressionPath.UnwrapConvert(memberExpression.Expression); + } + + return expression == _rootParameter; + } +} + +internal sealed class MemberAccessMatch +{ + public MemberAccessMatch(Expression expression, IReadOnlyList pathSegments, Type memberType) + { + Expression = expression; + PathSegments = pathSegments; + MemberType = memberType; + Path = "/" + string.Join("/", pathSegments); + } + + public Expression Expression { get; } + + public IReadOnlyList PathSegments { get; } + + public Type MemberType { get; } + + public string Path { get; } +} + +internal sealed class ReplaceExpressionVisitor : ExpressionVisitor +{ + private readonly Expression _from; + private readonly Expression _to; + private readonly string? _fromMemberPath; + + private ReplaceExpressionVisitor(Expression from, Expression to) + { + _from = from; + _to = to; + _fromMemberPath = from is MemberExpression memberExpression + ? "/" + string.Join("/", ExpressionPath.GetMemberSegments(memberExpression)) + : null; + } + + public static Expression Replace(Expression root, Expression from, Expression to) => + new ReplaceExpressionVisitor(from, to).Visit(root)!; + + public override Expression? Visit(Expression? node) + { + if (node == _from) + { + return _to; + } + + if (_fromMemberPath is not null && + node is MemberExpression memberExpression && + string.Equals( + "/" + string.Join("/", ExpressionPath.GetMemberSegments(memberExpression)), + _fromMemberPath, + StringComparison.Ordinal)) + { + return _to; + } + + return base.Visit(node); + } +} + +internal static class ValueConversion +{ + public static object? Convert(object? value, Type sourceType, Type targetType) + { + if (value is null) + { + return null; + } + + var sourceValue = UnwrapJsonElement(value); + var nonNullableTargetType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (nonNullableTargetType.IsInstanceOfType(sourceValue)) + { + return sourceValue; + } + + if (nonNullableTargetType.IsEnum) + { + if (sourceValue is string enumName) + { + return Enum.Parse(nonNullableTargetType, enumName, true); + } + + var enumValue = System.Convert.ChangeType(sourceValue, Enum.GetUnderlyingType(nonNullableTargetType), CultureInfo.InvariantCulture); + return Enum.ToObject(nonNullableTargetType, enumValue!); + } + + if (nonNullableTargetType == typeof(Guid)) + { + return sourceValue switch + { + Guid guid => guid, + string guidString => Guid.Parse(guidString), + _ => throw new InvalidCastException($"Cannot convert {sourceValue.GetType().FullName} to Guid.") + }; + } + + if (nonNullableTargetType == typeof(string)) + { + return System.Convert.ToString(sourceValue, CultureInfo.InvariantCulture); + } + + return System.Convert.ChangeType(sourceValue, nonNullableTargetType, CultureInfo.InvariantCulture); + } + + private static object UnwrapJsonElement(object value) + { + var typeName = value.GetType().FullName; + if (!string.Equals(typeName, "System.Text.Json.JsonElement", StringComparison.Ordinal)) + { + return value; + } + + var rawText = (string)value.GetType().GetMethod("GetRawText")!.Invoke(value, Array.Empty())!; + var valueKind = value.GetType().GetProperty("ValueKind")!.GetValue(value)?.ToString(); + + return valueKind switch + { + "String" => (string)value.GetType().GetMethod("GetString")!.Invoke(value, Array.Empty())!, + "Number" => ParseJsonNumber(rawText), + "True" => true, + "False" => false, + "Null" => null!, + _ => value + }; + } + + private static object ParseJsonNumber(string rawText) + { + if (long.TryParse(rawText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var int64Value)) + { + return int64Value; + } + + if (decimal.TryParse(rawText, NumberStyles.Float, CultureInfo.InvariantCulture, out var decimalValue)) + { + return decimalValue; + } + + return double.Parse(rawText, CultureInfo.InvariantCulture); + } +} + +internal readonly struct TypePair : IEquatable +{ + public TypePair(Type source, Type target) + { + Source = source; + Target = target; + } + + public Type Source { get; } + + public Type Target { get; } + + public bool Equals(TypePair other) => Source == other.Source && Target == other.Target; + + public override bool Equals(object? obj) => obj is TypePair other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + return ((Source?.GetHashCode() ?? 0) * 397) ^ (Target?.GetHashCode() ?? 0); + } + } +} diff --git a/src/Mapper/Mapper.csproj b/src/Mapper/Mapper.csproj new file mode 100644 index 000000000..22653f1ce --- /dev/null +++ b/src/Mapper/Mapper.csproj @@ -0,0 +1,10 @@ + + + netstandard2.1 + Mapper + + + + + + From c45d64387585f60a8fffc333e6122041ba8fd657 Mon Sep 17 00:00:00 2001 From: atamanuk_m Date: Wed, 1 Apr 2026 17:55:19 +0200 Subject: [PATCH 2/2] hw0 --- homeworks/hw-0/prompt.md | 1 + homeworks/hw-0/report.md | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 homeworks/hw-0/prompt.md create mode 100644 homeworks/hw-0/report.md diff --git a/homeworks/hw-0/prompt.md b/homeworks/hw-0/prompt.md new file mode 100644 index 000000000..d20f00077 --- /dev/null +++ b/homeworks/hw-0/prompt.md @@ -0,0 +1 @@ +@TASK-1 \ No newline at end of file diff --git a/homeworks/hw-0/report.md b/homeworks/hw-0/report.md new file mode 100644 index 000000000..caf8856e3 --- /dev/null +++ b/homeworks/hw-0/report.md @@ -0,0 +1,21 @@ +Что сделал агент? +- создал проект +- наполнил его функциональностью по спеке +- сбилдил (подтянув нужное через mise) +- кажется, запускал тесты + +Какие файлы он изменил или собирался изменить? +- полностью создал запускаемый проект + +Как вы проверили результат? +- поверил агенту, что проект билдится +- просмотрел исходники глазами + +Где агент ошибся или потерял контекст? +- по задумке мэппинг должен настраиваться с помощью кодогенерации, а он повел как-то не так + +В каком месте вам захотелось его остановить? +- не захотелось + +Какие 3-5 уточнений вы бы добавили в следующий заход? +- нужно как-то направить реализацию в нужное русло. Пока не сформулировал \ No newline at end of file