nhibernate-core icon indicating copy to clipboard operation
nhibernate-core copied to clipboard

Support for "first-class span types" C# 14 language feature

Open zgabi opened this issue 10 months ago • 15 comments

Since the latest Visual Studio version (17.13)/C# version some of our code started to fail at runtime. The problem is that Microsoft implemeted the "First-class Span types" language feature (https://github.com/dotnet/csharplang/blob/main/proposals/first-class-span-types.md) I'm using the LangVersion=preview settings (because of the "field" keyword)

For example we have the following code:

        var ids = new Guid[] { guid1, guid2... };
        foreach (var r in session.Query<MyClass>().Where(x => ids.Contains(x.Id)))
        {
            ...
        }
 

Earlier (before 11.02.2025) this code worked, but now it throws the following exception:

NHibernate.HibernateException
  HResult=0x80131500
  Message=Evaluation failure on op_Implicit(value(System.Guid[]))
  Source=NHibernate
  StackTrace:
   at NHibernate.Linq.Visitors.NhPartialEvaluatingExpressionVisitor.Visit(Expression expression)
....
Inner Exception 1:
NotSupportedException: Specified method is not supported.
 

Because earlier the predicate expression in the Where method was calling the Enumerable.Contains extension method, but now it calls the MemoryExtensions.Contains method.

There is a workaround:

        var ids = new Guid[] { guid1, guid2... };
        foreach (var r in session.Query<MyClass>().Where(x => ((IEnumerable<Guid>)ids).Contains(x.Id)))
        {
            ...
        }

but it needs to change our code everywhere. And hard to find every places since there is no build error, only a runtime exception.

zgabi avatar Feb 13 '25 19:02 zgabi

Another workaround, which would apply globally, would be to add custom generators for these MemoryExtensions methods, that would mimic the work of the standard generators.

fredericDelaporte avatar Feb 14 '25 19:02 fredericDelaporte

.NET 10 generates compile time error when using LangVersion=preview in this case.

hazzik avatar Mar 14 '25 13:03 hazzik

Have hit this issue as well - I tried going down the route of the custom generators, but I couldn't find any way of stopping the exception on op_Implicit, or of intercepting that call.

I tried adding a class based on BaseHqlGeneratorForMethod for matching MemoryExtensions.Contains, one using IRuntimeMethodHqlGenerator and also overriding the methods on DefaultLinqToHqlGeneratorsRegistry.

IDDesigns avatar Apr 16 '25 13:04 IDDesigns

This issue is still there on .NET 10, can't use Visual Studio Insiders because of it

Topaz-Frog avatar Sep 22 '25 08:09 Topaz-Frog

@Topaz-Frog, set LangVersion=preview and fix build errors.

hazzik avatar Sep 22 '25 09:09 hazzik

@hazzik For me there is no build error.

Earlier I put a cast to IEnumerable to my lists to force using the System.Linq.Enumerable methods instead of the MemoryExtenstions, but now I was curious whether your suggestion helps to solve the error in .NET 10 + preview language settings, so I removed the cast... And I don't get any build error...

zgabi avatar Sep 22 '25 13:09 zgabi

Ok I've checked on latest rc. They seem relaxed the warning. Previously on my version it was showing warning/error that by ref structs cannot be in expression trees.

This feature has broken a lot of things by the look of it. https://github.com/dotnet/runtime/issues/109757

hazzik avatar Sep 23 '25 01:09 hazzik

EFCore solution to remapping MemoryExtensions: https://github.com/dotnet/efcore/blob/2e211e4e2f6cb34f29547707a00ba9ea695be8f4/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs#L918

IDDesigns avatar Oct 14 '25 10:10 IDDesigns

I think I have a workaround, based on the code from EFCore

In the configuration add:

cfg.LinqQueryProvider<CustomNhQueryProvider>();

Then add a class:

using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Reflection;
using NHibernate;
using NHibernate.Engine;
using NHibernate.Linq;

namespace My.NHibernate;

public sealed class CustomNhQueryProvider : DefaultQueryProvider
{
#if NET9_0_OR_GREATER
    private static readonly MemoryExtensionsVisitor _visitor = new MemoryExtensionsVisitor();
#endif

    public CustomNhQueryProvider(ISessionImplementor session) : base(session) { }

    public CustomNhQueryProvider(ISessionImplementor session, object collection) : base(session, collection) { }

    protected override NhLinqExpression PrepareQuery(Expression expression, out IQuery query)
    {
#if NET9_0_OR_GREATER
        expression = _visitor.Visit(expression);
#endif

        return base.PrepareQuery(expression, out query);
    }

    public override IQueryable CreateQuery(Expression expression)
    {
#if NET9_0_OR_GREATER
        expression = _visitor.Visit(expression);
#endif

        return base.CreateQuery(expression);
    }

    public override IQueryable<T> CreateQuery<T>(Expression expression)
    {
#if NET9_0_OR_GREATER
        expression = _visitor.Visit(expression);
#endif

        return base.CreateQuery<T>(expression);
    }

#if NET9_0_OR_GREATER
    private sealed class MemoryExtensionsVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo _contains;

        private static readonly MethodInfo _sequenceEqual;

        protected override Expression VisitMethodCall(MethodCallExpression node)
        {
            var method = node.Method;

            if (method.DeclaringType != typeof(MemoryExtensions))
            {
                return base.VisitMethodCall(node);
            }

            // .NET 10 made changes to overload resolution to prefer Span-based overloads when those exist ("first-class spans").
            // Unfortunately, the LINQ interpreter does not support ref structs, so we rewrite e.g. MemoryExtensions.Contains to
            // Enumerable.Contains here.
            // Code below taken and adapted from to EFCore solution
            switch (method.Name)
            {
                // Note that MemoryExtensions.Contains has an optional 3rd ComparisonType parameter; we only match when
                // it's null.
                case nameof(MemoryExtensions.Contains)
                    when node.Arguments is [var spanArg, var valueArg, ..] &&
                         (node.Arguments.Count is 2 ||
                          node.Arguments.Count is 3 && node.Arguments[2] is ConstantExpression { Value: null }) &&
                         TryUnwrapSpanImplicitCast(spanArg, out var unwrappedSpanArg):
                {
                    return Visit(
                        Expression.Call(
                            _contains.MakeGenericMethod(method.GetGenericArguments()[0]),
                            unwrappedSpanArg,
                            valueArg));
                }

                case nameof(MemoryExtensions.SequenceEqual)
                    when node.Arguments is [var spanArg, var otherArg] &&
                         TryUnwrapSpanImplicitCast(spanArg, out var unwrappedSpanArg) &&
                         TryUnwrapSpanImplicitCast(otherArg, out var unwrappedOtherArg):
                    return Visit(
                        Expression.Call(
                            _sequenceEqual.MakeGenericMethod(method.GetGenericArguments()[0]),
                            unwrappedSpanArg,
                            unwrappedOtherArg));
                default:
                    throw new NotSupportedException(
                        $"The method '{method}' is not supported by the LINQ to NHibernate provider.");
            }

            static bool TryUnwrapSpanImplicitCast(Expression expression, [NotNullWhen(true)] out Expression? result)
            {
                if (expression is MethodCallExpression
                    {
                        Method:
                        {
                            Name: "op_Implicit", DeclaringType: { IsGenericType: true } implicitCastDeclaringType
                        },
                        Arguments: [var unwrapped]
                    } &&
                    implicitCastDeclaringType.GetGenericTypeDefinition() is var genericTypeDefinition &&
                    (genericTypeDefinition == typeof(Span<>) || genericTypeDefinition == typeof(ReadOnlySpan<>)))
                {
                    // nHibernate does not like it when there is a convert to the same type, so we remove it here.
                    if (unwrapped is UnaryExpression { NodeType: ExpressionType.Convert } u && u.Type == u.Operand.Type)
                    {
                        result = u.Operand;
                    }
                    else
                    {
                        result = unwrapped;
                    }

                    return true;
                }

                result = null;
                return false;
            }
        }

        /// <summary>
        /// Initialise the lookups for Enumerable methods.
        /// </summary>
        static MemoryExtensionsVisitor()
        {
            var queryableMethodGroups = typeof(Enumerable)
                .GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
                .GroupBy(mi => mi.Name)
                .ToDictionary(e => e.Key, l => l.ToList());

            _contains = GetMethod(
                nameof(Enumerable.Contains),
                1,
                types => [typeof(IEnumerable<>).MakeGenericType(types[0]), types[0]]);

            _sequenceEqual = GetMethod(
                nameof(Enumerable.SequenceEqual),
                1,
                types =>
                [
                    typeof(IEnumerable<>).MakeGenericType(types[0]), typeof(IEnumerable<>).MakeGenericType(types[0])
                ]);
            return;

            MethodInfo GetMethod(
                string name,
                int genericParameterCount,
                Func<System.Type[], System.Type[]> parameterGenerator)
            {
                return queryableMethodGroups[name].Single(mi =>
                    ((genericParameterCount == 0 && !mi.IsGenericMethod) ||
                     (mi.IsGenericMethod && mi.GetGenericArguments().Length == genericParameterCount)) &&
                    mi.GetParameters().Select(e => e.ParameterType).SequenceEqual(
                        parameterGenerator(mi.IsGenericMethod ? mi.GetGenericArguments() : [])));
            }
        }
    }
#endif
}

IDDesigns avatar Oct 21 '25 12:10 IDDesigns

There seems to be no warnings or errors of this in the released version of .NET 10, there are only errors during runtime. Can the workaround be implemented in NHibernate?

andreas-eriksson avatar Nov 12 '25 10:11 andreas-eriksson