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
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<Entity>> 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.
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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`\
Expand Down
87 changes: 87 additions & 0 deletions src/ByteAether.Ulid.Tests/Ulid.Boundaries.Tests.cs
Original file line number Diff line number Diff line change
@@ -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());
}
}
13 changes: 1 addition & 12 deletions src/ByteAether.Ulid.Tests/Ulid.Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -128,4 +117,4 @@ private static Ulid CreateUlid(byte[] bytes)

return ulid;
}
}
}
34 changes: 22 additions & 12 deletions src/ByteAether.Ulid/PACKAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<char> 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<byte> 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<char> 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<char> 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<byte>`.
Gets the time component of the ULID as a `ReadOnlySpan<byte>`.
- `.Random`\
Gets the random component of the ULID as a `ReadOnlySpan<byte>`.
Gets the random component of the ULID as a `ReadOnlySpan<byte>`.

### Conversion Methods

Expand Down
53 changes: 53 additions & 0 deletions src/ByteAether.Ulid/Ulid.Boundaries.cs
Original file line number Diff line number Diff line change
@@ -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();

/// <summary>
/// Represents an empty ULID value.
/// </summary>
/// <remarks>
/// The <see cref="Empty"/> field is a ULID with all components set to zero.
/// It can be used as a default or placeholder value.
/// </remarks>
public static readonly Ulid Empty = default;

/// <summary>
/// Represents the maximum possible value for a ULID.
/// </summary>
/// <remarks>
/// The <see cref="Max"/> 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.
/// </remarks>
public static readonly Ulid Max = New(Enumerable.Repeat((byte)0xFF, _ulidSize).ToArray());

/// <summary>
/// Creates the minimum possible <see cref="Ulid"/> value for the specified timestamp.
/// </summary>
/// <param name="timestamp">The timestamp used to create the minimum <see cref="Ulid"/> value.</param>
/// <returns>The minimum <see cref="Ulid"/> value for the given timestamp.</returns>
public static Ulid MinAt(long timestamp) => New(timestamp, _randomMin);

/// <summary>
/// Creates the minimum possible <see cref="Ulid"/> value for the specified timestamp.
/// </summary>
/// <param name="datetime">The timestamp used to create the minimum <see cref="Ulid"/> value.</param>
/// <returns>The minimum <see cref="Ulid"/> value for the given timestamp.</returns>
public static Ulid MinAt(DateTimeOffset datetime) => New(datetime, _randomMin);

/// <summary>
/// Creates the maximum possible <see cref="Ulid"/> value for the specified timestamp.
/// </summary>
/// <param name="timestamp">The timestamp used to create the maximum <see cref="Ulid"/> value.</param>
/// <returns>The maximum <see cref="Ulid"/> value for the given timestamp.</returns>
public static Ulid MaxAt(long timestamp) => New(timestamp, _randomMax);

/// <summary>
/// Creates the maximum possible <see cref="Ulid"/> value for the specified timestamp.
/// </summary>
/// <param name="datetime">The timestamp used to create the maximum <see cref="Ulid"/> value.</param>
/// <returns>The maximum <see cref="Ulid"/> value for the given timestamp.</returns>
public static Ulid MaxAt(DateTimeOffset datetime) => New(datetime, _randomMax);
}
9 changes: 0 additions & 9 deletions src/ByteAether.Ulid/Ulid.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,6 @@ public readonly partial struct Ulid
private const byte _ulidSizeRandom = 10;
private const byte _ulidSize = _ulidSizeTime + _ulidSizeRandom;

/// <summary>
/// Represents an empty ULID value.
/// </summary>
/// <remarks>
/// The <see cref="Empty"/> field is a ULID with all components set to zero.
/// It can be used as a default or placeholder value.
/// </remarks>
public static readonly Ulid Empty = default;

[FieldOffset(00)] private readonly byte _t0;
[FieldOffset(01)] private readonly byte _t1;
[FieldOffset(02)] private readonly byte _t2;
Expand Down