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
6 changes: 6 additions & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

* [All] Add support for `Guid.CreateVersion7()` and `Guid.CreateVersion7(DateTimeOffset)`

### Fixed

* [JS/TS] Fix `Guid` to use cryptographically strong random values (by @ncave)
* [Python] Fix `DateTimeOffset` millisecond constructor and property (by @ncave)
* [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist)
* [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index

Expand Down
6 changes: 6 additions & 0 deletions src/Fable.Compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

* [All] Add support for `Guid.CreateVersion7()` and `Guid.CreateVersion7(DateTimeOffset)`

### Fixed

* [JS/TS] Fix `Guid` to use cryptographically strong random values (by @ncave)
* [Python] Fix `DateTimeOffset` millisecond constructor and property (by @ncave)
* [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist)
* [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index

Expand Down
1 change: 1 addition & 0 deletions src/Fable.Transforms/Beam/Replacements.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3877,6 +3877,7 @@ let private guids
Helper.LibCall(com, "fable_guid", "from_bytes", t, [ arg ], ?loc = r) |> Some
| _ -> None
| "NewGuid" -> Helper.LibCall(com, "fable_guid", "new_guid", t, [], ?loc = r) |> Some
| "CreateVersion7" -> Helper.LibCall(com, "fable_guid", "create_version7", t, args, ?loc = r) |> Some
| "Parse" ->
match args with
| [ StringConst literalGuid ] ->
Expand Down
3 changes: 3 additions & 0 deletions src/Fable.Transforms/Dart/Replacements.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3450,6 +3450,9 @@ let guids

match i.CompiledName with
| "NewGuid" -> Helper.LibCall(com, "Guid", "newGuid", t, []) |> Some
| "CreateVersion7" ->
Helper.LibCall(com, "Guid", "createVersion7", t, args, i.SignatureArgTypes)
|> Some
| "Parse" ->
match args with
| [ StringConst literalGuid ] -> parseGuid literalGuid
Expand Down
3 changes: 3 additions & 0 deletions src/Fable.Transforms/Python/Replacements.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3494,6 +3494,9 @@ let guids
=
match i.CompiledName with
| "NewGuid" -> Helper.LibCall(com, "guid", "new_guid", t, []) |> Some
| "CreateVersion7" ->
Helper.LibCall(com, "guid", "create_version7", t, args, i.SignatureArgTypes)
|> Some
| "Parse" -> Helper.LibCall(com, "guid", "parse", t, args, i.SignatureArgTypes) |> Some
| "TryParse" -> Helper.LibCall(com, "guid", "try_parse", t, args, i.SignatureArgTypes) |> Some
| "ToByteArray" ->
Expand Down
3 changes: 3 additions & 0 deletions src/Fable.Transforms/Replacements.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3822,6 +3822,9 @@ let guids

match i.CompiledName with
| "NewGuid" -> Helper.LibCall(com, "Guid", "newGuid", t, []) |> Some
| "CreateVersion7" ->
Helper.LibCall(com, "Guid", "createVersion7", t, args, i.SignatureArgTypes)
|> Some
| "Parse" ->
match args with
| [ StringConst literalGuid ] -> parseGuid literalGuid
Expand Down
4 changes: 4 additions & 0 deletions src/Fable.Transforms/Rust/Replacements.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3121,6 +3121,10 @@ let guids
| _ -> None
// | "Empty", None, [] -> // it's a static field, see tryField
| "NewGuid", None, [] -> Helper.LibCall(com, "Guid", "new_guid", t, args, ?loc = r) |> Some
| "CreateVersion7", None, [] -> Helper.LibCall(com, "Guid", "create_version7", t, [], ?loc = r) |> Some
| "CreateVersion7", None, _ ->
Helper.LibCall(com, "Guid", "create_version7_with_timestamp", t, args, ?loc = r)
|> Some
| "Parse", None, [ ExprType String ] -> Helper.LibCall(com, "Guid", "parse", t, args, ?loc = r) |> Some
| "TryParse", None, [ ExprType String; _ ] -> Helper.LibCall(com, "Guid", "tryParse", t, args, ?loc = r) |> Some
| "ToByteArray", Some x, [] -> Helper.LibCall(com, "Guid", "toByteArray", t, [ x ], ?loc = r) |> Some
Expand Down
36 changes: 35 additions & 1 deletion src/fable-library-beam/fable_guid.erl
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
-module(fable_guid).
-export([
new_guid/0, parse/1, try_parse/1, try_parse/2, from_bytes/1, to_byte_array/1, to_string_format/2
new_guid/0, create_version7/0, create_version7/1,
parse/1, try_parse/1, try_parse/2, from_bytes/1, to_byte_array/1, to_string_format/2
]).

-spec new_guid() -> binary().
-spec create_version7() -> binary().
-spec create_version7(integer()) -> binary().
-spec parse(binary()) -> binary().
-spec try_parse(binary()) -> {boolean(), binary()}.
-spec try_parse(binary(), reference()) -> boolean().
Expand All @@ -26,6 +29,37 @@ new_guid() ->
)
).

%% Generate a new UUID v7 (RFC 9562) with current timestamp.
create_version7() ->
Ms = erlang:system_time(millisecond),
create_version7(Ms).

%% Generate a new UUID v7 (RFC 9562) with given DateTimeOffset or ms since Unix epoch.
create_version7({Ticks, _Kind, _Offset}) ->
%% DateTimeOffset tuple: convert .NET ticks to Unix epoch milliseconds
%% .NET ticks are 100ns intervals since 0001-01-01
%% Unix epoch in .NET ticks = 621355968000000000
Ms = (Ticks - 621355968000000000) div 10000,
create_version7(Ms);
create_version7(Timestamp) when is_integer(Timestamp) ->
<<RandA0:16, RandB0:64>> = crypto:strong_rand_bytes(10),
RandA = RandA0 band 16#0FFF,
RandB = RandB0 band 16#3FFFFFFFFFFFFFFF,
%% 48-bit timestamp split into A (high 32 bits) and B (low 16 bits)
A = (Timestamp bsr 16) band 16#FFFFFFFF,
B = Timestamp band 16#FFFF,
%% Version 7: top 4 bits = 0111
C = 16#7000 bor RandA,
%% Variant: top 2 bits = 10
D = 16#8000 bor ((RandB bsr 48) band 16#3FFF),
E = RandB band 16#FFFFFFFFFFFF,
list_to_binary(
io_lib:format(
"~8.16.0b-~4.16.0b-~4.16.0b-~4.16.0b-~12.16.0b",
[A, B, C, D, E]
)
).

%% Construct a GUID from a 16-byte array (binary or list of integers).
%% .NET uses mixed-endian: first 3 groups are little-endian, last 2 are big-endian.
from_bytes({byte_array, _, _} = BA) ->
Expand Down
12 changes: 11 additions & 1 deletion src/fable-library-py/fable_library/date_offset.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def create(
ms = 0

if python_offset is None:
dt = datetime(year, month, day, h, m, s, ms)
dt = datetime(year, month, day, h, m, s, ms * 1000)
if offset is not None:
offset_ms = int(time_span.total_microseconds(offset) / 1000)
python_offset = timedelta(microseconds=float(time_span.total_microseconds(offset)))
Expand Down Expand Up @@ -207,6 +207,14 @@ def second(d: DateTimeOffset) -> int:
return d.second


def millisecond(d: DateTimeOffset) -> int:
return d.microsecond // 1000


def microsecond(d: DateTimeOffset) -> int:
return d.microsecond % 1000


def op_subtraction(x: DateTimeOffset, y: DateTimeOffset | TimeSpan) -> DateTimeOffset | TimeSpan:
if isinstance(y, TimeSpan):
# Subtract TimeSpan from DateTimeOffset
Expand All @@ -231,6 +239,8 @@ def op_subtraction(x: DateTimeOffset, y: DateTimeOffset | TimeSpan) -> DateTimeO
"op_subtraction",
"parse",
"second",
"millisecond",
"microsecond",
"try_parse",
"utc_now",
"year",
Expand Down
33 changes: 33 additions & 0 deletions src/fable-library-py/fable_library/guid.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import secrets
import time
import uuid

from .array_ import Array
from .core import FSharpRef, byte
from .date_offset import DateTimeOffset


def parse(string: str) -> uuid.UUID:
Expand Down Expand Up @@ -32,8 +35,38 @@ def array_to_guid(guid: Array[byte]) -> uuid.UUID:
return uuid.UUID(bytes_le=bytes(guid))


def create_version7(timestamp: DateTimeOffset | None = None) -> uuid.UUID:
"""Create a UUID v7 per RFC 9562."""
if timestamp is None:
ms = int(time.time() * 1000)
else:
# DateTimeOffset has getTime() returning ms since epoch
ms = int(timestamp.getTime())

# Build 16 bytes per RFC 9562
rand_bytes = secrets.token_bytes(10)

# Bytes 0-5: 48-bit big-endian ms timestamp
ts_bytes = ms.to_bytes(6, byteorder="big")

# Bytes 6-7: version (0111) + 12 bits rand_a
rand_a = int.from_bytes(rand_bytes[0:2], "big")
ver_rand_a = (0x7000 | (rand_a & 0x0FFF)).to_bytes(2, "big")

# Bytes 8-9: variant (10) + 14 bits rand_b
rand_b_hi = int.from_bytes(rand_bytes[2:4], "big")
var_rand_b = (0x8000 | (rand_b_hi & 0x3FFF)).to_bytes(2, "big")

# Bytes 10-15: 48 bits rand_b continued
rand_rest = rand_bytes[4:10]

all_bytes = ts_bytes + ver_rand_a + var_rand_b + rand_rest
return uuid.UUID(bytes=bytes(all_bytes))


__all__ = [
"array_to_guid",
"create_version7",
"guid_to_array",
"new_guid",
"parse",
Expand Down
4 changes: 2 additions & 2 deletions src/fable-library-rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ rust_decimal = { version = "1.40", features = ["maths"], default-features = fals
startup = { version = "0.1", path = "vendored/startup", optional = true }

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
uuid = { version = "1.22", features = ["v4"], default-features = false, optional = true }
uuid = { version = "1.23", features = ["v4", "v7"], default-features = false, optional = true }

[target.'cfg(target_arch = "wasm32")'.dependencies]
uuid = { version = "1.22", features = ["v4", "js"], default-features = false, optional = true }
uuid = { version = "1.23", features = ["v4", "v7", "js"], default-features = false, optional = true }
4 changes: 4 additions & 0 deletions src/fable-library-rust/src/DateTimeOffset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ pub mod DateTimeOffset_ {
self.0.timestamp()
}

pub fn toUnixTimeSubsecNanos(&self) -> u32 {
self.0.timestamp_subsec_nanos()
}

pub fn year(&self) -> i32 {
self.0.year()
}
Expand Down
15 changes: 14 additions & 1 deletion src/fable-library-rust/src/Guid.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#[cfg(feature = "guid")]
pub mod Guid_ {
use crate::DateTimeOffset_::DateTimeOffset;
use crate::NativeArray_::{new_array, Array};
use crate::Native_::{compare, MutCell};
use crate::String_::{string, toString};
use uuid::Uuid;
use uuid::{NoContext, Timestamp, Uuid};

#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct Guid(Uuid);
Expand Down Expand Up @@ -52,4 +53,16 @@ pub mod Guid_ {
pub fn toByteArray(x: Guid) -> Array<u8> {
new_array(&x.0.to_bytes_le())
}

pub fn create_version7() -> Guid {
let dto = DateTimeOffset::utcNow();
create_version7_with_timestamp(dto)
}

pub fn create_version7_with_timestamp(dto: DateTimeOffset) -> Guid {
let seconds = dto.toUnixTimeSeconds() as u64;
let subsec_nanos = dto.toUnixTimeSubsecNanos();
let ts = Timestamp::from_unix(NoContext, seconds, subsec_nanos);
Guid(Uuid::new_v7(ts))
}
}
39 changes: 31 additions & 8 deletions src/fable-library-ts/Guid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,38 @@ export function tryParse(str: string, defValue: FSharpRef<string>): boolean {
}
}

// From https://gist.github.com/LeverOne/1308368
export function newGuid() {
let b = "";
for (let a = 0; a++ < 36;) {
b += a * 51 & 52
? (a ^ 15 ? 8 ^ Math.random() * (a ^ 20 ? 16 : 4) : 4).toString(16)
: "-";
}
return b;
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
const hex = Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

// RFC 9562 UUID v7
export function createVersion7(timestamp?: Date): string {
const ms = timestamp != null ? timestamp.getTime() : Date.now();

// 48-bit timestamp as hex
const msHex = Math.floor(ms).toString(16).padStart(12, "0");

// random bits
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
const view = new DataView(bytes.buffer);
const randA = view.getUint16(0) & 0x0fff; // 12 bits
const randB1 = view.getUint16(2) & 0x3fff; // 14 bits
const randB2 = view.getUint32(4); // 32 bits
const randB3 = view.getUint16(8); // 16 bits

const timeLow = msHex.slice(0, 8);
const timeMid = msHex.slice(8, 12);
const ver = (0x7000 | randA).toString(16).padStart(4, "0");
const variantAndRandB = (0x8000 | randB1).toString(16).padStart(4, "0");
const node = randB2.toString(16).padStart(8, "0") + randB3.toString(16).padStart(4, "0");

return `${timeLow}-${timeMid}-${ver}-${variantAndRandB}-${node}`;
}

// Maps for number <-> hex string conversion
Expand Down
2 changes: 2 additions & 0 deletions tests/Beam/DateTimeOffsetTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ let ``test DateTimeOffset constructors work`` () =
d.Hour |> equal 13
d.Minute |> equal 23
d.Second |> equal 30
let d2 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 500, TimeSpan.Zero)
d2.Second + d2.Millisecond |> equal 530

[<Fact>]
let ``test DateTimeOffset.Offset works`` () =
Expand Down
31 changes: 31 additions & 0 deletions tests/Beam/GuidTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,37 @@ let ``test Guid.NewGuid creates different guids`` () =
let g2 = Guid.NewGuid()
g = g2 |> equal false

[<Fact>]
let ``test Guid.CreateVersion7 works`` () =
let g1 = Guid.CreateVersion7()
let g2 = Guid.CreateVersion7()
g1 = g2 |> equal false
let s1 = string g1
equal 36 s1.Length
equal '7' s1[14]
let variantChar = s1[19]
(variantChar = '8' || variantChar = '9' || variantChar = 'a' || variantChar = 'b') |> equal true

[<Fact>]
let ``test Guid.CreateVersion7 with DateTimeOffset works`` () =
let dto = DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)
let g1 = Guid.CreateVersion7(dto)
let g2 = Guid.CreateVersion7(dto)
g1 = g2 |> equal false
let s1 = string g1
equal 36 s1.Length
equal '7' s1[14]
let variantChar = s1[19]
(variantChar = '8' || variantChar = '9' || variantChar = 'a' || variantChar = 'b') |> equal true

[<Fact>]
let ``test Guid.CreateVersion7 is monotonic`` () =
[1 .. 100]
|> List.map (fun ms -> DateTimeOffset(2024, 1, 1, 0, 0, 0, ms, TimeSpan.Zero))
|> List.map (fun dto -> Guid.CreateVersion7(dto))
|> List.pairwise
|> List.iter (fun (g1, g2) -> g1 < g2 |> equal true)

[<Fact>]
let ``test Guid equality works`` () =
let s = "f46d24f0-183e-4fca-9f47-e382afdfde8b"
Expand Down
30 changes: 30 additions & 0 deletions tests/Js/Main/TypeTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,36 @@ let tests =
let g3 = Guid.Parse s1
g1 = g3 |> equal true

testCase "Guid.CreateVersion7 works" <| fun () ->
let g1 = Guid.CreateVersion7()
let g2 = Guid.CreateVersion7()
g1 = g2 |> equal false
let s1 = string g1
equal 36 s1.Length
// Check version nibble is '7'
equal '7' s1[14]
// Check variant nibble is 8, 9, a, or b
let variantChar = s1[19]
(variantChar = '8' || variantChar = '9' || variantChar = 'a' || variantChar = 'b') |> equal true

testCase "Guid.CreateVersion7 with DateTimeOffset works" <| fun () ->
let dto = DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)
let g1 = Guid.CreateVersion7(dto)
Comment thread
OnurGumus marked this conversation as resolved.
Resolved
let g2 = Guid.CreateVersion7(dto)
g1 = g2 |> equal false
let s1 = string g1
equal 36 s1.Length
equal '7' s1[14]
let variantChar = s1[19]
(variantChar = '8' || variantChar = '9' || variantChar = 'a' || variantChar = 'b') |> equal true

testCase "Guid.CreateVersion7 is monotonic" <| fun () ->
[1 .. 100]
|> List.map (fun ms -> DateTimeOffset(2024, 1, 1, 0, 0, 0, ms, TimeSpan.Zero))
|> List.map (fun dto -> Guid.CreateVersion7(dto))
|> List.pairwise
|> List.iter (fun (g1, g2) -> g1 < g2 |> equal true)

testCase "Guid.Empty works" <| fun () ->
let g1 = Guid.Empty
string g1 |> equal "00000000-0000-0000-0000-000000000000"
Expand Down
Loading
Loading