Skip to content

Instantly share code, notes, and snippets.

@bluzky
Last active October 12, 2021 09:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bluzky/989cb6287075b37c32abe0b25d9b8626 to your computer and use it in GitHub Desktop.
Save bluzky/989cb6287075b37c32abe0b25d9b8626 to your computer and use it in GitHub Desktop.
Coding challenge Onpoint

Viết 1 module với 1 public function parse để parse chuỗi thời gian về struct DateTime hỗ trợ một số selector cơ bản của strptime

Support format

format description value example
H 24 hour 00 - 23
I 12 hour 00 - 12
m minute 00 - 59
S second 00 - 59
d day 01 - 31
M month 01 -12
y 2 digits year 00 - 99
Y 4 digits year
z timezone offset +0100, -0330
P PM or AM
p pm or am

Ví dụ:

TimeParser.parse("10/12/2021 10:15:00AM +0700", "%d/%M/%Y %I:%m:S%P %z")
# > ~U[2021-12-10 03:15:00]

Template

defmodule DateTimeParser do
  def parse(date_string, format) do
    # your code
  end
end

Benchmark

Sử dụng benchee để benchmak

https://github.com/bencheeorg/benchee

Cách đánh giá kế quả

  • Sẽ có 1 bộ test public (sẽ thêm sau)
  • Và 1 bộ test secret

Kết quả dành cho solution nào pass tất cả test và có thời gian chạy nhanh nhất ( tính trung bình cho 10000 round)

Sample test

cases = [
  {"2021-10-12", "%Y-%M-%d", ~U[2021-10-12 00:00:00Z]},
  {"02/10/2021", "%d/%M/%Y", ~U[2021-10-02 00:00:00Z]},
  {"10:07:22", "%H:%m:%S", ~U[0000-01-01 10:07:22Z]},
  {"10:15:10PM", "%I:%m:%S%P", ~U[0000-01-01 22:15:10Z]},
  {"10:15:10AM", "%I:%m:%S%P", ~U[0000-01-01 10:15:10Z]},
  {"12:15:10PM", "%I:%m:%S%P", ~U[0000-01-01 12:15:10Z]},
  {"22/12/21 11:00:55", "%d/%M/%y %H:%m:%S", ~U[2021-12-22 11:00:55Z]},
  {"22-10-2021 11:67:25", "%d-%M-%Y %H:%m:%S", :error},
  {"10/15/2022 09:12:11 +0700", "%M/%d/%Y %H:%m:%S %z", ~U[2022-10-15 02:12:11Z]},
  {"10/15/2022 09:12:11 -0230", "%M/%d/%Y %H:%m:%S %z", ~U[2022-10-15 11:42:11Z]},
]

{success, _error} =
Enum.reduce(cases, {0, 0}, fn {str, format, result}, {success, error} ->
  {status, dt} = DateTimeParser.parse(str, format)

  cond  do
    status == :error and result == :error ->
      {success + 1, error}
    status == :ok and result == dt ->
      {success + 1, error}
    true ->
      {success, error + 1}
  end
end)

IO.puts("Test passed: #{success}/#{length(cases)}")
@quangvo09
Copy link

quangvo09 commented Oct 9, 2021

defmodule DateTimeParser do
  def parse(date_string, format_string) do
    default =
      DateTime.now!("Etc/UTC")
      |> Map.merge(%{
        year: 0,
        month: 1,
        day: 1,
        hour: 0,
        minute: 0,
        second: 0
      })
      |> DateTime.truncate(:second)

    %{pattern: pattern, groups: groups} = extract_format(format_string)

    with :ok <- validate_groups(groups),
         {:ok, group_values} <- extract_data(date_string, pattern),
         date_map <- Enum.zip(groups, group_values) |> Enum.into(%{}),
         dt <- build_datetime(date_map, default),
         _ <- DateTime.to_unix(dt) do
      {:ok, dt}
    else
      _ ->
        {:error, nil}
    end
  end

  @spec extract_data(String.t(), String.t()) :: {:ok, list()} | {:error, String.t()}
  defp extract_data(date_string, pattern) do
    with {_, {:ok, regex}} <- {:compile, Regex.compile(pattern)},
         {_, [_ | group_values]} <- {:validate, Regex.run(regex, date_string)} do
      {:ok, group_values}
    else
      {:compile, {:error, error}} ->
        {:error, "#{inspect(error)}"}

      _error ->
        {:error, "Invalid format"}
    end
  end

  # Extract given format
  # Input: "%d/%M/%Y"
  # Output:
  # %{
  #    pattern: "(\d\d)/(\d\d)/(\d\d\d\d)",
  #    groups: [:day, :month, :year]
  # }
  @spec extract_format(String.t()) :: map()
  def extract_format(format) do
    structure =
      %{pattern: "", groups: [], format: String.graphemes(format)}
      |> do_extract()
      |> Map.drop([:format])

    Map.put(structure, :pattern, "^" <> structure.pattern <> "$")
  end

  defp validate_groups(groups) do
    with unique_groups <- Enum.dedup(groups),
         true <- unique_groups == groups do
      :ok
    else
      _ ->
        {:error, "Invalid format"}
    end
  end

  defp do_extract(params) do
    with [prefix, type | remains] <- params.format do
      case map_type("#{prefix}#{type}") do
        %{pattern: pattern, groups: groups} ->
          params
          |> Map.put(:pattern, params.pattern <> pattern)
          |> Map.put(:groups, params.groups ++ groups)
          |> Map.put(:format, remains)
          |> do_extract()

        _ ->
          params
          |> Map.put(:pattern, params.pattern <> prefix)
          |> Map.put(:format, [type | remains])
          |> do_extract()
      end
    else
      _ ->
        Map.put(
          params,
          :pattern,
          params.pattern <> Enum.reduce(params.format, "", &(&2 <> "#{&1}"))
        )
    end
  end

  defp map_type(type) do
    case type do
      "%d" ->
        %{
          pattern: ~S"(0[1-9]|[1-2][0-9]|3[0-1])",
          groups: [:day]
        }

      "%M" ->
        %{
          pattern: ~S"(0[1-9]|1[0-2])",
          groups: [:month]
        }

      "%Y" ->
        %{
          pattern: ~S"(\d\d\d\d)",
          groups: [:year]
        }

      "%H" ->
        %{
          pattern: ~S"([0-1][0-9]|2[0-3])",
          groups: [:hour]
        }

      "%m" ->
        %{
          pattern: ~S"([0-5][0-9])",
          groups: [:minute]
        }

      "%S" ->
        %{
          pattern: ~S"([0-5][0-9])",
          groups: [:second]
        }

      "%I" ->
        %{
          pattern: ~S"(0[0-9]|1[0-2])",
          groups: [:hour]
        }

      "%P" ->
        %{
          pattern: ~S"([A|P]M)",
          groups: [:post]
        }

      "%p" ->
        %{
          pattern: ~S"([a|p]m)",
          groups: [:post]
        }

      "%y" ->
        %{
          pattern: ~S"(\d\d)",
          groups: [:year_2_digits]
        }

      "%z" ->
        %{
          pattern: ~S"([+|-]\d{4})",
          groups: [:timezone]
        }

      _ ->
        :error
    end
  end

  @date_time_fields [
    :day,
    :month,
    :year,
    :hour,
    :minute,
    :second,
    :post,
    :year_2_digits,
    :timezone
  ]
  defp build_datetime(datetime_map, dt) do
    Enum.reduce(@date_time_fields, dt, fn field, acc ->
      if value = datetime_map[field] do
        set_date_field(field, value, acc)
      else
        acc
      end
    end)
  end

  defp set_date_field(:day, value, dt) do
    Map.put(dt, :day, String.to_integer(value))
  end

  defp set_date_field(:month, value, dt) do
    Map.put(dt, :month, String.to_integer(value))
  end

  defp set_date_field(:year, value, dt) do
    Map.put(dt, :year, String.to_integer(value))
  end

  defp set_date_field(:hour, value, dt) do
    Map.put(dt, :hour, String.to_integer(value))
  end

  defp set_date_field(:minute, value, dt) do
    Map.put(dt, :minute, String.to_integer(value))
  end

  defp set_date_field(:second, value, dt) do
    Map.put(dt, :second, String.to_integer(value))
  end

  defp set_date_field(:post, value, dt) do
    if Map.get(dt, :hour) < 12 and value in ["PM", "pm"] do
      DateTime.add(dt, 12 * 3600, :second)
    else
      dt
    end
  end

  defp set_date_field(:year_2_digits, value, dt) do
    year = Map.get(DateTime.now!("Etc/UTC"), :year)
    Map.put(dt, :year, year - rem(year, 100) + String.to_integer(value))
  end

  defp set_date_field(:timezone, value, dt) do
    {hour_offset, minute_offset} =
      case value do
        "+" <> offset_str ->
          [[hour_str], [minute_str]] = Regex.scan(~r/../, offset_str)
          {-1 * String.to_integer(hour_str), -1 * String.to_integer(minute_str)}

        "-" <> offset_str ->
          [[hour_str], [minute_str]] = Regex.scan(~r/../, offset_str)
          {String.to_integer(hour_str), String.to_integer(minute_str)}

        _ ->
          {0, 0}
      end

    dt
    |> DateTime.add(hour_offset * 3600, :second)
    |> DateTime.add(minute_offset * 60, :second)
  end

  defp set_date_field(_, _value, dt), do: dt
end

cases = [
  {"2021-10-12", "%Y-%M-%d", ~U[2021-10-12 00:00:00Z]},
  {"02/10/2021", "%d/%M/%Y", ~U[2021-10-02 00:00:00Z]},
  {"10:07:22", "%H:%m:%S", ~U[0000-01-01 10:07:22Z]},
  {"10:15:10PM", "%I:%m:%S%P", ~U[0000-01-01 22:15:10Z]},
  {"10:15:10AM", "%I:%m:%S%P", ~U[0000-01-01 10:15:10Z]},
  {"12:15:10PM", "%I:%m:%S%P", ~U[0000-01-01 12:15:10Z]},
  {"22/12/21 11:00:55", "%d/%M/%y %H:%m:%S", ~U[2021-12-22 11:00:55Z]},
  {"22-10-2021 11:67:25", "%d-%M-%Y %H:%m:%S", :error},
  {"10/15/2022 09:12:11 +0700", "%M/%d/%Y %H:%m:%S %z", ~U[2022-10-15 02:12:11Z]},
  {"10/15/2022 09:12:11 -0230", "%M/%d/%Y %H:%m:%S %z", ~U[2022-10-15 11:42:11Z]}
]

{success, _error} =
  Enum.reduce(cases, {0, 0}, fn {str, format, result}, {success, error} ->
    {status, dt} = DateTimeParser.parse(str, format)

    cond do
      status == :error and result == :error ->
        {success + 1, error}

      status == :ok and result == dt ->
        {success + 1, error}

      true ->
        {success, error + 1}
    end
  end)

IO.puts("Test passed: #{success}/#{length(cases)}")
Test passed: 10/10

@Phathdt
Copy link

Phathdt commented Oct 9, 2021

@quangvo09
Copy link

done! :)

@khanha2
Copy link

khanha2 commented Oct 11, 2021

defmodule DateTimeParser do
  @fields [:year, :month, :day, :hour, :minute, :second]

  def parse(date_string, format) do
    format_items = split_format(format)

    with {:ok, {_, params}} <- extract_date_params(date_string, format_items),
         {:ok, params} <- validate_hours(params) do
      Map.merge(
        %DateTime{
          year: 0,
          month: 1,
          day: 1,
          zone_abbr: "UTC",
          hour: 0,
          minute: 0,
          second: 0,
          microsecond: {0, 0},
          utc_offset: 0,
          std_offset: 0,
          time_zone: "Etc/UTC"
        },
        Map.take(params, @fields)
      )
      |> DateTime.add(params[:offset] || 0)
    else
      _ ->
        :error
    end
  end

  @annotations ~W(H I m S d M y Y z P p)

  defp split_format(format) do
    {items, merged_item, _} =
      format
      |> String.graphemes()
      |> Enum.reduce({[], "", false}, fn
        "%", {items, merged_item, false} ->
          {items, merged_item, true}

        "%", {items, merged_item, true} ->
          {items, merged_item <> "%", true}

        character, {items, merged_item, true} when character in @annotations ->
          items = if merged_item == "", do: items, else: [merged_item | items]
          item = "%" <> character
          {[item | items], "", false}

        character, {items, merged_item, _} ->
          {items, merged_item <> character, false}
      end)

    items = if String.length(merged_item) > 0, do: [merged_item | items], else: items
    Enum.reverse(items)
  end

  defp extract_date_params(date_string, format_items) do
    Enum.reduce_while(format_items, {:ok, {date_string, %{}}}, fn
      format_item, {:ok, {remaining_string, params}} ->
        {:cont, validate_item(format_item, remaining_string, params)}

      _, acc ->
        {:halt, acc}
    end)
  end

  defp validate_item("%H", date_string, params),
    do: validate(date_string, params, :hour, range: 0..23)

  defp validate_item("%I", date_string, params),
    do: validate(date_string, params, :hour, range: 1..12)

  defp validate_item("%m", date_string, params),
    do: validate(date_string, params, :minute, range: 0..59)

  defp validate_item("%S", date_string, params),
    do: validate(date_string, params, :second, range: 0..59)

  defp validate_item("%d", date_string, params),
    do: validate(date_string, params, :day, range: 1..31)

  defp validate_item("%M", date_string, params),
    do: validate(date_string, params, :month, range: 1..12)

  defp validate_item("%y", date_string, params),
    do: validate("20" <> date_string, params, :year, length: 4, range: 2000..2099)

  defp validate_item("%Y", date_string, params),
    do: validate(date_string, params, :year, length: 4)

  defp validate_item("%z", date_string, params) do
    with false <- Map.has_key?(params, :offset),
         false <- String.length(date_string) < 5,
         {value, remaining_string} <- String.split_at(date_string, 5),
         {sign, time_value} <- String.split_at(value, 1),
         true <- sign in ["+", "-"],
         {hour, minute} <- String.split_at(time_value, 2),
         {:ok, hour} <- parse_value(hour, :integer),
         {:ok, minute} <- parse_value(minute, :integer) do
      sign_value = if sign == "-", do: 1, else: -1
      offset = sign_value * (hour * 3600 + minute * 60)
      {:ok, {remaining_string, Map.put(params, :offset, offset)}}
    else
      _ ->
        :error
    end
  end

  defp validate_item("%P", date_string, params),
    do: validate(date_string, params, :period, type: :string, range: ["AM", "PM"])

  defp validate_item("%p", date_string, params),
    do: validate(date_string, params, :period, type: :string, range: ["am", "pm"])

  defp validate_item(item, date_string, params) do
    length_item = String.length(item)

    with false <- String.length(date_string) < length_item,
         {value, remaining_string} <- String.split_at(date_string, length_item),
         true <- value == item do
      {:ok, {remaining_string, params}}
    else
      _ -> :error
    end
  end

  defp validate(date_string, params, key, opts) do
    value_length = opts[:length] || 2
    value_type = opts[:type] || :integer
    value_range = opts[:range] || nil

    with false <- Map.has_key?(params, key),
         false <- String.length(date_string) < value_length,
         {value, remaining_string} <- String.split_at(date_string, value_length),
         {:ok, value} <- parse_value(value, value_type),
         true <- value_range == nil or value in value_range do
      {:ok, {remaining_string, Map.put(params, key, value)}}
    else
      _ ->
        :error
    end
  end

  defp parse_value(value, :integer) do
    Integer.parse(value)
    |> case do
      {value, ""} ->
        {:ok, value}

      _ ->
        :error
    end
  end

  defp parse_value(value, _), do: {:ok, value}

  defp validate_hours(params) do
    period = params[:period]
    hour = params[:hour]

    cond do
      is_nil(period) ->
        {:ok, params}

      is_nil(hour) ->
        :error

      hour not in 1..12 ->
        :error

      period in ["AM", "am"] ->
        {:ok, Map.put(params, :hour, rem(hour, 12))}

      true ->
        {:ok, Map.put(params, :hour, rem(hour, 12) + 12)}
    end
  end
end

cases = [
  {"2021-10-12", "%Y-%M-%d", ~U[2021-10-12 00:00:00Z]},
  {"02/10/2021", "%d/%M/%Y", ~U[2021-10-02 00:00:00Z]},
  {"10:07:22", "%H:%m:%S", ~U[0000-01-01 10:07:22Z]},
  {"10:15:10PM", "%I:%m:%S%P", ~U[0000-01-01 22:15:10Z]},
  {"10:15:10AM", "%I:%m:%S%P", ~U[0000-01-01 10:15:10Z]},
  {"12:15:10PM", "%I:%m:%S%P", ~U[0000-01-01 12:15:10Z]},
  {"22/12/21 11:00:55", "%d/%M/%y %H:%m:%S", ~U[2021-12-22 11:00:55Z]},
  {"22-10-2021 11:67:25", "%d-%M-%Y %H:%m:%S", :error},
  {"10/15/2022 09:12:11 +0700", "%M/%d/%Y %H:%m:%S %z", ~U[2022-10-15 02:12:11Z]},
  {"10/15/2022 09:12:11 -0230", "%M/%d/%Y %H:%m:%S %z", ~U[2022-10-15 11:42:11Z]}
]

{success, _error} =
  Enum.reduce(cases, {0, 0}, fn {str, format, result}, {success, error} ->
    value = DateTimeParser.parse(str, format)

    if value == result do
      {success + 1, error}
    else
      {success, error + 1}
    end
  end)

IO.puts("Test passed: #{success}/#{length(cases)}")
Test passed: 10/10

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment