Skip to content

Commit c9302b8

Browse files
Merge pull request #39 from matteobortolazzo/MoreLINQNativeSupport
Composite calls
2 parents 8b8b511 + 20784b9 commit c9302b8

8 files changed

+262
-53
lines changed

README.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,30 @@ If the Where method is not called in the expression, it will at an empty selecto
179179
| execution_stats | IncludeExecutionStats() |
180180
| conflicts | IncludeConflicts() |
181181

182-
### Other IQueryable methods?
182+
### Composite methods
183+
184+
Some methods that are not directly supported by CouchDB are converted to a composition of supported ones.
185+
186+
| Input | Output |
187+
|:----------------------------------|:--------------------------------------|
188+
| Min(r => r.Age) | OrderBy(r => r.Age).Take(1) |
189+
| Max(r => r.Age) | OrderByDescending(r => r.Age).Take(1) |
190+
| Single() | Take(1) |
191+
| SingleOrDefault() | Take(1) |
192+
| Single(r => r.Age == 19) | Where(r => r.Age == 19).Take(1) |
193+
| SingleOrDefault(r => r.Age == 19) | Where(r => r.Age == 19).Take(1) |
194+
195+
**WARN**: Do not call a method twice, for example: `Where(func).Single(func)` won't work.
196+
197+
**WARN**: Since Max and Min use **sort**, an *index* must be created.
198+
199+
200+
### All other IQueryables
183201

184202
IQueryable methods that are not natively supported by CouchDB are evaluated in-memory using the IEnumerable counterpart, if possible.
185-
**WARN** Max and Min are not working now because the mapping from IQueryable to IEnumerable is not 1 to 1.
203+
204+
For example: `All` `Any` `Avg` `Count` `DefaultIfEmpty` `ElementAt` `ElementAtOrDefault` `GroupBy` `Last` `Reverse` `SelectMany` `Sum`
205+
186206

187207
## Client operations
188208

src/CouchDB.Driver/CouchQueryProvider.cs

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
using CouchDB.Driver.DTOs;
2-
using CouchDB.Driver.ExpressionVisitors;
2+
using CouchDB.Driver.CompositeExpressionsEvaluator;
33
using CouchDB.Driver.Helpers;
44
using CouchDB.Driver.Settings;
55
using CouchDB.Driver.Types;
@@ -29,7 +29,7 @@ public CouchQueryProvider(IFlurlClient flurlClient, CouchSettings settings, stri
2929

3030
public override string GetQueryText(Expression expression)
3131
{
32-
return Translate(expression);
32+
return Translate(ref expression);
3333
}
3434

3535
public override object Execute(Expression expression, bool completeResponse)
@@ -38,7 +38,7 @@ public override object Execute(Expression expression, bool completeResponse)
3838
var unsupportedMethodCallExpressions = new List<MethodCallExpression>();
3939
expression = RemoveUnsupportedMethodExpressions(expression, out var hasUnsupportedMethods, unsupportedMethodCallExpressions);
4040

41-
var body = Translate(expression);
41+
var body = Translate(ref expression);
4242
Type elementType = TypeSystem.GetElementType(expression.Type);
4343

4444
// Create generic GetCouchList method and invoke it, sending the request to CouchDB
@@ -60,12 +60,15 @@ public override object Execute(Expression expression, bool completeResponse)
6060
return result;
6161
}
6262

63-
private string Translate(Expression e)
63+
private string Translate(ref Expression e)
6464
{
65-
e = Evaluator.PartialEval(e);
66-
var whereVisitor = new WhereExpressionVisitor();
65+
e = Local.PartialEval(e);
66+
var whereVisitor = new BoolMemberToConstantEvaluator();
6767
e = whereVisitor.Visit(e);
6868

69+
var pretranslator = new QueryPretranslator();
70+
e = pretranslator.Visit(e);
71+
6972
return new QueryTranslator(_settings).Translate(e);
7073
}
7174

@@ -104,6 +107,12 @@ bool IsUnsupportedMethodCallExpression(Expression ex)
104107
unsupportedMethodCallExpressions.Add(m);
105108
return isUnsupported;
106109
}
110+
// If the next call is supported and the current is in the composite list
111+
if (QueryTranslator.CompositeQueryableMethods.Contains(m.Method.Name))
112+
{
113+
unsupportedMethodCallExpressions.Add(m);
114+
return true;
115+
}
107116
// If the next call is supported and the current is not in the supported list
108117
if (!QueryTranslator.NativeQueryableMethods.Contains(m.Method.Name))
109118
{
@@ -123,13 +132,25 @@ private object InvokeUnsupportedMethodCallExpression(object result, MethodCallEx
123132
{
124133
MethodInfo queryableMethodInfo = methodCallExpression.Method;
125134
Expression[] queryableMethodArguments = methodCallExpression.Arguments.ToArray();
126-
// Find the equivalent method in Enumerable
127-
MethodInfo enumarableMethodInfo = typeof(Enumerable).GetMethods().Single(enumerableMethodInfo =>
135+
136+
// Since Max and Min are not map 1 to 1 from Queryable to Enumerable
137+
// they need to be handled differently
138+
MethodInfo FindEnumerableMethod()
128139
{
129-
return
130-
queryableMethodInfo.Name == enumerableMethodInfo.Name &&
131-
ReflectionComparator.IsCompatible(queryableMethodInfo, enumerableMethodInfo);
132-
});
140+
if (queryableMethodInfo.Name == nameof(Queryable.Max) || queryableMethodInfo.Name == nameof(Queryable.Min))
141+
{
142+
return FindEnumerableMinMax(queryableMethodInfo);
143+
}
144+
return typeof(Enumerable).GetMethods().Single(enumerableMethodInfo =>
145+
{
146+
return
147+
queryableMethodInfo.Name == enumerableMethodInfo.Name &&
148+
ReflectionComparator.IsCompatible(queryableMethodInfo, enumerableMethodInfo);
149+
});
150+
}
151+
152+
// Find the equivalent method in Enumerable
153+
MethodInfo enumarableMethodInfo = FindEnumerableMethod();
133154

134155
// Add the list as first parameter of the call
135156
var invokeParameter = new List<object> { result };
@@ -158,5 +179,19 @@ private object GetArgumentValueFromExpression(Expression e)
158179
}
159180
throw new NotImplementedException($"Expression of type {e.NodeType} not supported.");
160181
}
182+
183+
private static MethodInfo FindEnumerableMinMax(MethodInfo queryableMethodInfo)
184+
{
185+
Type[] genericParams = queryableMethodInfo.GetGenericArguments();
186+
MethodInfo finalMethodInfo = typeof(Enumerable).GetMethods().Single(enumerableMethodInfo =>
187+
{
188+
Type[] enumerableArguments = enumerableMethodInfo.GetGenericArguments();
189+
return
190+
enumerableMethodInfo.Name == queryableMethodInfo.Name &&
191+
enumerableArguments.Length == genericParams.Length - 1 &&
192+
enumerableMethodInfo.ReturnType == genericParams[1];
193+
});
194+
return finalMethodInfo;
195+
}
161196
}
162197
}

src/CouchDB.Driver/ExpressionVisitors/WhereExpressionVisitor.cs renamed to src/CouchDB.Driver/ExpressionVisitors/BoolMemberToConstantEvaluator.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
using System.Linq.Expressions;
33
using System.Reflection;
44

5-
namespace CouchDB.Driver.ExpressionVisitors
5+
namespace CouchDB.Driver.CompositeExpressionsEvaluator
66
{
7-
internal class WhereExpressionVisitor : ExpressionVisitor
7+
internal class BoolMemberToConstantEvaluator : ExpressionVisitor
88
{
99
private bool _visitingWhereMethod;
1010

1111
protected override Expression VisitMethodCall(MethodCallExpression m)
1212
{
13-
_visitingWhereMethod = m.Method.Name == "Where" && m.Method.DeclaringType == typeof(Queryable);
13+
_visitingWhereMethod = m.Method.Name == nameof(Queryable.Where) && m.Method.DeclaringType == typeof(Queryable);
1414
if (_visitingWhereMethod)
1515
{
1616
Expression result = base.VisitMethodCall(m);
@@ -22,7 +22,7 @@ protected override Expression VisitMethodCall(MethodCallExpression m)
2222

2323
protected override Expression VisitBinary(BinaryExpression expression)
2424
{
25-
if (expression.Right is ConstantExpression c && c.Type == typeof(bool) &&
25+
if (_visitingWhereMethod && expression.Right is ConstantExpression c && c.Type == typeof(bool) &&
2626
(expression.NodeType == ExpressionType.Equal || expression.NodeType == ExpressionType.NotEqual))
2727
{
2828
return expression;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using System;
2+
using System.Linq;
3+
using System.Linq.Expressions;
4+
5+
namespace CouchDB.Driver.CompositeExpressionsEvaluator
6+
{
7+
public class QueryPretranslator : ExpressionVisitor
8+
{
9+
protected override Expression VisitMethodCall(MethodCallExpression node)
10+
{
11+
Type[] genericArgs = node.Method.GetGenericArguments();
12+
var numberOfParameters = node.Method.GetParameters().Length;
13+
14+
// Return an expression representing Queryable<T>.Take(1)
15+
MethodCallExpression GetTakeOneExpression(Expression previousExpression)
16+
{
17+
return Expression.Call(typeof(Queryable), nameof(Queryable.Take), genericArgs.Take(1).ToArray(), previousExpression, Expression.Constant(1));
18+
}
19+
20+
// Min(e => e.P) == OrderBy(e => e.P).Take(1) + Min
21+
if (node.Method.Name == nameof(Queryable.Min) && numberOfParameters == 2)
22+
{
23+
MethodCallExpression orderByDesc = Expression.Call(typeof(Queryable), nameof(Queryable.OrderBy), genericArgs, node.Arguments[0], node.Arguments[1]);
24+
return GetTakeOneExpression(orderByDesc);
25+
}
26+
// Max(e => e.P) == OrderByDescending(e => e.P).Take(1) + Max
27+
if (node.Method.Name == nameof(Queryable.Max) && numberOfParameters == 2)
28+
{
29+
MethodCallExpression orderBy = Expression.Call(typeof(Queryable), nameof(Queryable.OrderByDescending), genericArgs, node.Arguments[0], node.Arguments[1]);
30+
return GetTakeOneExpression(orderBy);
31+
}
32+
// Single and SingleOrDefault have the same behaviour
33+
if (node.Method.Name == nameof(Queryable.First) || node.Method.Name == nameof(Queryable.FirstOrDefault))
34+
{
35+
// First() == Take(1) + First
36+
if (numberOfParameters == 1)
37+
{
38+
return GetTakeOneExpression(node.Arguments[0]);
39+
}
40+
// First(e => e.P) == Where(e => e.P).Take(1) + First
41+
else if (numberOfParameters == 2)
42+
{
43+
MethodCallExpression whereExpression = Expression.Call(typeof(Queryable), nameof(Queryable.Where), genericArgs, node.Arguments[0], node.Arguments[1]);
44+
return GetTakeOneExpression(whereExpression);
45+
}
46+
}
47+
return base.VisitMethodCall(node);
48+
}
49+
}
50+
}

src/CouchDB.Driver/Helpers/Evaluator.cs renamed to src/CouchDB.Driver/ExpressionVisitors/LocalExpressionEvaluator.cs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
using System.Linq.Expressions;
66

77
#pragma warning disable IDE0058 // Expression value is never used
8-
namespace CouchDB.Driver.Helpers
8+
namespace CouchDB.Driver.CompositeExpressionsEvaluator
99
{
10-
internal static class Evaluator
10+
internal static class Local
1111
{
1212
/// <summary>
1313
/// Performs evaluation & replacement of independent sub-trees
@@ -27,17 +27,15 @@ public static Expression PartialEval(Expression expression, Func<Expression, boo
2727
/// /// <returns>A new tree with sub-trees evaluated and replaced.</returns>
2828
public static Expression PartialEval(Expression expression)
2929
{
30-
return PartialEval(expression, Evaluator.CanBeEvaluatedLocally);
30+
return PartialEval(expression, Local.CanBeEvaluatedLocally);
3131
}
3232

3333
private static bool CanBeEvaluatedLocally(Expression expression)
3434
{
3535
if (expression is MethodCallExpression c)
3636
{
37-
return
38-
c.Method.Name != nameof(Queryable.Where) &&
39-
c.Method.Name != nameof(Queryable.Skip) &&
40-
c.Method.Name != nameof(Queryable.Take) &&
37+
return !QueryTranslator.CompositeQueryableMethods.Contains(c.Method.Name) &&
38+
!QueryTranslator.NativeQueryableMethods.Contains(c.Method.Name) &&
4139
c.Method.Name != nameof(QueryableExtensions.WithReadQuorum) &&
4240
c.Method.Name != nameof(QueryableExtensions.WithoutIndexUpdate) &&
4341
c.Method.Name != nameof(QueryableExtensions.UseBookmark) &&

src/CouchDB.Driver/Translators/MethodCallExpressionTranslator.cs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,22 @@ internal partial class QueryTranslator
1212
{
1313
internal static List<string> NativeQueryableMethods { get; } = new List<string>
1414
{
15-
"Where",
16-
"OrderBy", "ThenByWhere",
17-
"OrderByDescending", "ThenByDescending",
18-
"Skip",
19-
"Take",
20-
"Select"
15+
nameof(Queryable.Where),
16+
nameof(Queryable.OrderBy),
17+
nameof(Queryable.ThenBy),
18+
nameof(Queryable.OrderByDescending),
19+
nameof(Queryable.ThenByDescending),
20+
nameof(Queryable.Skip),
21+
nameof(Queryable.Take),
22+
nameof(Queryable.Select)
23+
};
24+
25+
internal static List<string> CompositeQueryableMethods { get; } = new List<string>
26+
{
27+
nameof(Queryable.Max),
28+
nameof(Queryable.Min),
29+
nameof(Queryable.First),
30+
nameof(Queryable.FirstOrDefault)
2131
};
2232

2333
private static Expression StripQuotes(Expression e)
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
using CouchDB.Driver.UnitTests.Models;
7+
using Flurl.Http.Testing;
8+
using Xunit;
9+
10+
namespace CouchDB.Driver.UnitTests
11+
{
12+
public class SupportByCombination_Tests
13+
{
14+
private readonly CouchDatabase<Rebel> _rebels;
15+
private readonly Rebel _mainRebel;
16+
private readonly List<Rebel> _rebelsList;
17+
private object _response;
18+
19+
public SupportByCombination_Tests()
20+
{
21+
var client = new CouchClient("http://localhost");
22+
_rebels = client.GetDatabase<Rebel>();
23+
_mainRebel = new Rebel
24+
{
25+
Id = Guid.NewGuid().ToString(),
26+
Name = "Luke",
27+
Age = 19,
28+
Skills = new List<string> { "Force" }
29+
};
30+
_rebelsList = new List<Rebel>
31+
{
32+
_mainRebel
33+
};
34+
_response = new
35+
{
36+
Docs = _rebelsList
37+
};
38+
}
39+
40+
[Fact]
41+
public async Task Max()
42+
{
43+
using (var httpTest = new HttpTest())
44+
{
45+
httpTest.RespondWithJson(_response);
46+
var result = _rebels.AsQueryable().Max(r => r.Age);
47+
Assert.Equal(_mainRebel.Age, result);
48+
}
49+
}
50+
51+
[Fact]
52+
public async Task Min()
53+
{
54+
using (var httpTest = new HttpTest())
55+
{
56+
httpTest.RespondWithJson(_response);
57+
var result = _rebels.AsQueryable().Min(r => r.Age);
58+
Assert.Equal(_mainRebel.Age, result);
59+
}
60+
}
61+
62+
[Fact]
63+
public async Task First()
64+
{
65+
using (var httpTest = new HttpTest())
66+
{
67+
httpTest.RespondWithJson(_response);
68+
var result = _rebels.AsQueryable().First();
69+
Assert.Equal(_mainRebel.Age, result.Age);
70+
}
71+
}
72+
73+
[Fact]
74+
public async Task First_Expr()
75+
{
76+
using (var httpTest = new HttpTest())
77+
{
78+
httpTest.RespondWithJson(_response);
79+
var result = _rebels.AsQueryable().First(r => r.Age == 19);
80+
Assert.Equal(_mainRebel.Age, result.Age);
81+
}
82+
}
83+
84+
[Fact]
85+
public async Task FirstOrDefault()
86+
{
87+
using (var httpTest = new HttpTest())
88+
{
89+
httpTest.RespondWithJson(new { Docs = Array.Empty<Rebel>() });
90+
var result = _rebels.AsQueryable().FirstOrDefault();
91+
Assert.Null(result);
92+
}
93+
}
94+
95+
[Fact]
96+
public async Task FirstOrDefault_Expr()
97+
{
98+
using (var httpTest = new HttpTest())
99+
{
100+
httpTest.RespondWithJson(new { Docs = Array.Empty<Rebel>() });
101+
var result = _rebels.AsQueryable().FirstOrDefault(r => r.Age == 20);
102+
Assert.Null(result);
103+
}
104+
}
105+
}
106+
}

0 commit comments

Comments
 (0)