diff --git a/CHANGELOG.md b/CHANGELOG.md index fe25f04..43166d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 This changelog was reconstructed from the project's commit history and published NuGet releases; entries for older versions are a best-effort summary. +## [4.4.0] - 2026-06-16 + +### Added +- `AutoDiscoverKnownTypes` option on the serializers (and `ExpressionSerializer`). When enabled, the + runtime types of constant values found in an expression are walked and registered as known types + automatically during serialization, so expressions that capture custom types or enum values no longer + require a manual `AddKnownType` call for each such type ([#158]). + +### Notes +- The option is opt-in and off by default, so existing behavior is unchanged. Discovery inspects constant + value types (and their array element / generic argument types) statically — the constant values are never + enumerated, so lazy sequences such as `IQueryable` are not executed. +- Discovery runs on the serialization side; the discovered types are added to the serializer instance, so a + serialize/deserialize round-trip on the same instance works without extra registration. When deserializing + on a different instance, register the types there as usual. + ## [4.3.0] - 2026-06-16 ### Added @@ -131,6 +147,7 @@ NuGet releases; entries for older versions are a best-effort summary. - Baseline release. [Unreleased]: https://github.com/esskar/Serialize.Linq/compare/main...HEAD +[#158]: https://github.com/esskar/Serialize.Linq/issues/158 [#178]: https://github.com/esskar/Serialize.Linq/issues/178 [#151]: https://github.com/esskar/Serialize.Linq/issues/151 [#169]: https://github.com/esskar/Serialize.Linq/issues/169 diff --git a/src/Serialize.Linq.Tests/Issues/Issue158.cs b/src/Serialize.Linq.Tests/Issues/Issue158.cs new file mode 100644 index 0000000..0e5a35f --- /dev/null +++ b/src/Serialize.Linq.Tests/Issues/Issue158.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Runtime.Serialization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Serialize.Linq.Factories; +using Serialize.Linq.Interfaces; +using Serialize.Linq.Serializers; + +namespace Serialize.Linq.Tests.Issues +{ + // https://github.com/esskar/Serialize.Linq/issues/158 + [TestClass] + public class Issue158 + { + public enum OutputChannelCodes + { + Sms, + Email + } + + public class OutputChannelLog + { + public OutputChannelCodes Channel { get; set; } + } + + private static OutputChannelLog[] BuildItems() => new[] + { + new OutputChannelLog { Channel = OutputChannelCodes.Sms }, + new OutputChannelLog { Channel = OutputChannelCodes.Sms }, + new OutputChannelLog { Channel = OutputChannelCodes.Email }, + new OutputChannelLog { Channel = OutputChannelCodes.Email } + }; + + [TestMethod] + public void AutoDiscoverKnownTypes_RoundTripsEnumConstant_WithoutManualKnownType() + { + foreach (var serializer in new ITextSerializer[] { new JsonSerializer(), new XmlSerializer() }) + { + serializer.AutoDiscoverKnownTypes = true; + var expressionSerializer = new ExpressionSerializer(serializer); + + // The captured local becomes an enum-typed constant after closure compression - the exact + // shape that previously required a manual AddKnownType(typeof(OutputChannelCodes)) call. + var channel = OutputChannelCodes.Email; + Expression> filter = x => x.Channel == channel; + + var text = expressionSerializer.SerializeText(filter); + var deserialized = (Expression>)expressionSerializer.DeserializeText(text); + + var count = BuildItems().AsQueryable().Where(deserialized).Count(); + Assert.AreEqual(2, count, $"Serializer: {serializer.GetType().Name}"); + } + } + + [TestMethod] + public void AutoDiscoverKnownTypes_SerializesEnumCollectionConstant_WithoutManualKnownType() + { + // Runtime type of the captured collection is OutputChannelCodes[]; the discoverer must register + // both the array and its element type. Compare Issue156, which needed AddKnownType(SomeEnum[]) + // to get past serialization. + IReadOnlyCollection allowed = new[] { OutputChannelCodes.Email }; + + var serializer = new JsonSerializer { AutoDiscoverKnownTypes = true }; + var expressionSerializer = new ExpressionSerializer(serializer); + + Expression> filter = x => allowed.Contains(x.Channel); + + // Without discovery this throws SerializationException ("Error converting type"). + var text = expressionSerializer.SerializeText(filter, new FactorySettings { AllowPrivateFieldAccess = true }); + Assert.IsFalse(string.IsNullOrEmpty(text)); + } + + [TestMethod] + public void WithoutAutoDiscoverKnownTypes_SerializingEnumConstant_Throws() + { + // Documents the historical behaviour (issue #158) that AutoDiscoverKnownTypes resolves. + var expressionSerializer = new ExpressionSerializer(new JsonSerializer()); + + var channel = OutputChannelCodes.Email; + Expression> filter = x => x.Channel == channel; + + Assert.ThrowsException(() => expressionSerializer.SerializeText(filter)); + } + } +} diff --git a/src/Serialize.Linq/Interfaces/ISerializer.cs b/src/Serialize.Linq/Interfaces/ISerializer.cs index 9cd096d..4fc5a5d 100644 --- a/src/Serialize.Linq/Interfaces/ISerializer.cs +++ b/src/Serialize.Linq/Interfaces/ISerializer.cs @@ -26,6 +26,13 @@ public interface ISerializer /// bool AutoAddKnownTypesAsListTypes { get; set; } + /// + /// If set to true, the runtime types of constant values found in an expression are automatically + /// registered as known types during serialization, removing the need to call + /// manually for custom types or enum values used inside an expression. + /// + bool AutoDiscoverKnownTypes { get; set; } + /// /// Adds a new type to the list of known types. /// diff --git a/src/Serialize.Linq/Internals/KnownTypeDiscoverer.cs b/src/Serialize.Linq/Internals/KnownTypeDiscoverer.cs new file mode 100644 index 0000000..e679c2a --- /dev/null +++ b/src/Serialize.Linq/Internals/KnownTypeDiscoverer.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using Serialize.Linq.Nodes; + +namespace Serialize.Linq.Internals +{ + /// + /// Walks a serialized tree and collects the runtime types of constant values so + /// they can be registered as known types with the underlying data contract serializer. This is what + /// allows expressions that capture custom types or enum values to be serialized without the caller + /// having to call AddKnownType manually for every such type. + /// + internal static class KnownTypeDiscoverer + { + public static IEnumerable Discover(Node root) + { + var result = new HashSet(); + if (root == null) + return result; + + VisitNode(root, result, new HashSet(ReferenceComparer.Instance), new HashSet()); + return result; + } + + private static void VisitNode(Node node, HashSet result, HashSet visitedNodes, HashSet visitedTypes) + { + if (node == null || !visitedNodes.Add(node)) + return; + + // A constant's value is the only member declared as object, so it is the one place the data + // contract serializer needs a polymorphic type registered. We read its runtime type but never + // enumerate the value itself, which could execute an IQueryable or other lazy sequence. + if (node is ConstantExpressionNode constant) + CollectType(constant.Value?.GetType(), result, visitedTypes); + + foreach (var property in node.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length > 0) + continue; + + // Only follow members that are structurally part of the node tree. Reading other members + // (e.g. the constant value) is intentionally avoided here to prevent side effects. + if (typeof(Node).IsAssignableFrom(property.PropertyType)) + { + VisitNode((Node)GetValue(property, node), result, visitedNodes, visitedTypes); + } + else + { + var elementType = GetNodeElementType(property.PropertyType); + if (elementType == null) + continue; + + if (GetValue(property, node) is IEnumerable sequence) + { + foreach (var item in sequence) + VisitNode(item as Node, result, visitedNodes, visitedTypes); + } + } + } + } + + private static object GetValue(PropertyInfo property, Node node) + { + try + { + return property.GetValue(node); + } + catch + { + return null; + } + } + + /// + /// Returns the element type of a property that is an array or generic sequence of nodes, or null + /// when the property is not a node collection. + /// + private static Type GetNodeElementType(Type type) + { + if (type.IsArray) + { + var elementType = type.GetElementType(); + return elementType != null && typeof(Node).IsAssignableFrom(elementType) ? elementType : null; + } + + foreach (var candidate in new[] { type }.Concat(type.GetInterfaces())) + { + if (!candidate.IsGenericType || candidate.GetGenericTypeDefinition() != typeof(IEnumerable<>)) + continue; + + var elementType = candidate.GetGenericArguments()[0]; + if (typeof(Node).IsAssignableFrom(elementType)) + return elementType; + } + + return null; + } + + /// + /// Adds a type and its array element / generic argument types to the result set, skipping the + /// built-in known types. The type structure is inspected statically; the value is never enumerated. + /// + private static void CollectType(Type type, HashSet result, HashSet visitedTypes) + { + if (type == null || !visitedTypes.Add(type)) + return; + + if (!KnownTypes.IsBuiltIn(type)) + result.Add(type); + + if (type.IsArray) + { + CollectType(type.GetElementType(), result, visitedTypes); + } + else if (type.IsGenericType) + { + foreach (var argument in type.GetGenericArguments()) + CollectType(argument, result, visitedTypes); + } + } + + private sealed class ReferenceComparer : IEqualityComparer + { + public static readonly ReferenceComparer Instance = new ReferenceComparer(); + + public bool Equals(Node x, Node y) => ReferenceEquals(x, y); + + public int GetHashCode(Node obj) => RuntimeHelpers.GetHashCode(obj); + } + } +} diff --git a/src/Serialize.Linq/Internals/KnownTypes.cs b/src/Serialize.Linq/Internals/KnownTypes.cs index 00f67a2..ee7afe4 100644 --- a/src/Serialize.Linq/Internals/KnownTypes.cs +++ b/src/Serialize.Linq/Internals/KnownTypes.cs @@ -26,9 +26,16 @@ internal static class KnownTypes private static readonly HashSet _allExploded = new HashSet(Explode(All, true, true)); - public static bool Match(Type type) => + public static bool Match(Type type) => type != null && (_allExploded.Contains(type) || _allExploded.Any(t => t.IsAssignableFrom(type))); + /// + /// Determines whether the type is one of the built-in known types (or one of their exploded + /// array/list/nullable variants). Unlike this uses exact set membership, so a + /// concrete enum type does not count as built-in just because is registered. + /// + public static bool IsBuiltIn(Type type) => type != null && _allExploded.Contains(type); + public static IEnumerable Explode(IEnumerable types, bool includeArrayTypes, bool includeListTypes) { foreach (var type in types) diff --git a/src/Serialize.Linq/Serialize.Linq.csproj b/src/Serialize.Linq/Serialize.Linq.csproj index 22b9db0..c4fc563 100644 --- a/src/Serialize.Linq/Serialize.Linq.csproj +++ b/src/Serialize.Linq/Serialize.Linq.csproj @@ -29,9 +29,9 @@ net48;net481;net6.0;net7.0;net8.0;net9.0;net10.0;netstandard2.0;netstandard2.1 - 4.3.0 - 4.3.0.0 - 4.3.0.0 + 4.4.0 + 4.4.0.0 + 4.4.0.0 LICENSE diff --git a/src/Serialize.Linq/Serializers/DataSerializer.cs b/src/Serialize.Linq/Serializers/DataSerializer.cs index 1766eda..c98cf55 100644 --- a/src/Serialize.Linq/Serializers/DataSerializer.cs +++ b/src/Serialize.Linq/Serializers/DataSerializer.cs @@ -30,6 +30,9 @@ public virtual void Serialize(Stream stream, T obj) where T : Node if (stream == null) throw new ArgumentNullException(nameof(stream)); + if (AutoDiscoverKnownTypes && obj != null) + AddKnownTypes(Internals.KnownTypeDiscoverer.Discover(obj)); + var serializer = CreateSerializer(typeof(T)); serializer.WriteObject(stream, obj); } diff --git a/src/Serialize.Linq/Serializers/ExpressionSerializer.cs b/src/Serialize.Linq/Serializers/ExpressionSerializer.cs index 7995e16..1043240 100644 --- a/src/Serialize.Linq/Serializers/ExpressionSerializer.cs +++ b/src/Serialize.Linq/Serializers/ExpressionSerializer.cs @@ -31,6 +31,12 @@ public bool AutoAddKnownTypesAsListTypes set => _serializer.AutoAddKnownTypesAsListTypes = value; } + public bool AutoDiscoverKnownTypes + { + get => _serializer.AutoDiscoverKnownTypes; + set => _serializer.AutoDiscoverKnownTypes = value; + } + public bool CanSerializeText => _serializer is ITextSerializer; public bool CanSerializeBinary => _serializer is IBinarySerializer; diff --git a/src/Serialize.Linq/Serializers/SerializerBase.cs b/src/Serialize.Linq/Serializers/SerializerBase.cs index 4624fe4..70d49ad 100644 --- a/src/Serialize.Linq/Serializers/SerializerBase.cs +++ b/src/Serialize.Linq/Serializers/SerializerBase.cs @@ -10,6 +10,7 @@ public abstract class SerializerBase private readonly HashSet _customKnownTypes; private bool _autoAddKnownTypesAsArrayTypes; private bool _autoAddKnownTypesAsListTypes; + private bool _autoDiscoverKnownTypes; private IEnumerable _knownTypesExploded; protected SerializerBase() @@ -42,13 +43,24 @@ public bool AutoAddKnownTypesAsListTypes } } + /// + /// If set to true, the runtime types of constant values encountered while serializing an + /// expression are automatically registered as known types. This removes the need to call + /// manually for custom types or enum values used inside an expression. + /// + public bool AutoDiscoverKnownTypes + { + get => _autoDiscoverKnownTypes; + set => _autoDiscoverKnownTypes = value; + } + public void AddKnownType(Type type) { if (type == null) throw new ArgumentNullException(nameof(type)); - _customKnownTypes.Add(type); - _knownTypesExploded = null; + if (_customKnownTypes.Add(type)) + _knownTypesExploded = null; } public void AddKnownTypes(IEnumerable types)