Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions src/Serialize.Linq.Tests/Issues/Issue158.cs
Original file line number Diff line number Diff line change
@@ -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<Func<OutputChannelLog, bool>> filter = x => x.Channel == channel;

var text = expressionSerializer.SerializeText(filter);
var deserialized = (Expression<Func<OutputChannelLog, bool>>)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<OutputChannelCodes> allowed = new[] { OutputChannelCodes.Email };

var serializer = new JsonSerializer { AutoDiscoverKnownTypes = true };
var expressionSerializer = new ExpressionSerializer(serializer);

Expression<Func<OutputChannelLog, bool>> 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<Func<OutputChannelLog, bool>> filter = x => x.Channel == channel;

Assert.ThrowsException<SerializationException>(() => expressionSerializer.SerializeText(filter));
}
}
}
7 changes: 7 additions & 0 deletions src/Serialize.Linq/Interfaces/ISerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ public interface ISerializer
/// </remarks>
bool AutoAddKnownTypesAsListTypes { get; set; }

/// <summary>
/// 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
/// <see cref="AddKnownType"/> manually for custom types or enum values used inside an expression.
/// </summary>
bool AutoDiscoverKnownTypes { get; set; }

/// <summary>
/// Adds a new type to the list of known types.
/// </summary>
Expand Down
135 changes: 135 additions & 0 deletions src/Serialize.Linq/Internals/KnownTypeDiscoverer.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Walks a serialized <see cref="Node"/> 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 <c>AddKnownType</c> manually for every such type.
/// </summary>
internal static class KnownTypeDiscoverer
{
public static IEnumerable<Type> Discover(Node root)
{
var result = new HashSet<Type>();
if (root == null)
return result;

VisitNode(root, result, new HashSet<Node>(ReferenceComparer.Instance), new HashSet<Type>());
return result;
}

private static void VisitNode(Node node, HashSet<Type> result, HashSet<Node> visitedNodes, HashSet<Type> 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;
}
}

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
private static void CollectType(Type type, HashSet<Type> result, HashSet<Type> 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<Node>
{
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);
}
}
}
9 changes: 8 additions & 1 deletion src/Serialize.Linq/Internals/KnownTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,16 @@ internal static class KnownTypes

private static readonly HashSet<Type> _allExploded = new HashSet<Type>(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)));

/// <summary>
/// Determines whether the type is one of the built-in known types (or one of their exploded
/// array/list/nullable variants). Unlike <see cref="Match"/> this uses exact set membership, so a
/// concrete enum type does not count as built-in just because <see cref="Enum"/> is registered.
/// </summary>
public static bool IsBuiltIn(Type type) => type != null && _allExploded.Contains(type);

public static IEnumerable<Type> Explode(IEnumerable<Type> types, bool includeArrayTypes, bool includeListTypes)
{
foreach (var type in types)
Expand Down
6 changes: 3 additions & 3 deletions src/Serialize.Linq/Serialize.Linq.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@

<PropertyGroup>
<TargetFrameworks>net48;net481;net6.0;net7.0;net8.0;net9.0;net10.0;netstandard2.0;netstandard2.1</TargetFrameworks>
<Version>4.3.0</Version>
<AssemblyVersion>4.3.0.0</AssemblyVersion>
<FileVersion>4.3.0.0</FileVersion>
<Version>4.4.0</Version>
<AssemblyVersion>4.4.0.0</AssemblyVersion>
<FileVersion>4.4.0.0</FileVersion>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>

Expand Down
3 changes: 3 additions & 0 deletions src/Serialize.Linq/Serializers/DataSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public virtual void Serialize<T>(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);
}
Expand Down
6 changes: 6 additions & 0 deletions src/Serialize.Linq/Serializers/ExpressionSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 14 additions & 2 deletions src/Serialize.Linq/Serializers/SerializerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public abstract class SerializerBase
private readonly HashSet<Type> _customKnownTypes;
private bool _autoAddKnownTypesAsArrayTypes;
private bool _autoAddKnownTypesAsListTypes;
private bool _autoDiscoverKnownTypes;
private IEnumerable<Type> _knownTypesExploded;

protected SerializerBase()
Expand Down Expand Up @@ -42,13 +43,24 @@ public bool AutoAddKnownTypesAsListTypes
}
}

/// <summary>
/// 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
/// <see cref="AddKnownType"/> manually for custom types or enum values used inside an expression.
/// </summary>
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<Type> types)
Expand Down
Loading