Files
Wes Sleeman e715c80a17 TimeSpan-Style TimeDuration Constructors in C# Bindings (#2778)
# Description of Changes

This change adds the following `System.TimeSpan`-style static
construction methods to `SpacetimeDB.TimeDuration`:
* `static TimeDuration FromMilliseconds(double milliseconds)`
* `static TimeDuration FromSeconds(double seconds)`
* `static TimeDuration FromMinutes(double minutes)`
* `static TimeDuration FromHours(double hours)`
* `static TimeDuration FromDays(double days)`

These mirror the equivalently named static methods on `System.TimeSpan`
and dramatically improve usability and familiarity for experienced C#
users with no more overhead than the user performing the multiplication
themselves.

Wish I'd thought to do this before v1.1.2 got released. Ah well.

# API and ABI breaking changes

None. Convenience methods added in bindings only.

# Expected complexity level and risk

1 (potentially up to a low 2 if cleanup is desired elsewhere in the
bindings to leverage these new methods).

# Testing

<!-- Describe any testing you've done, and any testing you'd like your
reviewers to do,
so that you're confident that all the changes work as expected! -->

- [x] Ensure the changes build. <!-- maybe a test you want to do -->
- [ ] New contributor check! Review to make sure repo style & substance
standards are complied with. <!-- maybe a test you want a reviewer to
do, so they can check it off when they're satisfied. -->
2025-08-15 09:27:36 -07:00

608 lines
22 KiB
C#

namespace SpacetimeDB;
using System.Diagnostics;
using System.Runtime.InteropServices;
using SpacetimeDB.BSATN;
internal static class Util
{
public static Span<byte> AsBytes<T>(ref T val)
where T : struct => MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref val, 1));
/// <summary>
/// Convert this object to a BIG-ENDIAN hex string.
///
/// Big endian is almost always the correct convention here. It puts the most significant bytes
/// of the number at the lowest indexes of the resulting string; assuming the string is printed
/// with low indexes to the left, this will result in the correct hex number being displayed.
///
/// (This might be wrong if the string is printed after, say, a unicode right-to-left marker.
/// But, well, what can you do.)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="val"></param>
/// <returns></returns>
public static string ToHexBigEndian<T>(T val)
where T : struct
{
var bytes = AsBytes(ref val);
// If host is little-endian, reverse the bytes.
// Note that this reverses our stack copy of `val`, not the original value, and doesn't require heap `byte[]` allocation.
if (BitConverter.IsLittleEndian)
{
bytes.Reverse();
}
#if NET5_0_OR_GREATER
return Convert.ToHexString(bytes);
#else
// Similar to `Convert.ToHexString`, but that method is not available in .NET Standard
// which we need to target for Unity support.
return BitConverter.ToString(bytes.ToArray()).Replace("-", "");
#endif
}
/// <summary>
/// Convert the passed byte array to a value of type T, optionally reversing it before performing the conversion.
/// If the input is not reversed, it is treated as having the native endianness of the host system.
/// (The endianness of the host system can be checked via System.BitConverter.IsLittleEndian.)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="source"></param>
/// <param name="littleEndian"></param>
/// <returns></returns>
public static T Read<T>(ReadOnlySpan<byte> source, bool littleEndian)
where T : struct
{
Debug.Assert(
source.Length == Marshal.SizeOf<T>(),
$"Error while reading ${typeof(T).FullName}: expected source span to be {Marshal.SizeOf<T>()} bytes long, but was {source.Length} bytes."
);
var result = MemoryMarshal.Read<T>(source);
if (littleEndian != BitConverter.IsLittleEndian)
{
AsBytes(ref result).Reverse();
}
return result;
}
/// <summary>
/// Convert a hex string to a byte array.
/// </summary>
/// <param name="hex"></param>
/// <returns></returns>
public static byte[] StringToByteArray(string hex)
{
#if NET5_0_OR_GREATER
return Convert.FromHexString(hex);
#else
// Manual implementation for .NET Standard compatibility.
Debug.Assert(
hex.Length % 2 == 0,
$"Expected input string (\"{hex}\") to be of even length"
);
var NumberChars = hex.Length;
var bytes = new byte[NumberChars / 2];
for (var i = 0; i < NumberChars; i += 2)
{
bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16);
}
return bytes;
#endif
}
// Similarly, we need some constants that are not available in .NET Standard.
public const long TicksPerMicrosecond = 10;
public const long MicrosecondsPerSecond = 1_000_000;
}
// The following types are "special" types: they has a special (Ref-less) AlgebraicType representations.
// See `spacetimedb-sats::AlgebraicType::is_valid_for_client_type_[use|generate]` for more information.
// We don't use [Type] here; instead we manually implement the serialization stuff that would be generated by
// [Type] so that we can override GetAlgebraicType to return types in a special, Ref-less form.
public readonly partial struct Unit
{
public readonly struct BSATN : IReadWrite<Unit>
{
public Unit Read(BinaryReader reader) => default;
public void Write(BinaryWriter writer, Unit value) { }
public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) =>
// Return a Product directly, not a Ref, because this is a special type.
new AlgebraicType.Product([]);
}
}
[StructLayout(LayoutKind.Sequential)]
public readonly record struct ConnectionId
: IEquatable<ConnectionId>,
IComparable,
IComparable<ConnectionId>
{
private readonly U128 value;
internal ConnectionId(U128 v) => value = v;
/// <summary>
/// Create a ConnectionId from a LITTLE-ENDIAN byte array.
///
/// If you are parsing a ConnectionId from a string, you probably want FromHexString instead,
/// or, failing that, FromBigEndian.
///
/// Returns null if the resulting ConnectionId is the default.
/// </summary>
/// <param name="bytes"></param>
public static ConnectionId? From(ReadOnlySpan<byte> bytes)
{
var id = Util.Read<ConnectionId>(bytes, littleEndian: true);
return id == default ? null : id;
}
/// <summary>
/// Create a ConnectionId from a BIG-ENDIAN byte array.
///
/// This method is the correct choice if you have converted the bytes of a hexadecimal-formatted ConnectionId
/// to a byte array in the following way:
///
/// "0xb0b1b2..."
/// ->
/// [0xb0, 0xb1, 0xb2, ...]
///
/// Returns null if the resulting ConnectionId is the default.
/// </summary>
/// <param name="bytes"></param>
public static ConnectionId? FromBigEndian(ReadOnlySpan<byte> bytes)
{
var id = Util.Read<ConnectionId>(bytes, littleEndian: false);
return id == default ? null : id;
}
/// <summary>
/// Create a ConnectionId from a hex string.
/// </summary>
/// <param name="hex"></param>
/// <returns></returns>
public static ConnectionId? FromHexString(string hex) =>
FromBigEndian(Util.StringToByteArray(hex));
public static ConnectionId Random()
{
var random = new Random();
var id = new ConnectionId();
random.NextBytes(Util.AsBytes(ref id));
return id;
}
// --- auto-generated ---
public readonly struct BSATN : IReadWrite<ConnectionId>
{
public ConnectionId Read(BinaryReader reader) =>
new(new SpacetimeDB.BSATN.U128Stdb().Read(reader));
public void Write(BinaryWriter writer, ConnectionId value) =>
new SpacetimeDB.BSATN.U128Stdb().Write(writer, value.value);
// --- / auto-generated ---
// --- customized ---
public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) =>
// Return a Product directly, not a Ref, because this is a special type.
new AlgebraicType.Product(
[
// Using this specific name here is important.
new("__connection_id__", new AlgebraicType.U128(default)),
]
);
// --- / customized ---
}
public override string ToString() => Util.ToHexBigEndian(value);
/// <inheritdoc cref="IComparable.CompareTo(object)" />
public int CompareTo(object? value)
{
if (value is ConnectionId other)
{
return CompareTo(other);
}
else if (value is null)
{
return 1;
}
else
{
throw new ArgumentException("Argument must be a ConnectionId", nameof(value));
}
}
/// <inheritdoc cref="IComparable{T}.CompareTo(T)" />
public int CompareTo(ConnectionId connectionId) => this.value.CompareTo(connectionId.value);
}
[StructLayout(LayoutKind.Sequential)]
public readonly record struct Identity : IEquatable<Identity>, IComparable, IComparable<Identity>
{
private readonly U256 value;
internal Identity(U256 val) => value = val;
/// <summary>
/// Create an Identity from a LITTLE-ENDIAN byte array.
///
/// If you are parsing an Identity from a string, you probably want FromHexString instead,
/// or, failing that, FromBigEndian.
/// </summary>
/// <param name="bytes"></param>
public Identity(ReadOnlySpan<byte> bytes) => this = From(bytes);
/// <summary>
/// Create an Identity from a LITTLE-ENDIAN byte array.
///
/// If you are parsing an Identity from a string, you probably want FromHexString instead,
/// or, failing that, FromBigEndian.
/// </summary>
/// <param name="bytes"></param>
public static Identity From(ReadOnlySpan<byte> bytes) =>
Util.Read<Identity>(bytes, littleEndian: true);
/// <summary>
/// Create an Identity from a BIG-ENDIAN byte array.
///
/// This method is the correct choice if you have converted the bytes of a hexadecimal-formatted `Identity`
/// to a byte array in the following way:
///
/// "0xb0b1b2..."
/// ->
/// [0xb0, 0xb1, 0xb2, ...]
/// </summary>
/// <param name="bytes"></param>
public static Identity FromBigEndian(ReadOnlySpan<byte> bytes) =>
Util.Read<Identity>(bytes, littleEndian: false);
/// <summary>
/// Create an Identity from a hex string.
/// </summary>
/// <param name="hex"></param>
/// <returns></returns>
public static Identity FromHexString(string hex) => FromBigEndian(Util.StringToByteArray(hex));
// --- auto-generated ---
public readonly struct BSATN : IReadWrite<Identity>
{
public Identity Read(BinaryReader reader) => new(new SpacetimeDB.BSATN.U256().Read(reader));
public void Write(BinaryWriter writer, Identity value) =>
new SpacetimeDB.BSATN.U256().Write(writer, value.value);
// --- / auto-generated ---
// --- customized ---
public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) =>
// Return a Product directly, not a Ref, because this is a special type.
new AlgebraicType.Product(
[
// Using this specific name here is important.
new("__identity__", new AlgebraicType.U256(default)),
]
);
// --- / customized ---
}
// This must be explicitly implemented, otherwise record will generate a new implementation.
public override string ToString() => Util.ToHexBigEndian(value);
/// <inheritdoc cref="IComparable.CompareTo(object)" />
public int CompareTo(object? value)
{
if (value is Identity other)
{
return CompareTo(other);
}
else if (value is null)
{
return 1;
}
else
{
throw new ArgumentException("Argument must be a Identity", nameof(value));
}
}
/// <inheritdoc cref="IComparable{T}.CompareTo(T)" />
public int CompareTo(Identity identity) => this.value.CompareTo(identity.value);
}
/// <summary>
/// A timestamp that represents a unique moment in time (in the Earth's reference frame).
///
/// This type may be converted to/from a DateTimeOffset, but the conversion can lose precision.
/// This type has less precision than DateTimeOffset (units of microseconds rather than units of 100ns).
/// </summary>
[StructLayout(LayoutKind.Sequential)] // we should be able to use it in FFI
public record struct Timestamp(long MicrosecondsSinceUnixEpoch)
: IStructuralReadWrite,
IComparable<Timestamp>
{
public static implicit operator DateTimeOffset(Timestamp t) =>
DateTimeOffset.UnixEpoch.AddTicks(t.MicrosecondsSinceUnixEpoch * Util.TicksPerMicrosecond);
public static implicit operator Timestamp(DateTimeOffset offset) =>
new Timestamp(offset.Subtract(DateTimeOffset.UnixEpoch).Ticks / Util.TicksPerMicrosecond);
// For backwards-compatibility.
public readonly DateTimeOffset ToStd() => this;
// Should be consistent with Rust implementation of Display.
public override readonly string ToString()
{
var date = ToStd();
return date.ToString("yyyy-MM-dd'T'HH:mm:ss.ffffffK");
}
public static readonly Timestamp UNIX_EPOCH = new(0);
public static Timestamp FromTimeDurationSinceUnixEpoch(TimeDuration timeDuration) =>
new Timestamp(timeDuration.Microseconds);
public readonly TimeDuration ToTimeDurationSinceUnixEpoch() => TimeDurationSince(UNIX_EPOCH);
public static Timestamp FromTimeSpanSinceUnixEpoch(TimeSpan timeSpan) =>
FromTimeDurationSinceUnixEpoch((TimeDuration)timeSpan);
public readonly TimeSpan ToTimeSpanSinceUnixEpoch() => (TimeSpan)ToTimeDurationSinceUnixEpoch();
public readonly TimeDuration TimeDurationSince(Timestamp earlier) =>
new TimeDuration(checked(MicrosecondsSinceUnixEpoch - earlier.MicrosecondsSinceUnixEpoch));
public static Timestamp operator +(Timestamp point, TimeDuration interval) =>
new Timestamp(checked(point.MicrosecondsSinceUnixEpoch + interval.Microseconds));
public static Timestamp operator -(Timestamp point, TimeDuration interval) =>
new Timestamp(checked(point.MicrosecondsSinceUnixEpoch - interval.Microseconds));
public int CompareTo(Timestamp that)
{
return this.MicrosecondsSinceUnixEpoch.CompareTo(that.MicrosecondsSinceUnixEpoch);
}
public static bool operator <(Timestamp l, Timestamp r)
{
return l.CompareTo(r) == -1;
}
public static bool operator >(Timestamp l, Timestamp r)
{
return l.CompareTo(r) == 1;
}
// --- auto-generated ---
public void ReadFields(BinaryReader reader)
{
MicrosecondsSinceUnixEpoch = BSATN.MicrosecondsSinceUnixEpoch.Read(reader);
}
public readonly void WriteFields(BinaryWriter writer)
{
BSATN.MicrosecondsSinceUnixEpoch.Write(writer, MicrosecondsSinceUnixEpoch);
}
readonly object IStructuralReadWrite.GetSerializer()
{
return new BSATN();
}
public readonly partial struct BSATN : IReadWrite<Timestamp>
{
internal static readonly I64 MicrosecondsSinceUnixEpoch = new();
public Timestamp Read(BinaryReader reader) => IStructuralReadWrite.Read<Timestamp>(reader);
public void Write(BinaryWriter writer, Timestamp value)
{
value.WriteFields(writer);
}
// --- / auto-generated ---
// --- customized ---
public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) =>
// Return a Product directly, not a Ref, because this is a special type.
new AlgebraicType.Product(
// Using this specific name here is important.
[new("__timestamp_micros_since_unix_epoch__", new AlgebraicType.I64(default))]
);
// --- / customized ---
}
}
/// <summary>
/// A duration that represents an interval between two events (in a particular reference frame).
///
/// This type may be converted to/from a TimeSpan, but the conversion can lose precision.
/// This type has less precision than TimeSpan (units of microseconds rather than units of 100ns).
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public record struct TimeDuration(long Microseconds) : IStructuralReadWrite
{
public static readonly TimeDuration ZERO = new(0);
/// <summary>
/// Returns a <see cref="TimeDuration"/> that represents a specified number of <paramref name="milliseconds"/>, accurate to the nearest microsecond.
/// </summary>
public static TimeDuration FromMilliseconds(double milliseconds) =>
new((long)(milliseconds * 1000L));
/// <summary>
/// Returns a <see cref="TimeDuration"/> that represents a specified number of <paramref name="seconds"/>, accurate to the nearest microsecond.
/// </summary>
public static TimeDuration FromSeconds(double seconds) =>
new((long)(seconds * Util.MicrosecondsPerSecond));
/// <summary>
/// Returns a <see cref="TimeDuration"/> that represents a specified number of 60-second <paramref name="minutes"/>.
/// </summary>
public static TimeDuration FromMinutes(double minutes) => FromSeconds(minutes * 60);
/// <summary>
/// Returns a <see cref="TimeDuration"/> that represents a specified number of 60-minute <paramref name="hours"/>.
/// </summary>
public static TimeDuration FromHours(double hours) => FromMinutes(hours * 60);
/// <summary>
/// Returns a <see cref="TimeDuration"/> that represents a specified number of 24-hour <paramref name="days"/>.
/// </summary>
public static TimeDuration FromDays(double days) => FromHours(days * 24);
public static implicit operator TimeSpan(TimeDuration d) =>
new(d.Microseconds * Util.TicksPerMicrosecond);
public static implicit operator TimeDuration(TimeSpan timeSpan) =>
new(timeSpan.Ticks / Util.TicksPerMicrosecond);
public static TimeDuration operator +(TimeDuration lhs, TimeDuration rhs) =>
new TimeDuration(checked(lhs.Microseconds + rhs.Microseconds));
public static TimeDuration operator -(TimeDuration lhs, TimeDuration rhs) =>
new TimeDuration(checked(lhs.Microseconds + rhs.Microseconds));
// For backwards-compatibility.
public readonly TimeSpan ToStd() => this;
// Should be consistent with Rust implementation of Display.
public override readonly string ToString()
{
var sign = Microseconds < 0 ? "-" : "+";
var pos = Math.Abs(Microseconds);
var secs = pos / Util.MicrosecondsPerSecond;
var microsRemaining = pos % Util.MicrosecondsPerSecond;
return $"{sign}{secs}.{microsRemaining:D6}";
}
// --- auto-generated ---
public void ReadFields(BinaryReader reader)
{
Microseconds = BSATN.__time_duration_micros__.Read(reader);
}
public readonly void WriteFields(BinaryWriter writer)
{
BSATN.__time_duration_micros__.Write(writer, Microseconds);
}
readonly object IStructuralReadWrite.GetSerializer()
{
return new BSATN();
}
public readonly partial struct BSATN : IReadWrite<TimeDuration>
{
internal static readonly I64 __time_duration_micros__ = new();
public TimeDuration Read(BinaryReader reader) =>
IStructuralReadWrite.Read<TimeDuration>(reader);
public void Write(BinaryWriter writer, TimeDuration value)
{
value.WriteFields(writer);
}
// --- customized ---
public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) =>
// Return a Product directly, not a Ref, because this is a special type.
new AlgebraicType.Product(
// Using this specific name here is important.
[new("__time_duration_micros__", new AlgebraicType.I64(default))]
);
// --- / customized ---
}
}
public partial record ScheduleAt : TaggedEnum<(TimeDuration Interval, Timestamp Time)>
{
public static implicit operator ScheduleAt(TimeDuration duration) => new Interval(duration);
public static implicit operator ScheduleAt(Timestamp time) => new Time(time);
public static implicit operator ScheduleAt(TimeSpan duration) => new Interval(duration);
public static implicit operator ScheduleAt(DateTimeOffset time) => new Time(time);
public static long ToMicroseconds(TimeSpan interval) => ((TimeDuration)interval).Microseconds;
public static TimeSpan TimeSpanFromMicroseconds(long intervalMicros) =>
(TimeSpan)(new TimeDuration(intervalMicros));
public static long ToMicrosecondsSinceUnixEpoch(DateTimeOffset time) =>
((Timestamp)time).MicrosecondsSinceUnixEpoch;
public static DateTimeOffset DateTimeOffsetFromMicrosSinceUnixEpoch(
long microsSinceUnixEpoch
) => (DateTimeOffset)(new Timestamp(microsSinceUnixEpoch));
// --- auto-generated ---
private ScheduleAt() { }
internal enum @enum : byte
{
Interval,
Time,
}
public sealed record Interval(TimeDuration Interval_) : ScheduleAt;
public sealed record Time(Timestamp Time_) : ScheduleAt;
public readonly partial struct BSATN : IReadWrite<ScheduleAt>
{
internal static readonly SpacetimeDB.BSATN.Enum<@enum> __enumTag = new();
internal static readonly TimeDuration.BSATN Interval = new();
internal static readonly Timestamp.BSATN Time = new();
public ScheduleAt Read(BinaryReader reader) =>
__enumTag.Read(reader) switch
{
@enum.Interval => new Interval(Interval.Read(reader)),
@enum.Time => new Time(Time.Read(reader)),
_ => throw new InvalidOperationException(
"Invalid tag value, this state should be unreachable."
),
};
public void Write(BinaryWriter writer, ScheduleAt value)
{
switch (value)
{
case Interval(var inner):
__enumTag.Write(writer, @enum.Interval);
Interval.Write(writer, inner);
break;
case Time(var inner):
__enumTag.Write(writer, @enum.Time);
Time.Write(writer, inner);
break;
}
}
// --- / auto-generated ---
// --- customized ---
public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) =>
// Return a Sum directly, not a Ref, because this is a special type.
new AlgebraicType.Sum(
[
// Using these specific names here is important.
new("Interval", Interval.GetAlgebraicType(registrar)),
new("Time", Time.GetAlgebraicType(registrar)),
]
);
// --- / customized ---
}
}