Skip to content

Commit fc81792

Browse files
NH-4009 - Handling db-only methods without exceptions
1 parent 333eed6 commit fc81792

File tree

7 files changed

+230
-34
lines changed

7 files changed

+230
-34
lines changed

doc/reference/modules/query_linq.xml

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ using NHibernate.Linq;]]></programlisting>
6767
session.Query<Cat>()
6868
.Where(c => c.BirthDate == DateTime.Today.MappedAs(NHibernateUtil.Date))
6969
.ToList();]]></programlisting>
70+
<programlisting><![CDATA[IList<Cat> cats =
71+
session.Query<Cat>()
72+
.Where(c => c.Name == "Max".MappedAs(TypeFactory.Basic("AnsiString(200)")))
73+
.ToList();]]></programlisting>
7074
</sect1>
7175

7276
<sect1 id="querylinq-supportedmethods">
@@ -656,11 +660,20 @@ IList<Cat> cats =
656660
}
657661
}]]></programlisting>
658662
<para>
659-
The method call will always be translated to SQL if at least one of the parameters of the
660-
method call has its value originating from an entity. Otherwise, the Linq provider will try to
661-
evaluate the method call with .Net runtime instead. Since NHibernate 5.0, if this runtime
662-
evaluation fails (throws an exception), then the method call will be translated to SQL too.
663+
Since NHibernate v5.0, the Linq provider will no more evaluate in-memory the method call
664+
even when it does not depend on the queried data. If you wish to have the method call evaluated
665+
before querying whenever possible, and then replaced in the query by its resulting value, specify
666+
<literal>LinqExtensionPreEvaluation.AllowPreEvaluation</literal> on the attribute.
663667
</para>
668+
<programlisting><![CDATA[public static class CustomLinqExtensions
669+
{
670+
[LinqExtensionMethod("dbo.aCustomFunction", LinqExtensionPreEvaluation.AllowPreEvaluation)]
671+
public static string ACustomFunction(this string input, string otherInput)
672+
{
673+
// In-memory evaluation implementation.
674+
return input.Replace(otherInput, "blah");
675+
}
676+
}]]></programlisting>
664677
</sect2>
665678

666679
<sect2 id="querylinq-extending-generator">
@@ -767,6 +780,13 @@ cfg.LinqToHqlGeneratorsRegistry<ExtendedLinqToHqlGeneratorsRegistry>();
767780
<para>
768781
(Of course, the same result could be obtained with <literal>(DateTime?)(c.BirthDate)</literal>.)
769782
</para>
783+
<para>
784+
By default, the Linq provider will try to evaluate the method call with .Net runtime
785+
whenever possible, instead of translating it to SQL. It will not do it if at least one
786+
of the parameters of the method call has its value originating from an entity, or if
787+
the method is marked with the <literal>NoPreEvaluation</literal> attribute (available
788+
since NHibernate 5.0).
789+
</para>
770790
</sect2>
771791
</sect1>
772792
</chapter>
Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
1-
using NHibernate.Linq;
1+
using System;
2+
using NHibernate.Linq;
23

34
namespace NHibernate.Test.Linq
45
{
5-
static class ExtensionMethods
6-
{
7-
[LinqExtensionMethod("Replace")]
8-
public static string ReplaceExtension(this string subject, string search, string replaceWith)
9-
{
10-
return null;
11-
}
6+
static class ExtensionMethods
7+
{
8+
[LinqExtensionMethod("Replace")]
9+
public static string ReplaceExtension(this string subject, string search, string replaceWith)
10+
{
11+
throw new InvalidOperationException("To be translated to SQL only");
12+
}
1213

13-
[LinqExtensionMethod]
14-
public static string Replace(this string subject, string search, string replaceWith)
15-
{
16-
return null;
17-
}
18-
}
14+
[LinqExtensionMethod]
15+
public static string Replace(this string subject, string search, string replaceWith)
16+
{
17+
throw new InvalidOperationException("To be translated to SQL only");
18+
}
19+
20+
// NH4 default behavior
21+
[LinqExtensionMethod("Replace", LinqExtensionPreEvaluation.AllowPreEvaluation)]
22+
public static string ReplaceWithEvaluation(this string subject, string search, string replaceWith)
23+
{
24+
if (subject == null)
25+
throw new ArgumentNullException(nameof(subject));
26+
return subject.Replace(search, replaceWith) + " (done in-memory)";
27+
}
28+
29+
// Just for checking weird combination
30+
[LinqExtensionMethod("Replace", LinqExtensionPreEvaluation.AllowPreEvaluation), NoPreEvaluation]
31+
public static string ReplaceWithEvaluation2(this string subject, string search, string replaceWith)
32+
{
33+
if (subject == null)
34+
throw new ArgumentNullException(nameof(subject));
35+
return subject.Replace(search, replaceWith) + " (done in-memory)";
36+
}
37+
}
1938
}

src/NHibernate.Test/Linq/FunctionTests.cs

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Linq;
3+
using System.Text.RegularExpressions;
34
using NHibernate.DomainModel;
45
using NHibernate.DomainModel.Northwind.Entities;
56
using NHibernate.Linq;
@@ -114,17 +115,58 @@ where e.FirstName.Substring(0, 2) == "An"
114115
[Test]
115116
public void ReplaceFunction()
116117
{
118+
var suppliedName = "Anne";
117119
var query = from e in db.Employees
118120
where e.FirstName.StartsWith("An")
119121
select new
120-
{
121-
Before = e.FirstName,
122-
AfterMethod = e.FirstName.Replace("An", "Zan"),
123-
AfterExtension = ExtensionMethods.Replace(e.FirstName, "An", "Zan"),
124-
AfterExtension2 = e.FirstName.ReplaceExtension("An", "Zan")
125-
};
126-
127-
var s = ObjectDumper.Write(query);
122+
{
123+
Before = e.FirstName,
124+
// This one call the standard string.Replace, not the extension. The linq registry handles it.
125+
AfterMethod = e.FirstName.Replace("An", "Zan"),
126+
AfterExtension = ExtensionMethods.Replace(e.FirstName, "An", "Zan"),
127+
AfterNamedExtension = e.FirstName.ReplaceExtension("An", "Zan"),
128+
AfterEvaluableExtension = e.FirstName.ReplaceWithEvaluation("An", "Zan"),
129+
AfterEvaluable2Extension = e.FirstName.ReplaceWithEvaluation2("An", "Zan"),
130+
BeforeConst = suppliedName,
131+
// This one call the standard string.Replace, not the extension. The linq registry handles it.
132+
AfterMethodConst = suppliedName.Replace("An", "Zan"),
133+
AfterExtensionConst = ExtensionMethods.Replace(suppliedName, "An", "Zan"),
134+
AfterNamedExtensionConst = suppliedName.ReplaceExtension("An", "Zan"),
135+
AfterEvaluableExtensionConst = suppliedName.ReplaceWithEvaluation("An", "Zan"),
136+
AfterEvaluable2ExtensionConst = suppliedName.ReplaceWithEvaluation2("An", "Zan")
137+
};
138+
var results = query.ToList();
139+
var s = ObjectDumper.Write(results);
140+
141+
foreach (var result in results)
142+
{
143+
var expectedDbResult = Regex.Replace(result.Before, "An", "Zan", RegexOptions.Compiled | RegexOptions.IgnoreCase);
144+
Assert.That(result.AfterMethod, Is.EqualTo(expectedDbResult), $"Wrong {nameof(result.AfterMethod)} value");
145+
Assert.That(result.AfterExtension, Is.EqualTo(expectedDbResult), $"Wrong {nameof(result.AfterExtension)} value");
146+
Assert.That(result.AfterNamedExtension, Is.EqualTo(expectedDbResult), $"Wrong {nameof(result.AfterNamedExtension)} value");
147+
Assert.That(result.AfterEvaluableExtension, Is.EqualTo(expectedDbResult), $"Wrong {nameof(result.AfterEvaluableExtension)} value");
148+
Assert.That(result.AfterEvaluable2Extension, Is.EqualTo(expectedDbResult), $"Wrong {nameof(result.AfterEvaluable2Extension)} value");
149+
150+
var expectedDbResultConst = Regex.Replace(result.BeforeConst, "An", "Zan", RegexOptions.Compiled | RegexOptions.IgnoreCase);
151+
var expectedInMemoryResultConst = result.BeforeConst.Replace("An", "Zan");
152+
var expectedInMemoryExtensionResultConst = result.BeforeConst.ReplaceWithEvaluation("An", "Zan");
153+
Assert.That(result.AfterMethodConst, Is.EqualTo(expectedInMemoryResultConst), $"Wrong {nameof(result.AfterMethodConst)} value");
154+
Assert.That(result.AfterExtensionConst, Is.EqualTo(expectedDbResultConst), $"Wrong {nameof(result.AfterExtensionConst)} value");
155+
Assert.That(result.AfterNamedExtensionConst, Is.EqualTo(expectedDbResultConst), $"Wrong {nameof(result.AfterNamedExtensionConst)} value");
156+
Assert.That(result.AfterEvaluableExtensionConst, Is.EqualTo(expectedInMemoryExtensionResultConst), $"Wrong {nameof(result.AfterEvaluableExtensionConst)} value");
157+
Assert.That(result.AfterEvaluable2ExtensionConst, Is.EqualTo(expectedInMemoryExtensionResultConst), $"Wrong {nameof(result.AfterEvaluable2ExtensionConst)} value");
158+
}
159+
160+
// Should cause ReplaceWithEvaluation to fail
161+
suppliedName = null;
162+
var failingQuery = from e in db.Employees
163+
where e.FirstName.StartsWith("An")
164+
select new
165+
{
166+
Before = e.FirstName,
167+
AfterEvaluableExtensionConst = suppliedName.ReplaceWithEvaluation("An", "Zan")
168+
};
169+
Assert.That(() => failingQuery.ToList(), Throws.InstanceOf<HibernateException>().And.InnerException.InstanceOf<ArgumentNullException>());
128170
}
129171

130172
[Test]

src/NHibernate/Linq/LinqExtensionMethodAttribute.cs

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,106 @@
22

33
namespace NHibernate.Linq
44
{
5-
public class LinqExtensionMethodAttribute: Attribute
5+
/// <summary>
6+
/// Flag a method as being a SQL function call for the linq-to-nhibernate provider. Its
7+
/// parameters will be used as the function call parameters.
8+
/// </summary>
9+
public class LinqExtensionMethodAttribute : LinqExtensionMethodAttributeBase
610
{
11+
/// <summary>
12+
/// Default constructor. The method call will be translated by the linq provider to
13+
/// a function call having the same name than the method.
14+
/// </summary>
715
public LinqExtensionMethodAttribute()
8-
{
9-
}
16+
: base(LinqExtensionPreEvaluation.NoEvaluation) { }
1017

18+
/// <summary>
19+
/// Constructor specifying a SQL function name.
20+
/// </summary>
21+
/// <param name="name">The name of the SQL function.</param>
1122
public LinqExtensionMethodAttribute(string name)
23+
: this(name, LinqExtensionPreEvaluation.NoEvaluation) { }
24+
25+
/// <summary>
26+
/// Constructor allowing to specify a <see cref="LinqExtensionPreEvaluation"/> for the method.
27+
/// </summary>
28+
/// <param name="preEvaluation">Should the method call be pre-evaluated when not depending on
29+
/// queried data? Default is <see cref="LinqExtensionPreEvaluation.NoEvaluation"/>.</param>
30+
public LinqExtensionMethodAttribute(LinqExtensionPreEvaluation preEvaluation)
31+
: base(preEvaluation) { }
32+
33+
/// <summary>
34+
/// Constructor for specifying a SQL function name and a <see cref="LinqExtensionPreEvaluation"/>.
35+
/// </summary>
36+
/// <param name="name">The name of the SQL function.</param>
37+
/// <param name="preEvaluation">Should the method call be pre-evaluated when not depending on
38+
/// queried data? Default is <see cref="LinqExtensionPreEvaluation.NoEvaluation"/>.</param>
39+
public LinqExtensionMethodAttribute(string name, LinqExtensionPreEvaluation preEvaluation)
40+
: base(preEvaluation)
1241
{
1342
Name = name;
1443
}
1544

16-
public string Name { get; private set; }
45+
/// <summary>
46+
/// The name of the SQL function.
47+
/// </summary>
48+
public string Name { get; }
49+
}
50+
51+
/// <summary>
52+
/// Can flag a method as not being callable by the runtime, when used in Linq queries.
53+
/// If the method is supported by the linq-to-nhibernate provider, it will always be converted
54+
/// to the corresponding SQL statement.
55+
/// Otherwise the linq-to-nhibernate provider evaluates method calls when they do not depend on
56+
/// the queried data.
57+
/// </summary>
58+
public class NoPreEvaluationAttribute : LinqExtensionMethodAttributeBase
59+
{
60+
/// <summary>
61+
/// Default constructor.
62+
/// </summary>
63+
public NoPreEvaluationAttribute()
64+
: base(LinqExtensionPreEvaluation.NoEvaluation) { }
65+
}
66+
67+
/// <summary>
68+
/// Base class for Linq extension attributes.
69+
/// </summary>
70+
public abstract class LinqExtensionMethodAttributeBase : Attribute
71+
{
72+
/// <summary>
73+
/// Should the method call be pre-evaluated when not depending on queried data? If it can,
74+
/// it would then be evaluated and replaced by the resulting (parameterized) constant expression
75+
/// in the resulting SQL query.
76+
/// </summary>
77+
public LinqExtensionPreEvaluation PreEvaluation { get; }
78+
79+
/// <summary>
80+
/// Default constructor.
81+
/// </summary>
82+
/// <param name="preEvaluation">Should the method call be pre-evaluated when not depending on queried data?</param>
83+
protected LinqExtensionMethodAttributeBase(LinqExtensionPreEvaluation preEvaluation)
84+
{
85+
PreEvaluation = preEvaluation;
86+
}
87+
}
88+
89+
/// <summary>
90+
/// Possible method call behaviors when the linq to NHibernate provider pre-evaluates
91+
/// expressions before translating them to SQL.
92+
/// </summary>
93+
public enum LinqExtensionPreEvaluation
94+
{
95+
/// <summary>
96+
/// The method call will not be evaluated even if its arguments do not depend on queried data.
97+
/// It will always be translated to the corresponding SQL statement.
98+
/// </summary>
99+
NoEvaluation,
100+
/// <summary>
101+
/// If the method call does not depend on queried data, the method call will be evaluated and replaced
102+
/// by the resulting (parameterized) constant expression in the resulting SQL query. A throwing
103+
/// method implementation will cause the query to throw.
104+
/// </summary>
105+
AllowPreEvaluation
17106
}
18107
}

src/NHibernate/Linq/LinqExtensionMethods.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ public static IQueryable<T> CacheRegion<T>(this IQueryable<T> query, string regi
121121
[Obsolete("Please use SetOptions instead.")]
122122
public static IQueryable<T> Timeout<T>(this IQueryable<T> query, int timeout)
123123
=> query.SetOptions(o => o.SetTimeout(timeout));
124+
125+
/// <summary>
126+
/// Allows to specify the parameter NHibernate type to use for a literal in a queryable expression.
127+
/// </summary>
128+
/// <typeparam name="T">The type of the literal.</typeparam>
129+
/// <param name="parameter">The literal value.</param>
130+
/// <param name="type">The NHibernate type, usually obtained from <c>NHibernateUtil</c> properties.</param>
131+
/// <returns>The literal value.</returns>
132+
[NoPreEvaluation]
124133
public static T MappedAs<T>(this T parameter, IType type)
125134
{
126135
throw new InvalidOperationException("The method should be used inside Linq to indicate a type of a parameter");

src/NHibernate/Linq/SqlMethods.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public static class SqlMethods
1111
/// will be translated.) This method can only be used in Linq2NHibernate expressions, and will throw
1212
/// if called directly.
1313
/// </summary>
14+
[NoPreEvaluation]
1415
public static bool Like(this string matchExpression, string sqlLikePattern)
1516
{
1617
throw new NotSupportedException(
@@ -24,6 +25,7 @@ public static bool Like(this string matchExpression, string sqlLikePattern)
2425
/// will be translated.) This method can only be used in Linq2NHibernate expressions, and will throw
2526
/// if called directly.
2627
/// </summary>
28+
[NoPreEvaluation]
2729
public static bool Like(this string matchExpression, string sqlLikePattern, char escapeCharacter)
2830
{
2931
throw new NotSupportedException(

src/NHibernate/Linq/Visitors/NhPartialEvaluatingExpressionVisitor.cs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System;
2+
using System.Linq;
13
using System.Linq.Expressions;
24
using Remotion.Linq.Clauses.Expressions;
35
using Remotion.Linq.Parsing;
@@ -21,17 +23,30 @@ protected override Expression VisitConstant(ConstantExpression expression)
2123

2224
public static Expression EvaluateIndependentSubtrees(Expression expression)
2325
{
24-
var evaluatedExpression = PartialEvaluatingExpressionVisitor.EvaluateIndependentSubtrees(expression, new NullEvaluatableExpressionFilter());
26+
var evaluatedExpression = PartialEvaluatingExpressionVisitor.EvaluateIndependentSubtrees(expression, new NhEvaluatableExpressionFilter());
2527
return new NhPartialEvaluatingExpressionVisitor().Visit(evaluatedExpression);
2628
}
2729

28-
public Expression VisitPartialEvaluationException(PartialEvaluationExceptionExpression expression)
30+
public Expression VisitPartialEvaluationException(PartialEvaluationExceptionExpression partialEvaluationExceptionExpression)
2931
{
30-
return Visit(expression.Reduce());
32+
throw new HibernateException(
33+
$"Evaluation failure on {partialEvaluationExceptionExpression.EvaluatedExpression}",
34+
partialEvaluationExceptionExpression.Exception);
3135
}
3236
}
3337

34-
internal class NullEvaluatableExpressionFilter : EvaluatableExpressionFilterBase
38+
internal class NhEvaluatableExpressionFilter : EvaluatableExpressionFilterBase
3539
{
40+
public override bool IsEvaluatableMethodCall(MethodCallExpression node)
41+
{
42+
if (node == null)
43+
throw new ArgumentNullException(nameof(node));
44+
45+
var attributes = node.Method
46+
.GetCustomAttributes(typeof(LinqExtensionMethodAttributeBase), false)
47+
.Cast<LinqExtensionMethodAttributeBase>().ToArray();
48+
return attributes.Length == 0 ||
49+
attributes.Any(a => a.PreEvaluation == LinqExtensionPreEvaluation.AllowPreEvaluation);
50+
}
3651
}
3752
}

0 commit comments

Comments
 (0)