Skip to content

Output fractional seconds in timestamp.to_rfc3339 function #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jan 21, 2025
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
66 changes: 66 additions & 0 deletions src/gleam/time/timestamp.gleam
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import gleam/bit_array
import gleam/float
import gleam/int
import gleam/list
import gleam/order
import gleam/result
import gleam/string
Expand Down Expand Up @@ -207,6 +208,7 @@ pub fn to_rfc3339(timestamp: Timestamp, offset_minutes offset: Int) -> String {
let out = out <> n4(years) <> "-" <> n2(months) <> "-" <> n2(days)
let out = out <> "T"
let out = out <> n2(hours) <> ":" <> n2(minutes) <> ":" <> n2(seconds)
let out = out <> show_second_fraction(timestamp.nanoseconds)
case int.compare(offset, 0) {
order.Eq -> out <> "Z"
order.Gt -> out <> "+" <> n2(offset_hours) <> ":" <> n2(offset_minutes)
Expand Down Expand Up @@ -263,6 +265,70 @@ fn to_civil(minutes: Int) -> #(Int, Int, Int) {
#(year, month, day)
}

/// Converts nanoseconds into a `String` representation of fractional seconds.
///
/// Assumes that `nanoseconds < 1_000_000_000`, which will be true for any
/// normalised timestamp.
///
fn show_second_fraction(nanoseconds: Int) -> String {
case int.compare(nanoseconds, 0) {
// Zero fractional seconds are not shown.
order.Lt | order.Eq -> ""
order.Gt -> {
let second_fraction_part = {
nanoseconds
|> get_zero_padded_digits
|> remove_trailing_zeros
|> list.map(int.to_string)
|> string.join("")
}

"." <> second_fraction_part
}
}
}

/// Given a list of digits, return new list with any trailing zeros removed.
///
fn remove_trailing_zeros(digits: List(Int)) -> List(Int) {
let reversed_digits = list.reverse(digits)

do_remove_trailing_zeros(reversed_digits)
}

fn do_remove_trailing_zeros(reversed_digits) {
case reversed_digits {
[] -> []
[digit, ..digits] if digit == 0 -> do_remove_trailing_zeros(digits)
reversed_digits -> list.reverse(reversed_digits)
}
}

/// Returns the list of digits of `number`. If the number of digits is less
/// than 9, the result is zero-padded at the front.
///
fn get_zero_padded_digits(number: Int) -> List(Int) {
do_get_zero_padded_digits(number, [], 0)
}

fn do_get_zero_padded_digits(
number: Int,
digits: List(Int),
count: Int,
) -> List(Int) {
case number {
number if number <= 0 && count >= 9 -> digits
number if number <= 0 ->
// Zero-pad the digits at the front until we have at least 9 digits.
do_get_zero_padded_digits(number, [0, ..digits], count + 1)
number -> {
let digit = number % 10
let number = floored_div(number, 10.0)
do_get_zero_padded_digits(number, [digit, ..digits], count + 1)
}
}
}

/// Parses an RFC 3339 formatted time string into a `Timestamp`.
///
/// # Examples
Expand Down
46 changes: 19 additions & 27 deletions test/gleam/time/rfc3339_generator.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,48 @@ import gleam/int
import gleam/option
import gleam/regexp
import gleam/string
import qcheck

import gleam/time/timestamp
import qcheck

pub fn timestamp_with_zero_nanoseconds_generator() -> qcheck.Generator(
timestamp.Timestamp,
) {
use seconds <- qcheck.map(seconds_for_timestamp_generator())

timestamp.from_unix_seconds_and_nanoseconds(seconds:, nanoseconds: 0)
}

// Don't use this one of you also want nanoseconds. Those nanoseconds could push
// it out of range, and need to be specifically accounted for.
fn seconds_for_timestamp_generator() {
/// Generate timestamps representing instants in the range `0000-01-01T00:00:00Z`
/// to `9999-12-31T23:59:59.999999999Z`.
///
pub fn timestamp_generator() {
// prng can only generate good integers in the range
// [-2_147_483_648, 2_147_483_647]
//
// So we must get to the range we need by generating the values in parts, then
// adding them together.
//
// The smallest number of milliseconds we need to generate:
// > d=new Date("0000-01-01T00:00:00+23:59"); d.getTime()
// -62_167_305_540_000 ms
// -62_167_305_540 s
// > d=new Date("0000-01-01T00:00:00"); d.getTime()
// -62_167_201_438_000 ms
// -62_167_201_438 s
//
// The largest number of milliseconds without leap second we need to generate:
// > d=new Date("9999-12-31T23:59:59-23:59"); d.getTime()
// 253_402_387_139_000 ms
// 253_402_387_139 s
//
// (Add in one second to the largest value if you need leap seconds.)
// > d=new Date("9999-12-31T23:59:59"); d.getTime()
// 253_402_318_799_000 ms
// 253_402_318_799 s
//
// So we can get to the range we need by generating the values in parts, then
// adding them together. This will also

let megasecond_generator = {
use second <- qcheck.map(qcheck.int_uniform_inclusive(-62_167, 253_402))
second * 1_000_000
}

let second_generator = qcheck.int_uniform_inclusive(-305_540, 387_139)
let second_generator = qcheck.int_uniform_inclusive(-201_438, 318_799)

use megasecond, second <- qcheck.map2(
use megasecond, second, nanosecond <- qcheck.map3(
g1: megasecond_generator,
g2: second_generator,
g3: qcheck.int_uniform_inclusive(0, 999_999_999),
)
let total_seconds = megasecond + second

let assert True =
-62_167_305_540 <= total_seconds && total_seconds <= 253_402_387_140
-62_167_201_438 <= total_seconds && total_seconds <= 253_402_318_799

total_seconds
timestamp.from_unix_seconds_and_nanoseconds(total_seconds, nanosecond)
}

pub fn date_time_generator(
Expand Down
81 changes: 68 additions & 13 deletions test/gleam/time/timestamp_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,60 @@ pub fn to_rfc3339_12_test() {
|> should.equal("0100-01-01T00:00:00Z")
}

pub fn to_rfc3339_13_test() {
timestamp.from_unix_seconds_and_nanoseconds(0, 1)
|> timestamp.to_rfc3339(0)
|> should.equal("1970-01-01T00:00:00.000000001Z")
}

pub fn to_rfc3339_14_test() {
timestamp.from_unix_seconds_and_nanoseconds(-1, 12)
|> timestamp.to_rfc3339(0)
|> should.equal("1969-12-31T23:59:59.000000012Z")
}

pub fn to_rfc3339_15_test() {
timestamp.from_unix_seconds_and_nanoseconds(1, 123)
|> timestamp.to_rfc3339(0)
|> should.equal("1970-01-01T00:00:01.000000123Z")
}

pub fn to_rfc3339_16_test() {
timestamp.from_unix_seconds_and_nanoseconds(0, 1230)
|> timestamp.to_rfc3339(0)
|> should.equal("1970-01-01T00:00:00.00000123Z")
}

pub fn to_rfc3339_17_test() {
timestamp.from_unix_seconds_and_nanoseconds(0, 500_600_000)
|> timestamp.to_rfc3339(0)
|> should.equal("1970-01-01T00:00:00.5006Z")
}

pub fn to_rfc3339_18_test() {
timestamp.from_unix_seconds_and_nanoseconds(0, 500_006)
|> timestamp.to_rfc3339(0)
|> should.equal("1970-01-01T00:00:00.000500006Z")
}

pub fn to_rfc3339_19_test() {
timestamp.from_unix_seconds_and_nanoseconds(0, 999_999_999)
|> timestamp.to_rfc3339(0)
|> should.equal("1970-01-01T00:00:00.999999999Z")
}

pub fn to_rfc3339_20_test() {
timestamp.from_unix_seconds_and_nanoseconds(0, 0)
|> timestamp.to_rfc3339(0)
|> should.equal("1970-01-01T00:00:00Z")
}

pub fn to_rfc3339_21_test() {
timestamp.from_unix_seconds_and_nanoseconds(0, 1_000_000_001)
|> timestamp.to_rfc3339(0)
|> should.equal("1970-01-01T00:00:01.000000001Z")
}

// RFC 3339 Parsing

pub fn parse_rfc3339_0_test() {
Expand Down Expand Up @@ -273,10 +327,8 @@ pub fn parse_rfc3339_3_test() {
|> should.equal(#(60, 550_000_000))
}

pub fn timestamp_rfc3339_timestamp_roundtrip_property_test() {
use timestamp <- qcheck.given(
rfc3339_generator.timestamp_with_zero_nanoseconds_generator(),
)
pub fn timestamp_rfc3339_string_timestamp_roundtrip_property_test() {
use timestamp <- qcheck.given(rfc3339_generator.timestamp_generator())

let assert Ok(parsed_timestamp) =
timestamp
Expand All @@ -286,16 +338,19 @@ pub fn timestamp_rfc3339_timestamp_roundtrip_property_test() {
timestamp.compare(timestamp, parsed_timestamp) == order.Eq
}

pub fn rfc3339_string_timestamp_rfc3339_string_round_tripping_test() {
use timestamp <- qcheck.given(
// TODO: switch to generator with nanoseconds once to_rfc3339 handles
// fractional seconds.
rfc3339_generator.timestamp_with_zero_nanoseconds_generator(),
)
let assert Ok(parsed_timestamp) =
timestamp.to_rfc3339(timestamp, 0) |> timestamp.parse_rfc3339()
pub fn rfc3339_string_timestamp_rfc3339_string_roundtrip_property_test() {
use date_time <- qcheck.given(rfc3339_generator.date_time_generator(
with_leap_second: True,
second_fraction_spec: rfc3339_generator.Default,
avoid_erlang_errors: False,
))

let assert Ok(original_timestamp) = timestamp.parse_rfc3339(date_time)

let assert Ok(roundtrip_timestamp) =
original_timestamp |> timestamp.to_rfc3339(0) |> timestamp.parse_rfc3339

timestamp == parsed_timestamp
timestamp.compare(original_timestamp, roundtrip_timestamp) == order.Eq
}

// Check against OCaml Ptime reference implementation.
Expand Down
Loading