Skip to content

Commit 09e31e8

Browse files
committed
Added paging extension
1 parent 66882f5 commit 09e31e8

File tree

3 files changed

+275
-0
lines changed

3 files changed

+275
-0
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using Eocron.Algorithms.Queryable.Paging;
5+
using FluentAssertions;
6+
using NUnit.Framework;
7+
8+
namespace Eocron.Algorithms.Tests
9+
{
10+
[TestFixture]
11+
public class PagingQueryableTests
12+
{
13+
private List<TestDbEntity> _items;
14+
15+
[SetUp]
16+
public void Setup()
17+
{
18+
var now = DateTime.UtcNow;
19+
_items = new List<TestDbEntity>()
20+
{
21+
new TestDbEntity(){Id = Guid.Parse("10000000-0000-0000-0000-000000000001"),Name = "Test1", Modified = now.AddDays(1)},
22+
new TestDbEntity(){Id = Guid.Parse("10000000-0000-0000-0000-000000000002"),Name = "Test2", Modified = now.AddDays(2)},
23+
new TestDbEntity(){Id = Guid.Parse("10000000-0000-0000-0000-000000000003"),Name = "Test3", Modified = now.AddDays(3)},
24+
new TestDbEntity(){Id = Guid.Parse("10000000-0000-0000-0000-000000000004"),Name = "Test4", Modified = now.AddDays(4)},
25+
new TestDbEntity(){Id = Guid.Parse("10000000-0000-0000-0000-000000000005"),Name = "Test5", Modified = now.AddDays(5)},
26+
new TestDbEntity(){Id = Guid.Parse("10000000-0000-0000-0000-000000000006"),Name = "Test6", Modified = now.AddDays(6)},
27+
};
28+
}
29+
30+
[Test]
31+
public void SanityCheck()
32+
{
33+
var queryable = _items.AsQueryable();
34+
35+
var cfg = new PagingConfiguration<TestDbEntity>();
36+
cfg.AddKeySelector(x=> x.Modified, isDescending: true);
37+
cfg.AddKeySelector(x=> x.Id);
38+
39+
var result = new List<TestDbEntity>();
40+
string ct = null;
41+
do
42+
{
43+
var tmpQuery = queryable.Continue(cfg, ct);
44+
var tmp = tmpQuery.Take(1).ToList().FirstOrDefault();
45+
if (tmp != null)
46+
{
47+
ct = cfg.GetContinuationToken(tmp);
48+
result.Add(tmp);
49+
}
50+
else
51+
{
52+
break;
53+
}
54+
} while (ct != null);
55+
56+
result.Should().Equal(_items.OrderByDescending(x=> x.Modified).ThenBy(x=> x.Id));
57+
}
58+
59+
public class TestDbEntity
60+
{
61+
public Guid Id { get; set; }
62+
63+
public string Name { get; set; }
64+
65+
public DateTime Modified { get; set; }
66+
}
67+
}
68+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Linq.Expressions;
5+
using Newtonsoft.Json;
6+
7+
namespace Eocron.Algorithms.Queryable.Paging
8+
{
9+
public sealed class PagingConfiguration<TEntity>
10+
{
11+
private readonly List<PagingKeyConfiguration> _keys = new();
12+
13+
public ParameterExpression Input = Expression.Parameter(typeof(TEntity), "x");
14+
15+
public IReadOnlyList<PagingKeyConfiguration> Keys => _keys;
16+
17+
private static readonly JsonSerializerSettings JsonSerializerSettings = new JsonSerializerSettings()
18+
{
19+
TypeNameHandling = TypeNameHandling.All
20+
};
21+
22+
public void AddKeySelector<TKey>(Expression<Func<TEntity, TKey>> keySelector, bool isDescending = false)
23+
{
24+
if(keySelector == null)
25+
throw new ArgumentNullException(nameof(keySelector));
26+
27+
var compiled = keySelector.Compile();
28+
keySelector = (Expression<Func<TEntity, TKey>>)new ReplaceParameterVisitor(keySelector.Parameters[0], Input).Visit(keySelector);
29+
_keys.Add(new PagingKeyConfiguration()
30+
{
31+
CompiledKeySelector = x => new TypeWrapper<TKey> { Value = compiled(x) },
32+
KeySelector = keySelector,
33+
IsDescending = isDescending
34+
});
35+
}
36+
37+
public string GetContinuationToken(TEntity entity)
38+
{
39+
if (entity == null)
40+
throw new ArgumentNullException(nameof(entity));
41+
if(_keys.Count == 0)
42+
throw new InvalidOperationException("No keys defined.");
43+
44+
return JsonConvert.SerializeObject(_keys.Select(x => x.CompiledKeySelector(entity)).ToList(),
45+
JsonSerializerSettings);
46+
}
47+
48+
public List<object> GetKeyValues(string continuationToken)
49+
{
50+
if (string.IsNullOrWhiteSpace(continuationToken))
51+
throw new ArgumentNullException(nameof(continuationToken));
52+
if(_keys.Count == 0)
53+
throw new InvalidOperationException("No keys defined.");
54+
55+
return JsonConvert.DeserializeObject<List<object>>(continuationToken, JsonSerializerSettings)
56+
.Cast<ITypeWrapper>()
57+
.Select(x => x.GetValue())
58+
.ToList();
59+
}
60+
61+
private interface ITypeWrapper
62+
{
63+
object GetValue();
64+
}
65+
66+
public class PagingKeyConfiguration
67+
{
68+
public Func<TEntity, object> CompiledKeySelector { get; init; }
69+
70+
public LambdaExpression KeySelector { get; init; }
71+
72+
public bool IsDescending { get; init; }
73+
}
74+
75+
private sealed class TypeWrapper<TValue> : ITypeWrapper
76+
{
77+
public TValue Value { get; init; }
78+
public object GetValue()
79+
{
80+
return Value;
81+
}
82+
}
83+
84+
private sealed class ReplaceParameterVisitor : ExpressionVisitor
85+
{
86+
private readonly ParameterExpression _oldParam;
87+
private readonly ParameterExpression _newParam;
88+
89+
public ReplaceParameterVisitor(ParameterExpression oldParam, ParameterExpression newParam)
90+
{
91+
_oldParam = oldParam;
92+
_newParam = newParam;
93+
}
94+
95+
protected override Expression VisitParameter(ParameterExpression node)
96+
{
97+
return node == _oldParam ? _newParam : base.VisitParameter(node);
98+
}
99+
}
100+
}
101+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using System;
2+
using System.Linq;
3+
using System.Linq.Expressions;
4+
5+
namespace Eocron.Algorithms.Queryable.Paging
6+
{
7+
/// <summary>
8+
/// Provides extension to efficiently page through any queryable, without full scan
9+
/// </summary>
10+
public static class PagingQueryableExtensions
11+
{
12+
/// <summary>
13+
/// Apply WHERE condition to IQueryable if continuation token is not empty.
14+
/// It will not apply Skip/Take operations, you should do it yourself.
15+
/// </summary>
16+
/// <param name="source"></param>
17+
/// <param name="configuration"></param>
18+
/// <param name="continuationToken"></param>
19+
/// <typeparam name="TEntity"></typeparam>
20+
/// <returns></returns>
21+
public static IQueryable<TEntity> Continue<TEntity>(
22+
this IQueryable<TEntity> source,
23+
PagingConfiguration<TEntity> configuration,
24+
string continuationToken) where TEntity : class
25+
{
26+
if(source == null)
27+
throw new ArgumentNullException(nameof(source));
28+
if (configuration == null)
29+
throw new ArgumentNullException(nameof(configuration));
30+
31+
var result = source;
32+
result = ApplyOrdering(result, configuration);
33+
if (string.IsNullOrWhiteSpace(continuationToken))
34+
return result;
35+
result = result.Where(CreateSkipCondition(configuration, continuationToken));
36+
return result;
37+
}
38+
39+
private static Expression<Func<TEntity, bool>> CreateSkipCondition<TEntity>(
40+
PagingConfiguration<TEntity> configuration,
41+
string continuationToken)
42+
{
43+
var keyValues = configuration.GetKeyValues(continuationToken).Select(Expression.Constant).ToList();
44+
Expression predicate = null;
45+
46+
for (var i = configuration.Keys.Count - 1; i >= 0; i--)
47+
{
48+
var keyCfg = configuration.Keys[i];
49+
var keyValue = keyValues[i];
50+
51+
var comparison = keyCfg.IsDescending
52+
? Expression.LessThan(keyCfg.KeySelector.Body, keyValue)
53+
: Expression.GreaterThan(keyCfg.KeySelector.Body, keyValue);
54+
55+
for (var j = 0; j < i; j++)
56+
{
57+
var prevKeyCfg = configuration.Keys[j];
58+
var prevKeyValue = keyValues[j];
59+
var prevEqual = Expression.Equal(prevKeyCfg.KeySelector.Body, prevKeyValue);
60+
comparison = Expression.AndAlso(prevEqual, comparison);
61+
}
62+
63+
predicate = predicate == null ? comparison : Expression.OrElse(predicate, comparison);
64+
}
65+
66+
var lambda = Expression.Lambda<Func<TEntity, bool>>(predicate!, configuration.Input);
67+
return lambda;
68+
}
69+
70+
private static IQueryable<TEntity> ApplyOrdering<TEntity>(IQueryable<TEntity> queryable,
71+
PagingConfiguration<TEntity> configuration)
72+
{
73+
for (int i = 0; i < configuration.Keys.Count; i++)
74+
{
75+
var keyCfg = configuration.Keys[i];
76+
queryable = ApplyOrdering(queryable, keyCfg.KeySelector, keyCfg.IsDescending, isFirst: i == 0);
77+
}
78+
79+
return queryable;
80+
}
81+
82+
private static IOrderedQueryable<TEntity> ApplyOrdering<TEntity>(
83+
IQueryable<TEntity> source,
84+
LambdaExpression keySelector,
85+
bool isDescending,
86+
bool isFirst)
87+
{
88+
var methodName = (isFirst, isDescending) switch
89+
{
90+
(true, false) => nameof(System.Linq.Queryable.OrderBy),
91+
(true, true) => nameof(System.Linq.Queryable.OrderByDescending),
92+
(false, false) => nameof(System.Linq.Queryable.ThenBy),
93+
(false, true) => nameof(System.Linq.Queryable.ThenByDescending),
94+
};
95+
96+
var method = typeof(System.Linq.Queryable)
97+
.GetMethods()
98+
.First(m =>
99+
m.Name == methodName &&
100+
m.GetParameters().Length == 2)
101+
.MakeGenericMethod(typeof(TEntity), keySelector.Body.Type);
102+
103+
return (IOrderedQueryable<TEntity>)method.Invoke(null, [source, keySelector])!;
104+
}
105+
}
106+
}

0 commit comments

Comments
 (0)