From ebb0e402864fd8d1089cec1ac1c11ad383ef572b Mon Sep 17 00:00:00 2001 From: Sascha Kiefer Date: Tue, 16 Jun 2026 11:12:31 +0200 Subject: [PATCH] feat: add AutoDiscoverKnownTypes to auto-register constant types Serializing an expression that captures a custom type or enum value previously threw "Error converting type" unless the caller registered each type via AddKnownType, because the DataContractSerializer requires polymorphic constant types to be known. Add an opt-in AutoDiscoverKnownTypes flag on the serializers (and ExpressionSerializer). When enabled, the node tree is walked at serialize time and the runtime types of constant values (plus their array element / generic argument types) are registered automatically. Discovery inspects types statically and never enumerates the constant values, so lazy sequences such as IQueryable are not executed. The option is off by default, so existing behavior is unchanged. Closes #158 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 17 +++ src/Serialize.Linq.Tests/Issues/Issue158.cs | 87 +++++++++++ src/Serialize.Linq/Interfaces/ISerializer.cs | 7 + .../Internals/KnownTypeDiscoverer.cs | 135 ++++++++++++++++++ src/Serialize.Linq/Internals/KnownTypes.cs | 9 +- src/Serialize.Linq/Serialize.Linq.csproj | 6 +- .../Serializers/DataSerializer.cs | 3 + .../Serializers/ExpressionSerializer.cs | 6 + .../Serializers/SerializerBase.cs | 16 ++- 9 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 src/Serialize.Linq.Tests/Issues/Issue158.cs create mode 100644 src/Serialize.Linq/Internals/KnownTypeDiscoverer.cs 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)