|
| 1 | +using System.Buffers; |
| 2 | +using System.Buffers.Binary; |
| 3 | +using System.Buffers.Text; |
| 4 | +using System.Diagnostics; |
| 5 | +using System.Diagnostics.CodeAnalysis; |
| 6 | +using System.Runtime.CompilerServices; |
| 7 | +using System.Runtime.InteropServices; |
| 8 | + |
| 9 | +namespace Arbiter.CommandQuery.Services; |
| 10 | + |
| 11 | +/// <summary> |
| 12 | +/// Represents a continuation token for pagination that combines an identifier and an optional timestamp. |
| 13 | +/// </summary> |
| 14 | +/// <typeparam name="T">The type of the identifier. Must be an unmanaged type.</typeparam> |
| 15 | +/// <remarks> |
| 16 | +/// <para> |
| 17 | +/// The <see cref="ContinuationToken{T}"/> provides a stateless pagination mechanism by encoding an identifier |
| 18 | +/// and optional timestamp into a compact, URL-safe Base64 string. This approach is more efficient than traditional |
| 19 | +/// page-based pagination for large datasets and avoids consistency issues when data changes between requests. |
| 20 | +/// </para> |
| 21 | +/// <para> |
| 22 | +/// The token uses little-endian encoding for cross-platform consistency and supports common unmanaged types including: |
| 23 | +/// <list type="bullet"> |
| 24 | +/// <item><description>Numeric types: <see cref="int"/>, <see cref="long"/>, <see cref="short"/>, <see cref="byte"/>, and unsigned variants</description></item> |
| 25 | +/// <item><description>Floating-point types: <see cref="float"/>, <see cref="double"/></description></item> |
| 26 | +/// <item><description><see cref="Guid"/> for unique identifiers</description></item> |
| 27 | +/// <item><description>Other unmanaged value types</description></item> |
| 28 | +/// </list> |
| 29 | +/// </para> |
| 30 | +/// <para> |
| 31 | +/// The optional timestamp component stores UTC ticks and can be used for time-based pagination or audit tracking. |
| 32 | +/// When serialized, the token is encoded as a Base64URL string that is safe for use in URLs and HTTP headers. |
| 33 | +/// </para> |
| 34 | +/// </remarks> |
| 35 | +/// <example> |
| 36 | +/// <para><strong>Basic Usage with Entity Framework Core</strong></para> |
| 37 | +/// <para> |
| 38 | +/// The following example demonstrates keyset pagination using continuation tokens. |
| 39 | +/// This is more efficient than SKIP/TAKE because it uses indexed WHERE clauses: |
| 40 | +/// </para> |
| 41 | +/// <code> |
| 42 | +/// var query = dbContext.Products |
| 43 | +/// .AsNoTracking() |
| 44 | +/// .Where(p => p.IsActive); |
| 45 | +/// |
| 46 | +/// // Parse continuation token to get the last seen ID |
| 47 | +/// if (ContinuationToken<int>.TryParse(continuationToken, out var token)) |
| 48 | +/// { |
| 49 | +/// // Fetch items after the last seen ID (keyset pagination) |
| 50 | +/// query = query.Where(p => p.Id > token.Id); |
| 51 | +/// } |
| 52 | +/// |
| 53 | +/// // Take one more than requested to check if there are more items |
| 54 | +/// var pageSize = 20; |
| 55 | +/// var items = await query |
| 56 | +/// .OrderBy(p => p.Id) |
| 57 | +/// .Take(pageSize + 1) |
| 58 | +/// .ToListAsync(); |
| 59 | +/// |
| 60 | +/// // Check if there are more items and create continuation token |
| 61 | +/// string? nextToken = null; |
| 62 | +/// if (items.Count > pageSize) |
| 63 | +/// { |
| 64 | +/// items.RemoveAt(items.Count - 1); |
| 65 | +/// var lastItem = items[^1]; |
| 66 | +/// nextToken = new ContinuationToken<int>(lastItem.Id).ToString(); |
| 67 | +/// } |
| 68 | +/// </code> |
| 69 | +/// |
| 70 | +/// <para><strong>Time-Based Pagination</strong></para> |
| 71 | +/// <para> |
| 72 | +/// Using timestamp with ID as a tie-breaker for ordering by date: |
| 73 | +/// </para> |
| 74 | +/// <code> |
| 75 | +/// var query = dbContext.Events.AsNoTracking(); |
| 76 | +/// |
| 77 | +/// // Parse token to get last seen timestamp and ID |
| 78 | +/// if (ContinuationToken<int>.TryParse(continuationToken, out var token)) |
| 79 | +/// { |
| 80 | +/// // Keyset pagination with composite key (timestamp + ID) |
| 81 | +/// query = query.Where(e => |
| 82 | +/// e.CreatedDate > token.Timestamp || |
| 83 | +/// (e.CreatedDate == token.Timestamp && e.Id > token.Id)); |
| 84 | +/// } |
| 85 | +/// |
| 86 | +/// // Take one more than requested to check if there are more items |
| 87 | +/// var pageSize = 50; |
| 88 | +/// var events = await query |
| 89 | +/// .OrderBy(e => e.CreatedDate) |
| 90 | +/// .ThenBy(e => e.Id) |
| 91 | +/// .Take(pageSize + 1) |
| 92 | +/// .ToListAsync(); |
| 93 | +/// |
| 94 | +/// // Check if there are more items and create continuation token |
| 95 | +/// string? nextToken = null; |
| 96 | +/// if (events.Count > pageSize) |
| 97 | +/// { |
| 98 | +/// events.RemoveAt(events.Count - 1); |
| 99 | +/// var last = events[^1]; |
| 100 | +/// nextToken = new ContinuationToken<int>(last.Id, last.CreatedDate).ToString(); |
| 101 | +/// } |
| 102 | +/// </code> |
| 103 | +/// </example> |
| 104 | +[DebuggerDisplay("Id = {Id}, Timestamp = {Timestamp}")] |
| 105 | +[StructLayout(LayoutKind.Sequential)] |
| 106 | +public readonly record struct ContinuationToken<T> |
| 107 | + where T : unmanaged |
| 108 | +{ |
| 109 | + /// <summary> |
| 110 | + /// Initializes a new instance of the <see cref="ContinuationToken{T}"/> struct. |
| 111 | + /// </summary> |
| 112 | + /// <param name="id">The identifier value.</param> |
| 113 | + /// <param name="timestamp">The optional timestamp associated with the token.</param> |
| 114 | + public ContinuationToken(T id, DateTimeOffset? timestamp = null) |
| 115 | + { |
| 116 | + Id = id; |
| 117 | + Timestamp = timestamp; |
| 118 | + } |
| 119 | + |
| 120 | + /// <summary> |
| 121 | + /// Gets the identifier value. |
| 122 | + /// </summary> |
| 123 | + public T Id { get; } |
| 124 | + |
| 125 | + /// <summary> |
| 126 | + /// Gets the optional timestamp associated with the token. |
| 127 | + /// </summary> |
| 128 | + public DateTimeOffset? Timestamp { get; } |
| 129 | + |
| 130 | + |
| 131 | + /// <summary> |
| 132 | + /// Converts the continuation token to a Base64URL-encoded string representation. |
| 133 | + /// </summary> |
| 134 | + /// <returns>A Base64URL-encoded string containing the identifier and optional date.</returns> |
| 135 | + public override readonly string ToString() |
| 136 | + { |
| 137 | + int idSize = Unsafe.SizeOf<T>(); |
| 138 | + |
| 139 | + // Allocate: idSize for Id + 1 for hasDate flag + (optional: 8 for ticks) |
| 140 | + int totalSize = Timestamp.HasValue ? idSize + 9 : idSize + 1; |
| 141 | + Span<byte> buffer = stackalloc byte[totalSize]; |
| 142 | + |
| 143 | + // Write Id with explicit little-endian encoding |
| 144 | + WriteValueLittleEndian(Id, buffer); |
| 145 | + |
| 146 | + // Write hasDate flag |
| 147 | + buffer[idSize] = Timestamp.HasValue ? (byte)1 : (byte)0; |
| 148 | + |
| 149 | + // Write Date ticks if present |
| 150 | + if (Timestamp.HasValue) |
| 151 | + BinaryPrimitives.WriteInt64LittleEndian(buffer[(idSize + 1)..], Timestamp.Value.UtcTicks); |
| 152 | + |
| 153 | + // Convert to Base64URL |
| 154 | + int encodedLength = Base64Url.GetEncodedLength(totalSize); |
| 155 | + Span<byte> encoded = stackalloc byte[encodedLength]; |
| 156 | + Base64Url.EncodeToUtf8(buffer, encoded, out _, out _); |
| 157 | + |
| 158 | + return System.Text.Encoding.UTF8.GetString(encoded); |
| 159 | + } |
| 160 | + |
| 161 | + |
| 162 | + /// <summary> |
| 163 | + /// Attempts to parse a Base64URL-encoded string into a continuation token. |
| 164 | + /// </summary> |
| 165 | + /// <param name="token">The Base64URL-encoded token string to parse.</param> |
| 166 | + /// <param name="result">When this method returns, contains the parsed continuation token if successful; otherwise, the default value.</param> |
| 167 | + /// <returns><c>true</c> if the token was successfully parsed; otherwise, <c>false</c>.</returns> |
| 168 | + [SuppressMessage("Design", "MA0018:Do not declare static members on generic types", Justification = "Safe")] |
| 169 | + public static bool TryParse(string? token, out ContinuationToken<T> result) |
| 170 | + { |
| 171 | + result = default; |
| 172 | + |
| 173 | + if (string.IsNullOrEmpty(token)) |
| 174 | + return false; |
| 175 | + |
| 176 | + try |
| 177 | + { |
| 178 | + int idSize = Unsafe.SizeOf<T>(); |
| 179 | + |
| 180 | + // Decode from Base64URL |
| 181 | + var tokenByteCount = System.Text.Encoding.UTF8.GetByteCount(token); |
| 182 | + Span<byte> encodedBytes = stackalloc byte[tokenByteCount]; |
| 183 | + System.Text.Encoding.UTF8.GetBytes(token, encodedBytes); |
| 184 | + |
| 185 | + int maxDecodedLength = Base64Url.GetMaxDecodedLength(encodedBytes.Length); |
| 186 | + Span<byte> buffer = stackalloc byte[maxDecodedLength]; |
| 187 | + |
| 188 | + if (Base64Url.DecodeFromUtf8(encodedBytes, buffer, out _, out int bytesWritten) != OperationStatus.Done) |
| 189 | + return false; |
| 190 | + |
| 191 | + buffer = buffer[..bytesWritten]; |
| 192 | + |
| 193 | + // Validate minimum size (idSize + 1 for hasDate flag) |
| 194 | + if (buffer.Length < idSize + 1) |
| 195 | + return false; |
| 196 | + |
| 197 | + // Read hasDate flag first to determine expected size |
| 198 | + bool hasDate = buffer[idSize] == 1; |
| 199 | + |
| 200 | + // Calculate expected size and validate exact match |
| 201 | + int expectedSize = hasDate ? idSize + 9 : idSize + 1; |
| 202 | + if (buffer.Length != expectedSize) |
| 203 | + return false; |
| 204 | + |
| 205 | + // Read Id with explicit little-endian decoding |
| 206 | + T id = ReadValueLittleEndian<T>(buffer); |
| 207 | + |
| 208 | + DateTimeOffset? date = null; |
| 209 | + if (hasDate) |
| 210 | + { |
| 211 | + long ticks = BinaryPrimitives.ReadInt64LittleEndian(buffer[(idSize + 1)..]); |
| 212 | + date = new DateTimeOffset(ticks, TimeSpan.Zero); |
| 213 | + } |
| 214 | + |
| 215 | + result = new ContinuationToken<T>(id, date); |
| 216 | + return true; |
| 217 | + } |
| 218 | + catch |
| 219 | + { |
| 220 | + return false; |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + |
| 225 | + /// <summary> |
| 226 | + /// Writes a value to a buffer using little-endian encoding. |
| 227 | + /// </summary> |
| 228 | + /// <typeparam name="TValue">The type of value to write. Must be an unmanaged type.</typeparam> |
| 229 | + /// <param name="value">The value to write.</param> |
| 230 | + /// <param name="buffer">The destination buffer.</param> |
| 231 | + private static void WriteValueLittleEndian<TValue>(TValue value, Span<byte> buffer) |
| 232 | + where TValue : unmanaged |
| 233 | + { |
| 234 | + // Handle common numeric types with explicit endianness |
| 235 | + if (typeof(TValue) == typeof(short)) |
| 236 | + BinaryPrimitives.WriteInt16LittleEndian(buffer, Unsafe.As<TValue, short>(ref value)); |
| 237 | + else if (typeof(TValue) == typeof(ushort)) |
| 238 | + BinaryPrimitives.WriteUInt16LittleEndian(buffer, Unsafe.As<TValue, ushort>(ref value)); |
| 239 | + else if (typeof(TValue) == typeof(int)) |
| 240 | + BinaryPrimitives.WriteInt32LittleEndian(buffer, Unsafe.As<TValue, int>(ref value)); |
| 241 | + else if (typeof(TValue) == typeof(uint)) |
| 242 | + BinaryPrimitives.WriteUInt32LittleEndian(buffer, Unsafe.As<TValue, uint>(ref value)); |
| 243 | + else if (typeof(TValue) == typeof(long)) |
| 244 | + BinaryPrimitives.WriteInt64LittleEndian(buffer, Unsafe.As<TValue, long>(ref value)); |
| 245 | + else if (typeof(TValue) == typeof(ulong)) |
| 246 | + BinaryPrimitives.WriteUInt64LittleEndian(buffer, Unsafe.As<TValue, ulong>(ref value)); |
| 247 | + else if (typeof(TValue) == typeof(float)) |
| 248 | + BinaryPrimitives.WriteSingleLittleEndian(buffer, Unsafe.As<TValue, float>(ref value)); |
| 249 | + else if (typeof(TValue) == typeof(double)) |
| 250 | + BinaryPrimitives.WriteDoubleLittleEndian(buffer, Unsafe.As<TValue, double>(ref value)); |
| 251 | + else if (typeof(TValue) == typeof(byte) || typeof(TValue) == typeof(sbyte)) |
| 252 | + // Single byte types don't need endianness conversion |
| 253 | + Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(buffer), value); |
| 254 | + else if (typeof(TValue) == typeof(Guid)) |
| 255 | + Unsafe.As<TValue, Guid>(ref value).TryWriteBytes(buffer); |
| 256 | + else |
| 257 | + // For other unmanaged types, use unaligned write |
| 258 | + Unsafe.WriteUnaligned(ref MemoryMarshal.GetReference(buffer), value); |
| 259 | + } |
| 260 | + |
| 261 | + /// <summary> |
| 262 | + /// Reads a value from a buffer using little-endian encoding. |
| 263 | + /// </summary> |
| 264 | + /// <typeparam name="TValue">The type of value to read. Must be an unmanaged type.</typeparam> |
| 265 | + /// <param name="buffer">The source buffer.</param> |
| 266 | + /// <returns>The value read from the buffer.</returns> |
| 267 | + private static TValue ReadValueLittleEndian<TValue>(Span<byte> buffer) |
| 268 | + where TValue : unmanaged |
| 269 | + { |
| 270 | + // Handle common numeric types with explicit endianness |
| 271 | + if (typeof(TValue) == typeof(short)) |
| 272 | + { |
| 273 | + short value = BinaryPrimitives.ReadInt16LittleEndian(buffer); |
| 274 | + return Unsafe.As<short, TValue>(ref value); |
| 275 | + } |
| 276 | + else if (typeof(TValue) == typeof(ushort)) |
| 277 | + { |
| 278 | + ushort value = BinaryPrimitives.ReadUInt16LittleEndian(buffer); |
| 279 | + return Unsafe.As<ushort, TValue>(ref value); |
| 280 | + } |
| 281 | + else if (typeof(TValue) == typeof(int)) |
| 282 | + { |
| 283 | + int value = BinaryPrimitives.ReadInt32LittleEndian(buffer); |
| 284 | + return Unsafe.As<int, TValue>(ref value); |
| 285 | + } |
| 286 | + else if (typeof(TValue) == typeof(uint)) |
| 287 | + { |
| 288 | + uint value = BinaryPrimitives.ReadUInt32LittleEndian(buffer); |
| 289 | + return Unsafe.As<uint, TValue>(ref value); |
| 290 | + } |
| 291 | + else if (typeof(TValue) == typeof(long)) |
| 292 | + { |
| 293 | + long value = BinaryPrimitives.ReadInt64LittleEndian(buffer); |
| 294 | + return Unsafe.As<long, TValue>(ref value); |
| 295 | + } |
| 296 | + else if (typeof(TValue) == typeof(ulong)) |
| 297 | + { |
| 298 | + ulong value = BinaryPrimitives.ReadUInt64LittleEndian(buffer); |
| 299 | + return Unsafe.As<ulong, TValue>(ref value); |
| 300 | + } |
| 301 | + else if (typeof(TValue) == typeof(float)) |
| 302 | + { |
| 303 | + float value = BinaryPrimitives.ReadSingleLittleEndian(buffer); |
| 304 | + return Unsafe.As<float, TValue>(ref value); |
| 305 | + } |
| 306 | + else if (typeof(TValue) == typeof(double)) |
| 307 | + { |
| 308 | + double value = BinaryPrimitives.ReadDoubleLittleEndian(buffer); |
| 309 | + return Unsafe.As<double, TValue>(ref value); |
| 310 | + } |
| 311 | + else if (typeof(TValue) == typeof(byte) || typeof(TValue) == typeof(sbyte)) |
| 312 | + { |
| 313 | + // Single byte types don't need endianness conversion |
| 314 | + return Unsafe.ReadUnaligned<TValue>(ref MemoryMarshal.GetReference(buffer)); |
| 315 | + } |
| 316 | + else if (typeof(TValue) == typeof(Guid)) |
| 317 | + { |
| 318 | + Guid value = new(buffer[..16]); |
| 319 | + return Unsafe.As<Guid, TValue>(ref value); |
| 320 | + } |
| 321 | + else |
| 322 | + { |
| 323 | + // For other unmanaged types, use unaligned read |
| 324 | + // Note: This may still have endianness issues for complex structs |
| 325 | + return Unsafe.ReadUnaligned<TValue>(ref MemoryMarshal.GetReference(buffer)); |
| 326 | + } |
| 327 | + } |
| 328 | +} |
0 commit comments