Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
</PropertyGroup>

<PropertyGroup Condition="'$(IsPackable)' != 'false'">
<Version>3.0.0</Version>
<Version>3.0.1</Version>
<Authors>DrSkillIssue</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/DrSkillIssue/EFPagination</PackageProjectUrl>
Expand Down
13 changes: 13 additions & 0 deletions src/EFPagination/Cursor/CursorReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,19 @@ public string ReadString()
return Encoding.UTF8.GetString(slice);
}

public byte[] ReadByteArray()
{
var byteLen = ReadVarUInt32();
if (Failed || byteLen > (uint)Remaining)
{
Failed = true;
return [];
}
var slice = _buffer.Slice(Position, (int)byteLen);
Position += (int)byteLen;
return slice.ToArray();
}

public ReadOnlySpan<byte> ReadRawBytes(int length)
{
if ((uint)length > (uint)Remaining)
Expand Down
8 changes: 8 additions & 0 deletions src/EFPagination/Cursor/CursorWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,12 @@ public void WriteString(string value)
Encoding.UTF8.GetBytes(value, dest);
_buffer.Advance(byteCount);
}

public void WriteByteArray(byte[] value)
{
WriteVarUInt32((uint)value.Length);
var dest = _buffer.GetSpan(value.Length);
value.AsSpan().CopyTo(dest);
_buffer.Advance(value.Length);
}
}
4 changes: 4 additions & 0 deletions src/EFPagination/Cursor/TypedCursorIo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ public static void Write<T>(ref CursorWriter writer, T value)
if (typeof(T) == typeof(TimeOnly?)) { writer.WriteTimeOnly(Unsafe.As<T, TimeOnly?>(ref value).GetValueOrDefault()); return; }
if (typeof(T) == typeof(TimeSpan?)) { writer.WriteTimeSpan(Unsafe.As<T, TimeSpan?>(ref value).GetValueOrDefault()); return; }

if (typeof(T) == typeof(byte[])) { writer.WriteByteArray(Unsafe.As<T, byte[]>(ref value)); return; }

ThrowUnsupported<T>();
}

Expand Down Expand Up @@ -117,6 +119,8 @@ public static T Read<T>(ref CursorReader reader)
if (typeof(T) == typeof(TimeOnly?)) { TimeOnly? v = reader.ReadTimeOnly(); return Unsafe.As<TimeOnly?, T>(ref v); }
if (typeof(T) == typeof(TimeSpan?)) { TimeSpan? v = reader.ReadTimeSpan(); return Unsafe.As<TimeSpan?, T>(ref v); }

if (typeof(T) == typeof(byte[])) { var v = reader.ReadByteArray(); return Unsafe.As<byte[], T>(ref v); }

return ThrowUnsupported<T>();
}

Expand Down
6 changes: 6 additions & 0 deletions src/EFPagination/Internal/ByteArrayPaginationComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace EFPagination.Internal;

internal static class ByteArrayPaginationComparer
{
public static int Compare(byte[] a, byte[] b) => a.AsSpan().SequenceCompareTo(b.AsSpan());
}
7 changes: 6 additions & 1 deletion src/EFPagination/Internal/FilterPredicateStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ internal static class FilterPredicateStrategy
{ typeof(string), GetCompareToMethod(typeof(string)) },
{ typeof(Guid), GetCompareToMethod(typeof(Guid)) },
{ typeof(bool), GetCompareToMethod(typeof(bool)) },
{ typeof(byte[]), typeof(ByteArrayPaginationComparer).GetMethod(nameof(ByteArrayPaginationComparer.Compare))
?? throw new InvalidOperationException("ByteArrayPaginationComparer.Compare not found.") },
}.ToFrozenDictionary();

/// <summary>
Expand Down Expand Up @@ -100,7 +102,10 @@ private static BinaryExpression MakeComparisonExpression<T>(
{
if (s_typeToCompareToMethod.TryGetValue(column.Type, out var compareToMethod))
{
var methodCall = Expression.Call(memberAccess, compareToMethod, EnsureMatchingType(memberAccess, referenceValue));
var matched = EnsureMatchingType(memberAccess, referenceValue);
var methodCall = compareToMethod.IsStatic
? Expression.Call(compareToMethod, memberAccess, matched)
: Expression.Call(memberAccess, compareToMethod, matched);
return compare(methodCall, ZeroConstant);
}

Expand Down
9 changes: 9 additions & 0 deletions src/EFPagination/Internal/SpineReconstructor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public Expression<Func<T, bool>> Reconstruct<T>(Expression[] replacements, Param
Op.BinaryStaticLeft => Expression.MakeBinary(inst.ExprType, inst.Static!, slots[inst.Right]),
Op.BinaryStaticRight => Expression.MakeBinary(inst.ExprType, slots[inst.Left], inst.Static!),
Op.MethodCall => Expression.Call(inst.Static!, inst.Method!, slots[inst.Right]),
Op.MethodCallStatic2 => Expression.Call(inst.Method!, inst.Static!, slots[inst.Right]),
_ => throw new InvalidOperationException()
};
}
Expand Down Expand Up @@ -149,6 +150,13 @@ private static int Flatten(Expression node, AnalysisContext ctx)
return ctx.Emit(new Instruction(Op.MethodCall, right: argSlot, staticExpr: call.Object, method: call.Method));
}

case MethodCallExpression call when call.Object is null && call.Arguments.Count == 2:
{
var argSlot = Flatten(call.Arguments[1], ctx);
if (argSlot < 0) return -1;
return ctx.Emit(new Instruction(Op.MethodCallStatic2, right: argSlot, staticExpr: call.Arguments[0], method: call.Method));
}

default:
return -1;
}
Expand Down Expand Up @@ -203,6 +211,7 @@ private enum Op : byte
BinaryStaticLeft,
BinaryStaticRight,
MethodCall,
MethodCallStatic2,
}

private readonly struct Instruction(Op op, int index = 0, int left = 0, int right = 0,
Expand Down
47 changes: 47 additions & 0 deletions test/EFPagination.Tests/ByteArrayKeysetTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using EFPagination.TestModels;
using Xunit;

namespace EFPagination;

[Collection(SqliteDatabaseCollection.Name)]
public class ByteArrayKeysetTests
{
private readonly TestDbContext _dbContext;

public ByteArrayKeysetTests(SqliteDatabaseFixture fixture)
{
var provider = fixture.BuildServices();
_dbContext = provider.GetService<TestDbContext>();
}

[Fact]
public void Cursor_RoundTrips_ByteArray()
{
var definition = PaginationQuery.Build<MainModel>(b => b.Ascending(x => x.Bytes).Ascending(x => x.Id));
var entity = new MainModel { Id = 42, Bytes = [0x01, 0x02, 0x03, 0xFF] };

var encoded = PaginationCursor.Encode(definition, entity);
var success = PaginationCursor.TryDecode(encoded, definition, out var values, out var metadata);

success.Should().BeTrue();
metadata.ValueCount.Should().Be(2);
values.Count.Should().Be(2);
}

[Fact]
public async Task Keyset_Paginates_By_ByteArray()
{
var definition = PaginationQuery.Build<MainModel>(b => b.Ascending(x => x.Bytes).Ascending(x => x.Id));

var firstPage = await _dbContext.MainModels.Keyset(definition).TakeAsync(10);

firstPage.Items.Select(x => x.Id).Should().BeEquivalentTo(Enumerable.Range(1, 10), o => o.WithStrictOrdering());
firstPage.NextCursor.Should().NotBeNull();

var secondPage = await _dbContext.MainModels.Keyset(definition).After(firstPage.NextCursor!).TakeAsync(10);

secondPage.Items.Select(x => x.Id).Should().BeEquivalentTo(Enumerable.Range(11, 10), o => o.WithStrictOrdering());
}
}
7 changes: 7 additions & 0 deletions test/EFPagination.Tests/DatabaseFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ private static void Seed(TestDbContext context)
var created = now.AddMinutes(i);
// Deterministic pseudo-random count without System.Random (avoids CA5394).
var inners2Count = (i * 7 + 3) % 10;
// 16-byte sequence whose big-endian numeric value increases with i so that
// byte-array lexicographic order matches Id order. Verifies that byte[] keyset
// pagination produces the same row ordering as a primitive key.
var bytes = new byte[16];
bytes[15] = (byte)(i & 0xFF);
bytes[14] = (byte)((i >> 8) & 0xFF);
_ = context.MainModels.Add(new MainModel
{
String = i.ToString(),
Expand All @@ -86,6 +92,7 @@ private static void Seed(TestDbContext context)
},
Inners2 = Enumerable.Range(0, inners2Count).Select(_ => new NestedInner2Model()).ToList(),
EnumValue = i % 2 == 0 ? TestEnum.Value1 : TestEnum.Value2,
Bytes = bytes,
});
}

Expand Down
5 changes: 5 additions & 0 deletions test/EFPagination.Tests/TestModels/MainModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace EFPagination.TestModels;
[Index(nameof(IsDone))]
[Index(nameof(Created))]
[Index(nameof(CreatedComputed))]
[Index(nameof(Bytes))]
public class MainModel
{
public int Id { get; set; }
Expand All @@ -28,6 +29,10 @@ public class MainModel
public NestedInnerModel Inner { get; set; }

public List<NestedInner2Model> Inners2 { get; set; }

#pragma warning disable CA1819 // Properties should not return arrays — required for EF Core varbinary mapping.
public byte[] Bytes { get; set; } = [];
#pragma warning restore CA1819
}

[Index(nameof(Created))]
Expand Down