diff --git a/Directory.Build.props b/Directory.Build.props index 1d9eb8a..bab5986 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,7 +22,7 @@ - 3.0.0 + 3.0.1 DrSkillIssue MIT https://github.com/DrSkillIssue/EFPagination diff --git a/src/EFPagination/Cursor/CursorReader.cs b/src/EFPagination/Cursor/CursorReader.cs index 868f69c..6e738dc 100644 --- a/src/EFPagination/Cursor/CursorReader.cs +++ b/src/EFPagination/Cursor/CursorReader.cs @@ -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 ReadRawBytes(int length) { if ((uint)length > (uint)Remaining) diff --git a/src/EFPagination/Cursor/CursorWriter.cs b/src/EFPagination/Cursor/CursorWriter.cs index c0092f5..71a9f86 100644 --- a/src/EFPagination/Cursor/CursorWriter.cs +++ b/src/EFPagination/Cursor/CursorWriter.cs @@ -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); + } } diff --git a/src/EFPagination/Cursor/TypedCursorIo.cs b/src/EFPagination/Cursor/TypedCursorIo.cs index 0f904ff..e3274ae 100644 --- a/src/EFPagination/Cursor/TypedCursorIo.cs +++ b/src/EFPagination/Cursor/TypedCursorIo.cs @@ -62,6 +62,8 @@ public static void Write(ref CursorWriter writer, T value) if (typeof(T) == typeof(TimeOnly?)) { writer.WriteTimeOnly(Unsafe.As(ref value).GetValueOrDefault()); return; } if (typeof(T) == typeof(TimeSpan?)) { writer.WriteTimeSpan(Unsafe.As(ref value).GetValueOrDefault()); return; } + if (typeof(T) == typeof(byte[])) { writer.WriteByteArray(Unsafe.As(ref value)); return; } + ThrowUnsupported(); } @@ -117,6 +119,8 @@ public static T Read(ref CursorReader reader) if (typeof(T) == typeof(TimeOnly?)) { TimeOnly? v = reader.ReadTimeOnly(); return Unsafe.As(ref v); } if (typeof(T) == typeof(TimeSpan?)) { TimeSpan? v = reader.ReadTimeSpan(); return Unsafe.As(ref v); } + if (typeof(T) == typeof(byte[])) { var v = reader.ReadByteArray(); return Unsafe.As(ref v); } + return ThrowUnsupported(); } diff --git a/src/EFPagination/Internal/ByteArrayPaginationComparer.cs b/src/EFPagination/Internal/ByteArrayPaginationComparer.cs new file mode 100644 index 0000000..088ba80 --- /dev/null +++ b/src/EFPagination/Internal/ByteArrayPaginationComparer.cs @@ -0,0 +1,6 @@ +namespace EFPagination.Internal; + +internal static class ByteArrayPaginationComparer +{ + public static int Compare(byte[] a, byte[] b) => a.AsSpan().SequenceCompareTo(b.AsSpan()); +} diff --git a/src/EFPagination/Internal/FilterPredicateStrategy.cs b/src/EFPagination/Internal/FilterPredicateStrategy.cs index 17d6025..0749c16 100644 --- a/src/EFPagination/Internal/FilterPredicateStrategy.cs +++ b/src/EFPagination/Internal/FilterPredicateStrategy.cs @@ -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(); /// @@ -100,7 +102,10 @@ private static BinaryExpression MakeComparisonExpression( { 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); } diff --git a/src/EFPagination/Internal/SpineReconstructor.cs b/src/EFPagination/Internal/SpineReconstructor.cs index 926b926..d4284d5 100644 --- a/src/EFPagination/Internal/SpineReconstructor.cs +++ b/src/EFPagination/Internal/SpineReconstructor.cs @@ -59,6 +59,7 @@ public Expression> Reconstruct(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() }; } @@ -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; } @@ -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, diff --git a/test/EFPagination.Tests/ByteArrayKeysetTests.cs b/test/EFPagination.Tests/ByteArrayKeysetTests.cs new file mode 100644 index 0000000..cfa893e --- /dev/null +++ b/test/EFPagination.Tests/ByteArrayKeysetTests.cs @@ -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(); + } + + [Fact] + public void Cursor_RoundTrips_ByteArray() + { + var definition = PaginationQuery.Build(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(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()); + } +} diff --git a/test/EFPagination.Tests/DatabaseFixture.cs b/test/EFPagination.Tests/DatabaseFixture.cs index bfb2529..559e488 100644 --- a/test/EFPagination.Tests/DatabaseFixture.cs +++ b/test/EFPagination.Tests/DatabaseFixture.cs @@ -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(), @@ -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, }); } diff --git a/test/EFPagination.Tests/TestModels/MainModel.cs b/test/EFPagination.Tests/TestModels/MainModel.cs index 549cb27..0002cec 100644 --- a/test/EFPagination.Tests/TestModels/MainModel.cs +++ b/test/EFPagination.Tests/TestModels/MainModel.cs @@ -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; } @@ -28,6 +29,10 @@ public class MainModel public NestedInnerModel Inner { get; set; } public List 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))]