diff --git a/ObjectPrinting/MemberPrintingConfig.cs b/ObjectPrinting/MemberPrintingConfig.cs new file mode 100644 index 000000000..151b1fe16 --- /dev/null +++ b/ObjectPrinting/MemberPrintingConfig.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace ObjectPrinting +{ + public class MemberPrintingConfig(PrintingConfig parent, MemberInfo member) + { + protected readonly PrintingConfig Parent = parent; + protected readonly MemberInfo Member = member; + + public PrintingConfig Using(Func serializer) + { + Parent.SetMemberSerializer(Member, serializer); + return Parent; + } + + } +} diff --git a/ObjectPrinting/MemberPrintingConfigForString.cs b/ObjectPrinting/MemberPrintingConfigForString.cs new file mode 100644 index 000000000..baf9ec56a --- /dev/null +++ b/ObjectPrinting/MemberPrintingConfigForString.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace ObjectPrinting +{ + public class MemberPrintingConfigForString(PrintingConfig parent, MemberInfo member) + : MemberPrintingConfig(parent, member) + { + public PrintingConfig TrimmedToLength(int maxLen) + { + Parent.SetMemberTrimLength(Member, maxLen); + return Parent; + } + } +} diff --git a/ObjectPrinting/ObjectPrinting.csproj b/ObjectPrinting/ObjectPrinting.csproj index c5db392ff..ea98111e3 100644 --- a/ObjectPrinting/ObjectPrinting.csproj +++ b/ObjectPrinting/ObjectPrinting.csproj @@ -5,6 +5,7 @@ + diff --git a/ObjectPrinting/PrintingConfig.cs b/ObjectPrinting/PrintingConfig.cs index a9e082117..b54306263 100644 --- a/ObjectPrinting/PrintingConfig.cs +++ b/ObjectPrinting/PrintingConfig.cs @@ -1,41 +1,93 @@ using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; +using System.Linq.Expressions; +using System.Reflection; using System.Text; - +using System.Reflection; namespace ObjectPrinting { public class PrintingConfig { - public string PrintToString(TOwner obj) + private readonly HashSet excludedTypes = new HashSet(); + private readonly HashSet excludedMembers = new HashSet(); + + private readonly Dictionary typeSerializers = new Dictionary(); + private readonly Dictionary typeCultures = new Dictionary(); + + private readonly Dictionary memberSerializers = new Dictionary(); + private readonly Dictionary memberTrimLengths = new Dictionary(); + private readonly HashSet finalTypes = + [ + typeof(int), typeof(double), typeof(float), typeof(long), typeof(short), typeof(string), + typeof(byte), typeof(decimal), typeof(bool), typeof(DateTime), typeof(TimeSpan) + ]; + internal IReadOnlyCollection ExcludedTypes => excludedTypes; + internal IReadOnlyCollection ExcludedMembers => excludedMembers; + internal IReadOnlyDictionary TypeSerializers => typeSerializers; + internal IReadOnlyDictionary TypeCultures => typeCultures; + internal IReadOnlyDictionary MemberSerializers => memberSerializers; + internal IReadOnlyDictionary MemberTrimLengths => memberTrimLengths; + + internal IReadOnlyCollection FinalTypes => finalTypes; + public PrintingConfig Excluding() + { + excludedTypes.Add(typeof(TProp)); + return this; + } + public PrintingConfig Excluding(Expression> memberSelector) { - return PrintToString(obj, 0); + var member = GetMemberInfo(memberSelector); + excludedMembers.Add(member); + return this; + } + internal void SetTypeSerializer(Func serializer) + { + typeSerializers[typeof(TProp)] = serializer; } - private string PrintToString(object obj, int nestingLevel) + internal void SetTypeCulture(CultureInfo culture) { - //TODO apply configurations - if (obj == null) - return "null" + Environment.NewLine; + typeCultures[typeof(TProp)] = culture; + } - var finalTypes = new[] - { - typeof(int), typeof(double), typeof(float), typeof(string), - typeof(DateTime), typeof(TimeSpan) - }; - if (finalTypes.Contains(obj.GetType())) - return obj + Environment.NewLine; - - var identation = new string('\t', nestingLevel + 1); - var sb = new StringBuilder(); - var type = obj.GetType(); - sb.AppendLine(type.Name); - foreach (var propertyInfo in type.GetProperties()) + internal void SetMemberSerializer(MemberInfo member, Func serializer) + { + memberSerializers[member] = serializer; + } + + internal void SetMemberTrimLength(MemberInfo member, int length) + { + memberTrimLengths[member] = length; + } + public TypePrintingConfig Printing() + { + return new TypePrintingConfig(this); + } + + public MemberPrintingConfig Printing(Expression> memberSelector) + { + var member = GetMemberInfo(memberSelector); + return new MemberPrintingConfig(this, member); + } + + public MemberPrintingConfigForString Printing(Expression> memberSelector) + { + var member = GetMemberInfo(memberSelector); + return new MemberPrintingConfigForString(this, member); + } + public string PrintToString(TOwner obj) + { + return new Serializer(this).Serialize(obj); + } + private static MemberInfo GetMemberInfo(Expression> memberSelector) + { + if (memberSelector.Body is MemberExpression memberExpression) { - sb.Append(identation + propertyInfo.Name + " = " + - PrintToString(propertyInfo.GetValue(obj), - nestingLevel + 1)); + return memberExpression.Member; } - return sb.ToString(); + throw new ArgumentException("Expression is not a member access", nameof(memberSelector)); } } } \ No newline at end of file diff --git a/ObjectPrinting/Serializer.cs b/ObjectPrinting/Serializer.cs new file mode 100644 index 000000000..4e4e54ba7 --- /dev/null +++ b/ObjectPrinting/Serializer.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace ObjectPrinting +{ + public class Serializer(PrintingConfig config) + { + private readonly PrintingConfig config = config; + private StringBuilder sb; + private HashSet visited; + + public string Serialize(T root) + { + sb = new StringBuilder(); + visited = new HashSet(new ReferenceEqualityComparer()); + + PrintObject(root, 0, null); + + var result = sb.ToString(); + + return result; + } + + private void PrintObject(object? obj, int nestingLevel, MemberInfo? currentMember) + { + if (obj == null) { sb.AppendLine("null"); return; } + + var type = obj.GetType(); + + if (IsExcluded(type, currentMember)) + { + sb.AppendLine(string.Empty); + return; + } + + if (TryApplyMemberSerializer(obj, currentMember)) return; + if (TryApplyTypeSerializer(type, obj)) return; + + if (HandleString(obj, currentMember)) return; + if (HandleFormattable(type, obj)) return; + if (HandleFinals(type, obj)) return; + + if (HandleReferenceTracking(type, obj)) return; + + if (HandleDictionary(obj, nestingLevel)) return; + if (HandleEnumerable(obj, nestingLevel)) return; + + sb.AppendLine(type.Name); + PrintProperties(type, obj, nestingLevel); + PrintFields(type, obj, nestingLevel); + } + + private static string Indent(int lvl) => new string('\t', lvl); + + private static object? GetValueSafely(MemberInfo member, object obj) + { + try + { + switch (member) + { + case PropertyInfo p: return p.GetValue(obj); + case FieldInfo f: return f.GetValue(obj); + default: return null; + } + } + catch + { + return null; + } + } + + private bool IsExcluded(Type type, MemberInfo? member) => + config.ExcludedTypes.Contains(type) || (member != null && config.ExcludedMembers.Contains(member)); + + private bool TryApplyMemberSerializer(object obj, MemberInfo? member) + { + if (member == null || !config.MemberSerializers.TryGetValue(member, out var mser)) return false; + var s = mser.DynamicInvoke(obj); + sb.AppendLine(s?.ToString()); + return true; + } + + private bool TryApplyTypeSerializer(Type type, object obj) + { + if (!config.TypeSerializers.TryGetValue(type, out var tser)) return false; + var s = tser.DynamicInvoke(obj); + sb.AppendLine(s?.ToString()); + return true; + } + + private bool HandleString(object? obj, MemberInfo? member) + { + if (obj == null) return false; + if (obj.GetType() != typeof(string)) return false; + + var s = obj as string; + if (member != null && config.MemberTrimLengths.TryGetValue(member, out var l) && s != null && s.Length > l) + s = s.Substring(0, l); + + sb.AppendLine(s); + return true; + } + + private bool HandleFormattable(Type type, object obj) + { + if (obj is not IFormattable formattable || !config.TypeCultures.TryGetValue(type, out var culture)) + return false; + sb.AppendLine(formattable.ToString(null, culture)); + return true; + } + + private bool HandleFinals(Type type, object obj) + { + if (!config.FinalTypes.Contains(type)) return false; + sb.AppendLine(obj.ToString()); + return true; + } + + private bool HandleReferenceTracking(Type type, object obj) + { + if (type.IsValueType) return false; + if (visited.Contains(obj)) + { + sb.AppendLine($"<Циклическая ссылка {type.Name}>"); + return true; + } + visited.Add(obj); + return false; + } + + private bool HandleDictionary(object obj, int nestingLevel) + { + if (obj is not IDictionary dict) return false; + + var type = obj.GetType(); + sb.AppendLine(type.Name); + foreach (DictionaryEntry e in dict) + { + sb.Append(Indent(nestingLevel + 1)); + sb.Append("Key = "); + PrintObject(e.Key, nestingLevel + 1, null); + + sb.Append(Indent(nestingLevel + 1)); + sb.Append("Value = "); + PrintObject(e.Value, nestingLevel + 1, null); + } + return true; + } + + private bool HandleEnumerable(object obj, int nestingLevel) + { + if (obj is not IEnumerable enumerable || obj is string) return false; + + var type = obj.GetType(); + sb.AppendLine(type.Name); + int i = 0; + foreach (var item in enumerable) + { + sb.Append(Indent(nestingLevel + 1)); + sb.Append($"[{i}] = "); + PrintObject(item, nestingLevel + 1, null); + i++; + } + return true; + } + + private void PrintProperties(Type type, object obj, int nestingLevel) + { + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var p in props) + { + if (p.GetIndexParameters().Length > 0) continue; + if (config.ExcludedTypes.Contains(p.PropertyType) || config.ExcludedMembers.Contains(p)) continue; + + sb.Append(Indent(nestingLevel + 1)); + sb.Append(p.Name); + sb.Append(" = "); + + var value = GetValueSafely(p, obj); + + if (value is string sVal && config.MemberTrimLengths.TryGetValue(p, out var trim) && sVal.Length > trim) + value = sVal.Substring(0, trim); + + PrintObject(value, nestingLevel + 1, p); + } + } + + private void PrintFields(Type type, object obj, int nestingLevel) + { + var fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance); + foreach (var f in fields) + { + if (config.ExcludedTypes.Contains(f.FieldType) || config.ExcludedMembers.Contains(f)) continue; + + sb.Append(Indent(nestingLevel + 1)); + sb.Append(f.Name); + sb.Append(" = "); + + var value = GetValueSafely(f, obj); + + if (value is string sf && config.MemberTrimLengths.TryGetValue(f, out var trimf) && sf.Length > trimf) + value = sf.Substring(0, trimf); + + PrintObject(value, nestingLevel + 1, f); + } + } + + private class ReferenceEqualityComparer : IEqualityComparer + { + public new bool Equals(object x, object y) => ReferenceEquals(x, y); + public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } + } +} diff --git a/ObjectPrinting/Tests/ObjectPrinterAcceptanceTests.cs b/ObjectPrinting/Tests/ObjectPrinterAcceptanceTests.cs index 4c8b2445c..e5aeb45a6 100644 --- a/ObjectPrinting/Tests/ObjectPrinterAcceptanceTests.cs +++ b/ObjectPrinting/Tests/ObjectPrinterAcceptanceTests.cs @@ -1,27 +1,49 @@ -using NUnit.Framework; +using System; +using System.Globalization; +using FluentAssertions; +using NUnit.Framework; +using ObjectPrinting; namespace ObjectPrinting.Tests { - [TestFixture] public class ObjectPrinterAcceptanceTests { + [Test] - public void Demo() + public void AcceptanceTest_FluentAssertions() { - var person = new Person { Name = "Alex", Age = 19 }; - - var printer = ObjectPrinter.For(); - //1. Исключить из сериализации свойства определенного типа - //2. Указать альтернативный способ сериализации для определенного типа - //3. Для числовых типов указать культуру - //4. Настроить сериализацию конкретного свойства - //5. Настроить обрезание строковых свойств (метод должен быть виден только для строковых свойств) - //6. Исключить из сериализации конкретного свойства - - string s1 = printer.PrintToString(person); - - //7. Синтаксический сахар в виде метода расширения, сериализующего по-умолчанию - //8. ...с конфигурированием + var person = new Person + { + Id = Guid.NewGuid(), + Name = "Alexander", + Age = 19, + Height = 1.85 + }; + + var printerNoGuid = ObjectPrinter.For() + .Excluding() + .Printing().Using(CultureInfo.InvariantCulture) + .Printing(p => p.Name).TrimmedToLength(5); + + string resultNoGuid = printerNoGuid.PrintToString(person); + + resultNoGuid.Should().NotBeNullOrWhiteSpace(); + resultNoGuid.Should().NotContain(person.Id.ToString()); + resultNoGuid.Should().Contain(person.Name.Substring(0, 5)); + resultNoGuid.Should().NotContain(person.Name.Substring(0, 6)); + if (person.Name.Length > 10) + resultNoGuid.Should().NotContain(person.Name); + + var printerFormat = ObjectPrinter.For() + .Printing().Using(i => i.ToString("X")); + + string resultFormat = printerFormat.PrintToString(person); + + resultFormat.Should().Contain(person.Age.ToString("X")); + + var printerExcludeAge = ObjectPrinter.For().Excluding(p => p.Age); + string resultExcludeAge = printerExcludeAge.PrintToString(person); + resultExcludeAge.Should().NotContain(person.Age.ToString()); } } -} \ No newline at end of file +} diff --git a/ObjectPrinting/Tests/PrintToStringTests.cs b/ObjectPrinting/Tests/PrintToStringTests.cs new file mode 100644 index 000000000..bbf1d6220 --- /dev/null +++ b/ObjectPrinting/Tests/PrintToStringTests.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using FluentAssertions; +using NUnit.Framework; +using ObjectPrinting; + +namespace ObjectPrinting.Tests +{ + [TestFixture] + public class PrintToStringTests + { + + private class Node + { + public string Name { get; set; } + public Node Other { get; set; } + } + + private class Container + { + public int[] Numbers { get; set; } + public List List { get; set; } + public Dictionary Map { get; set; } + } + + [Test] + public void PrintToString_NullObject_PrintsNull() + { + var printer = ObjectPrinter.For(); + string result = printer.PrintToString(null); + + result.Should().Be("null" + Environment.NewLine); + } + + [Test] + public void PrintToString_SimpleObject_ContainsTypeAndMembers() + { + var person = new Person { Id = Guid.NewGuid(), Name = "Петя", Height = 1.82, Age = 19 }; + string s = ObjectPrinter.For().PrintToString(person); + + s.Should().Contain(nameof(Person)); + s.Should().Contain(nameof(Person.Name)); + s.Should().Contain(nameof(Person.Age)); + s.Should().Contain(nameof(Person.Height)); + s.Should().Contain(person.Name); + s.Should().Contain(person.Age.ToString()); + } + + [Test] + public void PrintToString_ExcludingType_DoesNotContainValueOfThatType() + { + var person = new Person { Id = Guid.NewGuid(), Name = "Петя", Height = 1.75, Age = 30 }; + var printer = ObjectPrinter.For().Excluding(); + + string s = printer.PrintToString(person); + + s.Should().NotContain(person.Id.ToString()); + } + + [Test] + public void PrintToString_TypeSerializer_AppliesSerializerForType() + { + var person = new Person { Name = "X", Age = 255 }; + var printer = ObjectPrinter.For() + .Printing().Using(i => i.ToString("X")); + + string s = printer.PrintToString(person); + + s.Should().Contain(person.Age.ToString("X")); + } + + [Test] + public void PrintToString_TypeCulture_AppliesCultureForIFormattable() + { + var person = new Person { Name = "Y", Height = 1234.56 }; + var printer = ObjectPrinter.For() + .Printing().Using(CultureInfo.InvariantCulture); + + string s = printer.PrintToString(person); + + var expected = person.Height.ToString(CultureInfo.InvariantCulture); + s.Should().Contain(expected); + } + + [Test] + public void PrintToString_MemberSerializer_AppliesToSpecificMember() + { + var person = new Person { Name = "Петя", Age = 20 }; + var printer = ObjectPrinter.For() + .Printing(p => p.Name).Using(n => $"<{n}>"); + + string s = printer.PrintToString(person); + + s.Should().Contain($"<{person.Name}>"); + } + + [Test] + public void PrintToString_TrimmedToLength_TruncatesStringMember() + { + var person = new Person { Name = "Василий", Age = 40 }; + var printer = ObjectPrinter.For() + .Printing(p => p.Name).TrimmedToLength(3); + + string s = printer.PrintToString(person); + + s.Should().Contain(person.Name.Substring(0, 3)); + s.Should().NotContain(person.Name.Substring(0, 4)); + } + + [Test] + public void PrintToString_ExcludingMember_DoesNotContainMemberValue() + { + var person = new Person { Name = "Петя", Age = 77 }; + var printer = ObjectPrinter.For() + .Excluding(p => p.Age); + + string s = printer.PrintToString(person); + + s.Should().NotContain(person.Age.ToString()); + } + + [Test] + public void PrintToString_CircularReferences_DoesNotStackOverflowAndShowsMarker() + { + var a = new Node { Name = "A" }; + var b = new Node { Name = "B" }; + a.Other = b; + b.Other = a; + + string s = ObjectPrinter.For().PrintToString(a); + + s.Should().Contain(a.Name); + s.Should().Contain(b.Name); + s.Should().MatchRegex("(?i).*Циклическая.*"); + } + + [Test] + public void PrintToString_Collections_ArraysListsAndDictionariesAreSerialized() + { + var container = new Container + { + Numbers = new[] { 1, 2 }, + List = new List { "x", "y" }, + Map = new Dictionary { { "k", 42 } } + }; + + string s = ObjectPrinter.For().PrintToString(container); + + s.Should().Contain(nameof(Container)); + s.Should().Contain(nameof(Container.Numbers)); + s.Should().Contain("[0]"); + s.Should().Contain("1"); + s.Should().Contain(nameof(Container.List)); + s.Should().Contain("x"); + s.Should().Contain(nameof(Container.Map)); + s.Should().Contain("Key"); + s.Should().Contain("Value"); + s.Should().Contain("k"); + s.Should().Contain("42"); + } + } +} diff --git a/ObjectPrinting/TypePrintingConfig.cs b/ObjectPrinting/TypePrintingConfig.cs new file mode 100644 index 000000000..5005d2658 --- /dev/null +++ b/ObjectPrinting/TypePrintingConfig.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ObjectPrinting +{ + public class TypePrintingConfig(PrintingConfig parent) + { + public PrintingConfig Using(Func serializer) + { + parent.SetTypeSerializer(serializer); + return parent; + } + + public PrintingConfig Using(CultureInfo culture) + { + parent.SetTypeCulture(culture); + return parent; + } + } +}