Giter Site home page Giter Site logo

mrahhal / mr.entityframeworkcore.keysetpagination Goto Github PK

View Code? Open in Web Editor NEW
204.0 7.0 11.0 1.46 MB

Keyset/Seek/Cursor pagination for Entity Framework Core.

License: MIT License

PowerShell 5.20% C# 83.55% R 11.25%
pagination dotnet cursor-pagination seek-pagination keyset-pagination roslyn-analyzer

mr.entityframeworkcore.keysetpagination's Introduction

MR.EntityFrameworkCore.KeysetPagination

100% offset-free

CI NuGet version License

Keyset pagination for EF Core (Entity Framework Core). Also known as seek pagination or cursor pagination.

Learn about why the standard offset based pagination (Take().Skip()) is bad in many common cases here.

Check the benchmarks section below for a quick look at the different performance characteristics between offset and keyset.

Note If you're using ASP.NET Core, you can use MR.AspNetCore.Pagination which wraps this package and offers an easier to consume keyset pagination behavior with additional features for ASP.NET Core. This is a lower level library that implements keyset pagination for EF Core.

Usage

KeysetPaginate is an extension method on IQueryable<T> (same as all other queryable Linq methods), and it takes a few arguments:

KeysetPaginate(
    // This configures the keyset columns and their order.
    b => b.Ascending(entity => entity.Id),
    // The direction we want to walk relative to the order above (Forward/Backward). Default is Forward.
    direction,
    // The reference object (used to query previous/next pages). Default is null.
    reference
)

Using this method we can do all kinds of keyset queries: first page, previous page, next page, last page.

These queries usually follow the same patterns, shown in the "Common patterns" section. Practical code examples are shown in the "Getting the data" section.

But first, let's talk a bit more about KeysetPaginate and how it works.

Here's a small visual representation:

The columns and their configured order are used to order the data, and then the direction decides if we're getting the data before or after the reference row.

Warning You'll want to reverse the result whenever you use KeysetPaginationDirection.Backward to get the proper order of the data, since walking Backward gives results in the opposite order to the configured columns order. There's a helper method on KeysetContext for this, shown in a snippet later.

KeysetPaginate returns a context object which you can use to get secondary info and get the data result.

It can be called without direction and reference, in which case this is equivalent to querying the first page:

KeysetPaginate(
    b => b.Ascending(entity => entity.Id)
)

Configuring a composite keyset is easy as well. Just add all the columns you want:

KeysetPaginate(
    b => b.Ascending(entity => entity.Id).Ascending(entity => entity.Score),
    ...
)

You can also mix ASC/DESC columns. KeysetPaginate knows how to handle that:

KeysetPaginate(
    b => b.Ascending(entity => entity.Id).Descending(entity => entity.Score),
    ...
)

Important Make sure to read the "Deterministic keysets" and "Indexing" sections for important notes about configuring keysets.

Common patterns

Here are the 4 most common patterns of using KeysetPaginate.

First page

Not specifying direction and reference gives you the first page of data.

KeysetPaginate(
    b => ...
)

This is equivalent to the following:

KeysetPaginate(
    b => ...,
    KeysetPaginationDirection.Forward,
    null
)

Last page

We get the last page by specifying a Backward direction.

KeysetPaginate(
    b => ...,
    KeysetPaginationDirection.Backward
)

Previous page

You get previous/next pages by providing a direction and a reference. In this case, the reference should be the first item of the current page, and the direction is Backward:

KeysetPaginate(
    b => ...,
    KeysetPaginationDirection.Backward,
    reference
)

Next page

You get previous/next pages by providing a direction and a reference. In this case, the reference should be the last item of the current page, and the direction is Forward:

KeysetPaginate(
    b => ...,
    KeysetPaginationDirection.Forward,
    reference
)

Prebuilt keyset query definition

Although all the examples here build the keyset directly inside the KeysetPaginate call for brevity, the recommended way of doing this is to prebuild the keyset query definition. Prebuilding will allow reusing of internal caches, leading to more performance and less allocations.

To prebuild, all you need to do is move the keyset building code out of the KeysetPaginate call and into a long lived instance (such as a static field).

// In the ctor or someplace similar, set this to a static field for example.
_usersKeysetQuery = KeysetQuery.Build<User>(b => b.Ascending(x => x.Id));

// Then when calling KeysetPaginate, we use the prebuilt definition.
dbContext.Users.KeysetPaginate(
    _usersQueryKeyset,
    ...);

Getting the data

Let's now see how to work with the context object that KeysetPaginate returns.

The following is a basic example usage. We're querying the data and getting back 20 items:

var keysetContext = dbContext.Users.KeysetPaginate(...);

var users = await keysetContext
    .Query
    .Take(20)
    .ToListAsync();

// As noted in several places above, don't forget to ensure the data is correctly ordered:
keysetContext.EnsureCorrectOrder(users);

KeysetPaginate returns a context object that includes a Query property. This Query is what you'll chain more linq operators to and then use to get your data.

The context object itself can be further reused by other helper methods in this package such as HasPreviousAsync/HasNextAsync to get more info.

As a shortcut for when you don't need this context object, there's a KeysetPaginateQuery method:

var users = await dbContext.Users
    .KeysetPaginateQuery(...)
    .Take(20)
    .ToListAsync();

Using the context object with helper methods:

// Store it in a variable because we'll be using it in more than one way.
var keysetContext = dbContext.Users
    .KeysetPaginate(...);

// First, we'll get our actual data. We do this by using the `Query` property.
var users = await keysetContext.Query
    .Take(20)
    .ToListAsync();
// Make sure you call EnsureCorrectOrder before anything else.
keysetContext.EnsureCorrectOrder(users);

// This is true when there is more data before the returned list.
var hasPrevious = await keysetContext.HasPreviousAsync(users);

// This is true when there is more data after the returned list.
var hasNext = await keysetContext.HasNextAsync(users);

HasPreviousAsync/HasNextAsync are useful when you want to know when to render Previous/Next (Older/Newer) buttons.

Note The reference/data these methods accept are loosely typed to allow flexibility when projecting your models (to DTOs for example). For more info check this document.

Here's another example showing how to obtain the total count for the data to display somewhere:

// Assuming we're in an api that should return admin users.

// Prepare the base query first.
var query = dbContext.Users.Where(x => x.IsAdmin);

// This will be the count of all admins.
var count = await query.CountAsync();

// And then we apply keyset pagination at the end.
// `KeysetPaginate` adds ordering and more predicates to the query so we have to get the count before we apply it.
var keysetContext = query.KeysetPaginate(...);
var admins = await keysetContext.Query
    .Take(20)
    .ToListAsync();

// You can optionally use the context object too as explained above to get additional info.
keysetContext.EnsureCorrectOrder(admins);

Nested properties

Nested properties are also supported when defining a keyset. Just make sure the reference contains the same nested chain of properties.

// If you're using a loaded entity for the reference.
var reference = await dbContext.Users
    // Load it, otherwise you won't get the correct result.
    .Include(x => x.Nested)
    .FirstOrDefaultAsync(x => x.Id == id);

// If you're using another type for the reference.
var reference = new
{
    Nested = new
    {
        Created = ...,
    },
};

var keysetContext = dbContext.Users.KeysetPaginate(
    // Defining the keyset using a nested property.
    b => b.Ascending(entity => entity.Nested.Created),
    direction,
    reference);
var result = await keysetContext.Query
    // You'll want to load it here too if you plan on calling any context methods.
    .Include(x => x.Nested)
    .Take(20)
    .ToListAsync();

Deterministic keysets

A deterministic keyset is a keyset that can uniquely identify entities. This is an important concept to understand, so let's start by looking at an example.

b.Ascending(x => x.Created)

The keyset above consists of only one column that accesses Created. If by design multiple entities might have the same Created, then this is not a deterministic keyset.

There are a few problems with a non deterministic keyset. Most importantly, you'll be skipping over data when paginating. This is a side effect of how keyset pagination works.

Fixing this is easy enough. In most cases, you can just add more columns until it becomes deterministic. Most commonly, you can add a column that accesses Id.

b.Ascending(x => x.Created).Ascending(x => x.Id)

This makes the keyset deterministic because the combination of these particular columns will always resolve to uniquely identified entities.

If you can maintain this rule, and if your keyset's data doesn't change, you'll never skip over or duplicate data, a behavior that offset based pagination can never guarantee. We call this behavior stable pagination.

Keep in mind that to get the most performance out of this we should have proper indexing that takes into account this composite keyset. This is discussed in the next section.

Indexing

Keyset pagination โ€” as is the case with any other kind of database query โ€” can benefit a lot from good database indexing. Said in other words, not having a proper index defeats the purpose of using keyset pagination in the first place.

You'll want to add a composite index that is compatible with the columns and the order of your keyset.

Here's an example. Let's say we're doing the following:

KeysetPaginate(
    b => b.Descending(entity => entity.Created),
    ...
)

We should add an index on the Created column for this query to be as fast as it can.

Another more complex example:

KeysetPaginate(
    b => b.Descending(entity => entity.Score).Ascending(entity => entity.Id),
    ...
)

In this case you'll want to create a composite index on Score + Id, but make sure they're compatible with the order above. i.e You should make the index descending on Score and ascending on Id (or the opposite) for it to be effective.

Note Refer to this document on how to create indexes with EF Core. Note that support for specifying sort order in a composite index was introduced in EF Core 7.0.

Benchmarks

To give you an idea of the performance gains, here's a graph comparing using offset pagination vs keyset pagination when querying first, middle, and last pages under different table records counts.

The following are the different methods being benchmarked:

  • FirstPage: Query the first page
  • MidPage: Query the middle page (i.e for N=1K this benchmark queries the data starting from the 500's record)
  • LastPage: Query the last page

For a common use case, this is when the data is ordered in Created descending (a DateTime property).

Notice that when querying the first page, offset pagination does just as well as keyset. Offset pagination starts falling behind remarkably the further away the page you want to read is. Do consider this when choosing what method you want to use.

To that point, the keyset bars (green) are barely visible in the MidPage and LastPage graphs. This shows a major advantage of keyset pagination over offset pagination, that is the stable performance characteristic over large amounts of data even when querying further away pages.

Check the benchmarks folder for the source code.

Check this blog post for a more detailed look into the benchmarks.

Caveats

Check this document on a few caveats to keep in mind when working with keyset pagination.

Samples

Check the samples folder for project samples.

  • Basic: This is a quick example of a page that has First/Previous/Next/Last links (using razor pages).

Talks

.NET Standup session where we discuss pagination and showcase this package.

.NET Standup session

mr.entityframeworkcore.keysetpagination's People

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar

mr.entityframeworkcore.keysetpagination's Issues

Support JsonElement property access for Postgres

As a developer I want to be able to use a Json property in the keyset.

public class NestedJsonModel
{
    public int Id { get; set; }
    public NestedInnerJsonModel Inner { get; set; }
}

public class NestedInnerJsonModel
{
    public int Id { get; set; }
    public DateTime Created { get; set; }
    public JsonDocument Data { get; set; }
}

var context = query..KeysetPaginateQuery(
			b => b.Ascending(x => x.Inner.Data.RootElement.GetProperty("nbString").GetString()),
			KeysetPaginationDirection.Forward);

Support ternary expression in sorting or better error message

Hi,
I tried something similar to this

queryable.KeysetPaginate(e => e.Ascending(e => e.Which == "A" ? e.A : e.B));

But it doesn't work due to A limitation in the code.
I'm trying to find my way in there and maybe will create a pull request with an implementation.

But at least it could be better to have a specific error message here
https://github.com/mrahhal/MR.EntityFrameworkCore.KeysetPagination/blob/main/src/MR.EntityFrameworkCore.KeysetPagination/KeysetPaginationBuilder.cs#L36
if you get to the else without a MemberExpression you could get in this state :

var properties = ExpressionHelper.GetNestedPropertiesFromMemberAccess(unwrapped); // -> empty list
_columns.Add(new KeysetColumnNested<T, TProp>(
  properties,
  isDescending));

// Then in the KeysetColumnNested contructor

Properties = properties;
Property = properties[^1]; // Error with an empty list

Performing subtraction in a query breaks sorting

We're using subtraction of dates or EF.Functions.DateDiffHour() in a query we're trying to keyset paginate.

Unfortunately, this is resulting in a LINQ error that the query can no longer be compiled.

Is there a limitation that would prevent sorting on computed columns?

Unable to get the Last page working

Hi,

I'm trying to get the "Last page" logic working. But it keeps returning the same first page rows. I following of logic mentioned in the documentation to use "backward"

searchResultsQuery.KeysetPaginate(_keysetBuilderAction, KeysetPaginationDirection.Backward);

but it keeps returning the same first page results. My query uses multiple joins across multiple tables using EF Core lambda. As EF Core doesnt support DistinctBy so I'm using .GroupBy(z=>z.Id).Select(g=>g.First()) for getting the Distinct rows in my query, is this causing an issue finding the last page ?

Nulls in the keyset

I'm having an issue where whenever I try to paginate after a specific record, my result set comes back empty.

        var reference = after.HasValue
            ? await query.Where((e) => e.Id == after.Value).FirstOrDefaultAsync()
            : null;

        var pagination = query
            .OrderBy((e) => e.ModifiedUtc)
            .ThenBy((e) => e.Name)
            .KeysetPaginate(
                (builder) => builder
                    .Ascending((e) => e.ModifiedUtc)
                    .Ascending((e) => e.Name),
                KeysetPaginationDirection.Forward,
                reference
            );

        var result = pagination.Query
                .Select((e) => new Dto
                {
                    Id = e.GlobalId,
                    Name = e.Name,
                    LastModified = e.ModifiedUtc,
                })
                .Take(Math.Clamp(size, size, 100))

The only thing noteworthy about the test data I'm using is that they all have the same ModifiedUtc value. Though their names are different.

Is there any good way to troubleshoot whatever is going on here?

Thank you for this really cool library, looking forward to mastering using it!

Ordering by a computed column

Hi,
Found my way here via dotnet/efcore#20967 (comment) and frankly your project is brilliant and has solved our pagination woes in a repeatable and consistent way.

I have one corner case that I was hoping to discuss how it might be solved (I can attempt a PR for it if necessary). We would like to be able to order by an EF Core 'computed column'. Something like:

	[NotMapped]
	public short status_order { get; set; }
			modelBuilder.Entity<OurTable>()
				.Property(x => x.status_order)
				.HasComputedColumnSql(@"
					CASE status
						WHEN 0 THEN 0
						WHEN 3 THEN 1
						WHEN 2 THEN 2
						WHEN 1 THEN 3
						WHEN 99 THEN 99
						ELSE -1
					END
				");
			var keysetContext = ourTable
				.KeysetPaginate(
					b => b.Ascending(p => p.status_order),
					KeysetPaginationDirection.Forward,
					reference 
				);

                        var items = await paginationContext
			    .Query
			    .Take(pageSize)
			    .ToListAsync(cancellationToken);

Right now this builds a SQL query with an ORDER BY clause that directly refers to the computed (not actually existing) column:

...
ORDER BY e.status_order

Which obviously does not work.

So initially by query is two-fold:

  1. Is this even possible at all right now with EF Core?
  2. If yes, can you give me some pointers as to how you might go about attacking this so I can have a go myself?

Ordering based on mapped DTO

Would it at all be possible to base the builderAction parameter off of the mapped DTO (if present), instead of the source entities?

Wonder if this would help alleviate the "nested type" issue someone else raised.

Nullable fields

I am wondering if nullable fields are already handled. For instance: if the query is ordered by a nullable int field "Num", the value in the last row is NULL and the order is ascending, the expression "Num> NULL" is always FALSE in SQL... I think it should be generated something like "Num IS NOT NULL" (assuming NULL will come before any other value in ascending order). Similarly, in descending order, if the value in the last row is 10, the expression "Num < 10" will not get any row with NULL in "Num", the right expression should be something like "Num < 10 OR Num IS NULL".

Make this nuget independent from entity framework.

Hi!
This nuget is great! I know that it is targetting mostly to entity framework, but small changes can make it independent from EF.
The only place where's used async iqueryable is KeysetPaginationExtensions. If you change line 152:
return context.OrderedQuery.AnyAsync(lambda); to return Task.FromResult(context.OrderedQuery.Any(lambda)); you can get rid of the entity framework reference.
This will allow using of pagination with other ORMs using IQueryable (e.x. MongoDB.Driver). I can push these changes to another branch and make PR if you allow me to.

EF.Functions don't seems to be supported

When using EF.Functions the system try to run it client side.
Example:
with npgsql
.Ascending(i => EF.Functions.FuzzyStringMatchLevenshtein(i.Symbol.ToLower(), name.ToLower())) does not work

A normal order by on the query would work fine as it will be translated correctly

Nested types

Is there a way to use nested types in builder?

For example:

context.SomeModel.KeysetPaginate(b => b.Descending(entity => entity.Options.SomeOption), reference: new { Options = new { SomeOption = value } });

Handle null

Hi, I'm trying to move my current cursor paging implementation to this library and I'm having some issues with null properties.

reading about that https://github.com/mrahhal/MR.EntityFrameworkCore.KeysetPagination/blob/main/docs/caveats.md#null it does not seems to be supported (we currently need computed column to get rid of the null)

It would be nice to handle null properties for ordering without having to add computed columns.

Wouldn't it be possible to put all the null either at the end or at the beginning by comparing with Null (is null or is not null) on all properties (sql allow to check null on non nullable column) so something like

where 
property1 is null or property1 > cursor.property1 
or 
(property1 = cursor.property1 and (property2 is null or property2 > cursor.property2)
or
(property1 = cursor.property1 
    and (property2 = cursor.property2 and  (property3 is null or property3 > cursor.property3)
)
 var whereExpression = Expression.GreaterThan(Expression.Convert(sortProperties[0], orderProperties[0].Type), orderProperties[0]);
        for (var i = 1; i < sortProperties.Count; i++)
        {
            var prop = ((PropertyInfo)orderProperties[i].Member);
            var filterBy = prop.GetValue(cursor);

            var paramIsNull =
                Expression.OrElse(
                    Expression.Constant(filterBy == null, typeof(bool)),
                    Expression.Equal(Expression.Convert(sortProperties[i - 1], orderProperties[i - 1].Type), orderProperties[i - 1])
                );

            whereExpression = Expression.OrElse(
                whereExpression,
                Expression.AndAlso(
                    paramIsNull,
                    Expression.GreaterThan(Expression.Convert(sortProperties[i], orderProperties[i].Type), orderProperties[i])
                )
            );
        }

      

Column Attribute

I have an entity with a column that is defined similar to this.
The entity is being generated.

public ScheduledService {
   public Guid ScheduledServiceId { get; set; }
   [Column("TBID")]
   public int Tbid { get; set; }
   public DateTime ServiceDate{ get; set; }
}

I am trying to use this sorting
builder.Ascending(record => record.ServiceDate).Ascending(record => record.Tbid)

Error:
"Detail": "Property 'Tbid' not found on this object."
The Column attribute does not seem to be respected, and the query fails.

I thought it was purely the attribute, but I get the same error after manually renaming the Property to TBID.

public ScheduledService {
   public Guid ScheduledServiceId { get; set; }
   public int TBID{ get; set; }
   public DateTime ServiceDate{ get; set; }
}

"Detail": "Property 'TBID' not found on this object."

Is library thread safe? Particularly the HasNextAsync/HasPreviousAsync methods?

I have been writing some unit tests for a project of mine which call an API endpoint which uses keyset paging and I have noticed that when I run the tests until failure, they will intermittently fail. They always seem to fail on checking whether HasMore is = true/false.

This is the line of my test that always fails:

paging!.HasMore.Should().BeFalse(); OR paging!.HasMore.Should().BeTrue();

This is my keyset pagination query:

var items = await dataContext.Items
    .KeysetPaginate(b => b.Ascending(m => m.Id), KeysetPaginationDirection.Forward, reference)
    .Query
    .Select(u => new
    {
        Id = u.Id
    })
    .Take(take)
    .ToListAsync();

This is how I determine whether hasMore (cut down for brevity):

if (keysetPaginationContext.Direction == KeysetPaginationDirection.Forward)
{
    hasMore = await keysetPaginationContext.HasNextAsync(items);
}
else
{
    hasMore = await keysetPaginationContext.HasPreviousAsync(items);
}

This is my Paging<T> model which contains hasMore:

public class Paging<T>
{
    public bool HasMore { get; init; }
    public List<T> Items { get; init; }

    public Paging(List<T> items, bool hasMore)
    {
        Items = items;
        HasMore = hasMore;
    }
}

I am wondering if because the 8 tests run in parallel, perhaps something is getting wires crossed between tests? Some state is being used somewhere in the HasNextAsync(items) or HasPreviousAsync(items) ?

I am also creating the EF Core 7 data context fresh for every test so there should be no issues regarding that.

Any advice would be much appreciated.

Shadow property support

Hi, this doesnt seems to work:

x.Descending(x => EF.Property<DateTime>(x, "CreateDate"));

Any support for shadow properties or work around?.

The reason we cant just use the property directly its cause we mix entities and domain types, we hide specific entities properties like creation date, update date and others with shadow properties and expose only domain properties. Until now EF Core support for this is great, but testing this library (which ive been using for some time) i found out shadow properties are not supported or at least i cant find a way.

Thanks.

Offer an easier to consume pagination service aimed at apps

  • Consumer should be able to use mappers to transform results properly.
  • Offer automatic parsing of query params to control the pagination.
  • Allow configuring things like default page size, and whether or not the request can override it.
  • Offer a swashbuckle support package that includes an operation filter to add the hidden params.
  • Support IEnumerable + IQueryable. IEnumerable will do a quick in memory paging that replicates a cursor pagination.

Issue with Dynamic Sort column In KeySet for Value Type

First of all, I would like to thank you for this very useful package that abstracts away the complexity of Keyset Pagination.
So to the issue I seem to have encountered, I have a requirement to use a dynamic sort column in keyset.
in cases where the sort column is a reference type like string everything works Ok, but when the sort column is a value type like int, DateTime, or Guid, it fails with either of the following exceptions depending on the combination used.

System.InvalidOperationException : The binary operator LessThan is not defined for the types 'System.Object' and 'System.Object'.

or

System.InvalidOperationException : The binary operator GreaterThan is not defined for the types 'System.Object' and 'System.Object'.

a simple reproduction can be done by adding the code below to KeysetPaginationTest.cs and running the newly added test methods (Ascending_HasPreviousAsync_Buggy, Descending_HasPreviousAsync_Buggy, Ascending_HasNextAsync_Buggy, Descending_HasNextAsync_Buggy)

[Theory]
[InlineData("Id")]
[InlineData("String")]
[InlineData("Guid")]
[InlineData("IsDone")]
[InlineData("Created")]
[InlineData("CreatedNullable")]
public async Task Ascending_HasPreviousAsync_Buggy(string sortColumn)
{
  var keysetContext = DbContext.MainModels.KeysetPaginate(
    b => b.Ascending(GetSortColumn<MainModel>(sortColumn)));
  var items = await keysetContext.Query
    .Take(20)
    .ToListAsync();
  keysetContext.EnsureCorrectOrder(items);

  var dtos = items.Select(x => new
  {
    x.Id, x.String, x.Guid, x.IsDone, x.Created, x.CreatedNullable
  }).ToList();

  // exception is thrown when this line executes
  await keysetContext.HasPreviousAsync(dtos);
}

[Theory]
[InlineData("Id")]
[InlineData("String")]
[InlineData("Guid")]
[InlineData("IsDone")]
[InlineData("Created")]
[InlineData("CreatedNullable")]
public async Task Descending_HasPreviousAsync_Buggy(string sortColumn)
{
  var keysetContext = DbContext.MainModels.KeysetPaginate(
    b => b.Descending(GetSortColumn<MainModel>(sortColumn)));
  var items = await keysetContext.Query
    .Take(20)
    .ToListAsync();
  keysetContext.EnsureCorrectOrder(items);

  var dtos = items.Select(x => new
  {
    x.Id, x.String, x.Guid, x.IsDone, x.Created, x.CreatedNullable
  }).ToList();

  // exception is thrown when this line executes
  await keysetContext.HasPreviousAsync(dtos);
}

[Theory]
[InlineData("Id")]
[InlineData("String")]
[InlineData("Guid")]
[InlineData("IsDone")]
[InlineData("Created")]
[InlineData("CreatedNullable")]
public async Task Ascending_HasNextAsync_Buggy(string sortColumn)
{
  var keysetContext = DbContext.MainModels.KeysetPaginate(
    b => b.Ascending(GetSortColumn<MainModel>(sortColumn)));
  var items = await keysetContext.Query
    .Take(20)
    .ToListAsync();
  keysetContext.EnsureCorrectOrder(items);

  var dtos = items.Select(x => new
  {
    x.Id, x.String, x.Guid, x.IsDone, x.Created, x.CreatedNullable
  }).ToList();

  // exception is thrown when this line executes
  await keysetContext.HasNextAsync(dtos);
}

[Theory]
[InlineData("Id")]
[InlineData("String")]
[InlineData("Guid")]
[InlineData("IsDone")]
[InlineData("Created")]
[InlineData("CreatedNullable")]
public async Task Descending_HasNextAsync_Buggy(string sortColumn)
{
  var keysetContext = DbContext.MainModels.KeysetPaginate(
    b => b.Descending(GetSortColumn<MainModel>(sortColumn)));
  var items = await keysetContext.Query
    .Take(20)
    .ToListAsync();
  keysetContext.EnsureCorrectOrder(items);

  var dtos = items.Select(x => new
  {
    x.Id, x.String, x.Guid, x.IsDone, x.Created, x.CreatedNullable
  }).ToList();

  // exception is thrown when this line executes
  await keysetContext.HasNextAsync(dtos);
}

private static Expression<Func<TEntity, object>> GetSortColumn<TEntity>(string sortColumn) where TEntity: MainModel
{
  return sortColumn switch
  {
    _ when string.Equals(sortColumn, "Id", StringComparison.OrdinalIgnoreCase) => x => x.Id,
      _ when string.Equals(sortColumn, "String", StringComparison.OrdinalIgnoreCase) => x => x.String,
      _ when string.Equals(sortColumn, "Guid", StringComparison.OrdinalIgnoreCase) => x => x.Guid,
      _ when string.Equals(sortColumn, "IsDone", StringComparison.OrdinalIgnoreCase) => x => x.IsDone,
      _ when string.Equals(sortColumn, "Created", StringComparison.OrdinalIgnoreCase) => x => x.Created,
      _ when string.Equals(sortColumn, "CreatedNullable", StringComparison.OrdinalIgnoreCase) => x => x.CreatedNullable,
      _ =>
      throw new NotImplementedException($ "Unsupported {nameof(sortColumn)} {sortColumn}")
  };
}

from my little investigation, it seems that in order to coerce an expression returning a value type into Func<TEntity,object> the compiler needs to insert a Convert(expr, typeof(object)), a UnaryExpression.
For strings and other reference types, there is no need to box, so a "straight" member expression is returned.

Prebuilt keyset query definition

Right now every time we call KeysetPaginate we're creating new KeysetColumns (through KeysetPaginationBuilder). First, this means the following compiled expressions cache won't live across multiple KeysetPaginate calls, which renders it close to meaningless.

var compiledAccess = _referenceTypeToCompiledAccessMap.GetOrAdd(reference.GetType(), type =>
{
// Loose typing support: We'll need to adapt the lambda to the type.
var adaptedLambdaExpression = KeysetAdaptingExpressionVisitor.AdaptType(LambdaExpression, type);
return adaptedLambdaExpression.Compile();
});

To introduce more perf (through reusing compiled expressions) and decrease allocations, we could introduce a way to pre-build these KeysetColumns, and then just use the built result in calls to KeysetPaginate.

I imagine, instead of the following:

dbContext.Users.KeysetPaginate(
    b => b.Ascending(x => x.Id),
    ...);

we could do the following:

// In the ctor or someplace similar, set this to a static field for example.
_usersKeysetQuery = KeysetQuery.Build<User>(b => b.Ascending(x => x.Id));

// Then when calling KeysetPaginate.
dbContext.Users.KeysetPaginate(
    _usersQueryKeyset,
    ...);

This would allow both cached compiled expressions and KeysetColumns to be reused.

Add Enum support in keyset

As a developer I want to be able to use an Enum property in keyset.

Actually this throw an Exception at EF core side.

public enum MyEnum : int
{
    Val1,
    Val2
}

public class MyEntity
{
    public int Id { get; set; }
    public MyEnum MyEnum { get; set; }
}

var context = query.KeysetPaginate(b => b.Ascending(entity => entity.MyEnum), KeysetPaginationDirection.Forward);

Keep in mind that enum order will depend on the way it is stored in Db: string with converter / int ...

Expression adapting

This issue is about an enhancement of how we parse and work with the configured keyset column building expressions.

Right now we have support for two particular cases:

  • Simple access: x => x.Prop
  • Nested access: x => x.Nested.Prop

This issue is about completely overhauling how we deal with these expressions. Instead of supporting particular syntax, we should rewrite internal expressions to adapt it for use when building the keyset and other usages (i.e obtaining a value from a reference).

Error reporting should also be better and clearer for when there is incompatibility or unsupported syntax.

The goal is to also allow the following, among other patterns, when Created is nullable:

b.Ascending(x => x.Created ?? DateTime.MinValue)

This is instead of having to create a computed column to produce the same result. Note however that an indexed computed column will probably always be the preferred method for performance.

The following should also be possible as long as the resulting expression is non nullable.

b.Ascending(x => x.Created ?? x.Updated)

Note: This might harm performance as the query is forcing a scan instead of a seek. Is this something we should support in the first place? Since keyset pagination is all about performance.

Might also be related to #33.

Docs/Samples: Looping through entire queryable

The current sample is for interactive - how about a sample for headless where the entire purpose is to not bring in everything at once? (think huge tables that simply need to scanned)

Expose KeysetPaginationBuilder.ConfigureColumn

Can I change the access modifier on KeysetPaginationBuilder.ConfigureColumn to public?

At the moment I am having to write twice the amount of code required to use the fluent methods ascending/descending which I wouldn't need to do if I could just pass in true/false for descending

If not could i create a new method that takes in System.ComponentModel.ListSortDirection ?

Add a proper benchmark

Perf was validated using DB execution plans, but need to better cover all the different cases in a proper benchmark (BenchmarkDotNet) to have better visibility over the difference in perf under different circumstances.

Extend builder to pass member access by dot '.' notation string

As a developer I want to be able to specify member to acces using string dot '.' notation.

Our frontend just send to us the member path to order on in dot notation form.

This also ease the way we access Json fields in our backend.

public class NestedJsonModel
{
    public int Id { get; set; }
    public NestedInnerJsonModel Inner { get; set; }
}

public class NestedInnerJsonModel
{
    public int Id { get; set; }
    public DateTime Created { get; set; }
    public JsonDocument Data { get; set; }
}

var context = query..KeysetPaginateQuery(
			b => b.Ascending("inner.data.nbString"),
			KeysetPaginationDirection.Forward);

For Json property we also need an extra parameter to convert the JsonElement to the value type.

It can also be use in other cases for type convertion.

MethodInfo converter = typeof(JsonElement).GetMethod(....);
var context = query..KeysetPaginateQuery(
			b => b.Ascending("inner.data.nbString", converter),
			KeysetPaginationDirection.Forward);

Custom value objects

Hello, i'm trying to use this library with custom value objects but it seems like it does not work as expected.

For example:

public class UserId
{
    public UserId(string value) => Value = value;
    public string Value { get; set; }
}

public class User
{
    public UserId Id { get; set; }
    public string Email { get; set; }
    public DateTime RegisteredAt { get; set; }
}

public class UserEntityTypeConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id)
            .ValueGeneratedNever()
            .HasConversion(i => i.Value, i => new UserId(i));
        builder.Property(x => x.Email);
        builder.Property(x => x.RegisteredAt);
    }
}

var keysetBuilderAction = (KeysetPaginationBuilder<User> b) =>
{
    b.Ascending(x => x.CreatedAt).Ascending(x => x.Id);
};

var queryable = context.Users.AsQueryable();

var reference = await context.Users.FirstOrDefaultAsync(x => x.Id == new UserId(query.After));
var keysetContext = queryable.KeysetPaginate(keysetBuilderAction, KeysetPaginationDirection.Backward, reference);

It throws an exception:
The binary operator GreaterThan is not defined for the types 'App.ValueObjects.UserId'

Is there anyway to use UserId.Value for comparison?

Consider support for source generators

There is a fair amount of runtime expression manipulation handled by this library that might benefit from using a source generator at compile time. As I'm integrating this into a keyset paginated API, some benefits I could see from a source generated version:

  • Faster initialization time, especially in serverless environments.
  • Generate a serializable key type that corresponds to the columns required for the reference object, so that it's easier to provide pagination tokens to clients.
  • Aligns with the .NET 8 AOT performance goals as the EF Core team also investigates support for AOT.

It may also be reasonable to generate both IQueryable and IEnumerable versions using source generation to resolve #12, or determine if EFCore Async methods are available for IQueryable to resolve #32.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.