diff --git a/README.md b/README.md index e69066b..ed319c1 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,28 @@ var ulidFromString = Ulid.Parse(ulidString); Console.WriteLine($"ULID: {ulid}, GUID: {guid}, String: {ulidString}"); ``` + +### Filtering by Time Range (LINQ) + +Since ULIDs are lexicographically sortable and contain a timestamp, you can use `Ulid.MinAt()` and `Ulid.MaxAt()` to generate boundary ULIDs for a specific time range. This allows EF Core to translate these into efficient range comparisons (e.g., `WHERE Id >= @min AND Id <= @max`) in your database. + +```csharp +public async Task> GetEntitiesFromYesterday(MyDbContext context) +{ + var startOfYesterday = DateTimeOffset.UtcNow.AddDays(-1).Date; + var endOfYesterday = startOfYesterday.AddDays(1).AddTicks(-1); + + // Create boundary ULIDs for the time range + var minUlid = Ulid.MinAt(startOfYesterday); + var maxUlid = Ulid.MaxAt(endOfYesterday); + + // This query uses the primary key index for high performance + return await context.Entities + .Where(e => e.Id >= minUlid && e.Id <= maxUlid) + .ToListAsync(); +} +``` + ### Advanced Generation You can customize ULID generation by providing `GenerationOptions`. This allows you to control monotonicity and the source of randomness. @@ -133,6 +155,7 @@ var ulid = Ulid.New(); Console.WriteLine($"ULID from pseudo-random source: {ulid}"); ``` + ## API The `Ulid` implementation provides the following properties and methods: @@ -153,6 +176,14 @@ Generates a new ULID using the specified Unix timestamp in milliseconds (`long`) Creates a ULID from an existing byte array. - `Ulid.New(Guid guid)`\ Create from existing `Guid`. +- `Ulid.MinAt(DateTimeOffset datetime)`\ +Creates the minimum possible ULID value for the specified `DateTimeOffset`. +- `Ulid.MinAt(long timestamp)`\ +Creates the minimum possible ULID value for the specified Unix timestamp in milliseconds (`long`). +- `Ulid.MaxAt(DateTimeOffset datetime)`\ +Creates the maximum possible ULID value for the specified `DateTimeOffset`. +- `Ulid.MaxAt(long timestamp)`\ +Creates the maximum possible ULID value for the specified Unix timestamp in milliseconds (`long`). ### Checking Validity @@ -177,9 +208,11 @@ Tries to parse a ULID from a string in canonical format. Returns `true` if succe ### Properties - `Ulid.Empty`\ - Represents an empty ULID, equivalent to `default(Ulid)` and `Ulid.New(new byte[16])`. +Represents an empty ULID, equivalent to `default(Ulid)` and `Ulid.New(new byte[16])`. +- `Ulid.Max`\ +Represents the maximum possible value for a ULID (all bytes set to `0xFF`). - `Ulid.DefaultGenerationOptions`\ - Default configuration for ULID generation when no options are provided by the `Ulid.New(...)` call. +Default configuration for ULID generation when no options are provided by the `Ulid.New(...)` call. - `.Time`\ Gets the timestamp component of the ULID as a `DateTimeOffset`. - `.TimeBytes`\ diff --git a/src/ByteAether.Ulid.Tests/Ulid.Boundaries.Tests.cs b/src/ByteAether.Ulid.Tests/Ulid.Boundaries.Tests.cs new file mode 100644 index 0000000..a94d18a --- /dev/null +++ b/src/ByteAether.Ulid.Tests/Ulid.Boundaries.Tests.cs @@ -0,0 +1,87 @@ +namespace ByteAether.Ulid.Tests; + +public class UlidBoundariesTests +{ + [Fact] + public void EmptyUlid_ShouldBeDefault() + { + // Arrange + var ulid = Ulid.Empty; + var emptyBytes = new byte[16]; + + // Assert + Assert.Equal(default, ulid); + Assert.Equal(emptyBytes, ulid.AsByteSpan()); + } + + [Fact] + public void MaxUlid_ShouldHaveAllBytesSetToMax() + { + // Arrange + var ulid = Ulid.Max; + var expected = Enumerable.Repeat((byte)0xFF, 16).ToArray(); + + // Assert + Assert.Equal(expected, ulid.AsByteSpan()); + } + + [Fact] + public void MinAt_WithLongTimestamp_ShouldHaveZeroRandomComponent() + { + // Arrange + const long timestamp = 1234567890L; + + // Act + var ulid = Ulid.MinAt(timestamp); + + // Assert + // Last 10 bytes (random part) should be 0 + Assert.All(ulid.AsByteSpan()[^10..].ToArray(), x => Assert.Equal(0, x)); + Assert.Equal(timestamp, ulid.Time.ToUnixTimeMilliseconds()); + } + + [Fact] + public void MinAt_WithDateTimeOffset_ShouldHaveZeroRandomComponent() + { + // Arrange + var dto = DateTimeOffset.UtcNow; + + // Act + var ulid = Ulid.MinAt(dto); + + // Assert + // Last 10 bytes (random part) should be 0 + Assert.All(ulid.AsByteSpan()[^10..].ToArray(), x => Assert.Equal(0, x)); + Assert.Equal(dto.ToUnixTimeMilliseconds(), ulid.Time.ToUnixTimeMilliseconds()); + } + + [Fact] + public void MaxAt_WithLongTimestamp_ShouldHaveMaxRandomComponent() + { + // Arrange + const long timestamp = 1234567890L; + + // Act + var ulid = Ulid.MaxAt(timestamp); + + // Assert + // Last 10 bytes (random part) should be 0xFF + Assert.All(ulid.AsByteSpan()[^10..].ToArray(), x => Assert.Equal(0xFF, x)); + Assert.Equal(timestamp, ulid.Time.ToUnixTimeMilliseconds()); + } + + [Fact] + public void MaxAt_WithDateTimeOffset_ShouldHaveMaxRandomComponent() + { + // Arrange + var dto = DateTimeOffset.UtcNow; + + // Act + var ulid = Ulid.MaxAt(dto); + + // Assert + // Last 10 bytes (random part) should be 0xFF + Assert.All(ulid.AsByteSpan()[^10..].ToArray(), x => Assert.Equal(0xFF, x)); + Assert.Equal(dto.ToUnixTimeMilliseconds(), ulid.Time.ToUnixTimeMilliseconds()); + } +} \ No newline at end of file diff --git a/src/ByteAether.Ulid.Tests/Ulid.Tests.cs b/src/ByteAether.Ulid.Tests/Ulid.Tests.cs index 92e0db3..c383ba1 100644 --- a/src/ByteAether.Ulid.Tests/Ulid.Tests.cs +++ b/src/ByteAether.Ulid.Tests/Ulid.Tests.cs @@ -3,17 +3,6 @@ namespace ByteAether.Ulid.Tests; public class UlidTests { - [Fact] - public void EmptyUlid_ShouldBeDefault() - { - // Arrange - var ulid = Ulid.Empty; - - // Assert - Assert.Equal(default, ulid); - Assert.Equal(new byte[16], ulid.ToByteArray()); - } - private static readonly byte[] _sampleUlidBytes = [ // Timestamp (6 bytes) @@ -128,4 +117,4 @@ private static Ulid CreateUlid(byte[] bytes) return ulid; } -} +} \ No newline at end of file diff --git a/src/ByteAether.Ulid/PACKAGE.md b/src/ByteAether.Ulid/PACKAGE.md index be3b843..4d66cb4 100644 --- a/src/ByteAether.Ulid/PACKAGE.md +++ b/src/ByteAether.Ulid/PACKAGE.md @@ -86,39 +86,49 @@ Generates a new ULID using the specified Unix timestamp in milliseconds (`long`) Creates a ULID from an existing byte array. - `Ulid.New(Guid guid)`\ Create from existing `Guid`. +- `Ulid.MinAt(DateTimeOffset datetime)`\ +Creates the minimum possible ULID value for the specified `DateTimeOffset`. +- `Ulid.MinAt(long timestamp)`\ +Creates the minimum possible ULID value for the specified Unix timestamp in milliseconds (`long`). +- `Ulid.MaxAt(DateTimeOffset datetime)`\ +Creates the maximum possible ULID value for the specified `DateTimeOffset`. +- `Ulid.MaxAt(long timestamp)`\ +Creates the maximum possible ULID value for the specified Unix timestamp in milliseconds (`long`). ### Checking Validity - `Ulid.IsValid(string ulidString)`\ - Validates if the given string is a valid ULID. +Validates if the given string is a valid ULID. - `Ulid.IsValid(ReadOnlySpan ulidString)`\ - Validates if the given span of characters is a valid ULID. +Validates if the given span of characters is a valid ULID. - `Ulid.IsValid(ReadOnlySpan ulidBytes)`\ - Validates if the given byte array represents a valid ULID. +Validates if the given byte array represents a valid ULID. ### Parsing - `Ulid.Parse(ReadOnlySpan chars, IFormatProvider? provider = null)`\ - Parses a ULID from a character span in canonical format. The `IFormatProvider` is ignored. +Parses a ULID from a character span in canonical format. The `IFormatProvider` is ignored. - `Ulid.TryParse(ReadOnlySpan s, IFormatProvider? provider, out Ulid result)`\ - Tries to parse a ULID from a character span in canonical format. Returns `true` if successful. +Tries to parse a ULID from a character span in canonical format. Returns `true` if successful. - `Ulid.Parse(string s, IFormatProvider? provider = null)`\ - Parses a ULID from a string in canonical format. The `IFormatProvider` is ignored. +Parses a ULID from a string in canonical format. The `IFormatProvider` is ignored. - `Ulid.TryParse(string? s, IFormatProvider? provider, out Ulid result)`\ - Tries to parse a ULID from a string in canonical format. Returns `true` if successful. +Tries to parse a ULID from a string in canonical format. Returns `true` if successful. ### Properties - `Ulid.Empty`\ - Represents an empty ULID, equivalent to `default(Ulid)` and `Ulid.New(new byte[16])`. +Represents an empty ULID, equivalent to `default(Ulid)` and `Ulid.New(new byte[16])`. +- `Ulid.Max`\ +Represents the maximum possible value for a ULID (all bytes set to `0xFF`). - `Ulid.DefaultGenerationOptions`\ - Default configuration for ULID generation when no options are provided by the `Ulid.New(...)` call. +Default configuration for ULID generation when no options are provided by the `Ulid.New(...)` call. - `.Time`\ - Gets the timestamp component of the ULID as a `DateTimeOffset`. +Gets the timestamp component of the ULID as a `DateTimeOffset`. - `.TimeBytes`\ - Gets the time component of the ULID as a `ReadOnlySpan`. +Gets the time component of the ULID as a `ReadOnlySpan`. - `.Random`\ - Gets the random component of the ULID as a `ReadOnlySpan`. +Gets the random component of the ULID as a `ReadOnlySpan`. ### Conversion Methods diff --git a/src/ByteAether.Ulid/Ulid.Boundaries.cs b/src/ByteAether.Ulid/Ulid.Boundaries.cs new file mode 100644 index 0000000..98c0cdf --- /dev/null +++ b/src/ByteAether.Ulid/Ulid.Boundaries.cs @@ -0,0 +1,53 @@ +namespace ByteAether.Ulid; + +public partial struct Ulid +{ + private static readonly byte[] _randomMin = Enumerable.Repeat((byte)0x00, _ulidSizeRandom).ToArray(); + private static readonly byte[] _randomMax = Enumerable.Repeat((byte)0xFF, _ulidSizeRandom).ToArray(); + + /// + /// Represents an empty ULID value. + /// + /// + /// The field is a ULID with all components set to zero. + /// It can be used as a default or placeholder value. + /// + public static readonly Ulid Empty = default; + + /// + /// Represents the maximum possible value for a ULID. + /// + /// + /// The field is a ULID where all byte components are set to their highest possible value (0xFF). + /// It can be used as a sentinel or boundary value in comparison operations or range validations. + /// + public static readonly Ulid Max = New(Enumerable.Repeat((byte)0xFF, _ulidSize).ToArray()); + + /// + /// Creates the minimum possible value for the specified timestamp. + /// + /// The timestamp used to create the minimum value. + /// The minimum value for the given timestamp. + public static Ulid MinAt(long timestamp) => New(timestamp, _randomMin); + + /// + /// Creates the minimum possible value for the specified timestamp. + /// + /// The timestamp used to create the minimum value. + /// The minimum value for the given timestamp. + public static Ulid MinAt(DateTimeOffset datetime) => New(datetime, _randomMin); + + /// + /// Creates the maximum possible value for the specified timestamp. + /// + /// The timestamp used to create the maximum value. + /// The maximum value for the given timestamp. + public static Ulid MaxAt(long timestamp) => New(timestamp, _randomMax); + + /// + /// Creates the maximum possible value for the specified timestamp. + /// + /// The timestamp used to create the maximum value. + /// The maximum value for the given timestamp. + public static Ulid MaxAt(DateTimeOffset datetime) => New(datetime, _randomMax); +} \ No newline at end of file diff --git a/src/ByteAether.Ulid/Ulid.cs b/src/ByteAether.Ulid/Ulid.cs index 07e5ba0..6745c32 100644 --- a/src/ByteAether.Ulid/Ulid.cs +++ b/src/ByteAether.Ulid/Ulid.cs @@ -26,15 +26,6 @@ public readonly partial struct Ulid private const byte _ulidSizeRandom = 10; private const byte _ulidSize = _ulidSizeTime + _ulidSizeRandom; - /// - /// Represents an empty ULID value. - /// - /// - /// The field is a ULID with all components set to zero. - /// It can be used as a default or placeholder value. - /// - public static readonly Ulid Empty = default; - [FieldOffset(00)] private readonly byte _t0; [FieldOffset(01)] private readonly byte _t1; [FieldOffset(02)] private readonly byte _t2;