Created
December 11, 2021 16:19
-
-
Save bluzky/62a20cdb57b17f47c67261c10aa3da8b to your computer and use it in GitHub Desktop.
Elixir datetime parser with regex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule DateTimeParser do | |
@mapping %{ | |
"H" => "(?<hour>\\d{2})", | |
"I" => "(?<hour12>\\d{2})", | |
"M" => "(?<minute>\\d{2})", | |
"S" => "(?<second>\\d{2})", | |
"d" => "(?<day>\\d{2})", | |
"m" => "(?<month>\\d{2})", | |
"y" => "(?<year2>\\d{2})", | |
"Y" => "(?<year>-?\\d{4})", | |
"z" => "(?<tz>[+-]?\\d{4})", | |
"Z" => "(?<tz_name>[a-zA-Z_\/]+)", | |
"p" => "(?<p>PM|AM)", | |
"P" => "(?<P>pm|am)", | |
"%" => "%" | |
} | |
@doc """ | |
Parse string to datetime struct | |
**Example** | |
parse("2021-20-10", "%Y-%M-%d") | |
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 | | |
| Z | timezone name | UTC+7, Asia/Ho_Chi_Minh | | |
| p | PM or AM | | | |
| P | pm or am | | | |
""" | |
def parse!(dt_string, format \\ "%Y-%m-%dT%H:%M:%SZ") do | |
case parse(dt_string, format) do | |
{:ok, dt} -> dt | |
{:error, message} -> raise "Parse string #{dt_string} with error: #{message}" | |
end | |
end | |
def parse(dt_string, format \\ "%Y-%m-%dT%H:%M:%SZ") do | |
format | |
|> build_regex | |
|> Regex.named_captures(dt_string) | |
|> cast_data | |
|> to_datetime | |
end | |
def build_regex(format) do | |
keys = Map.keys(@mapping) |> Enum.join("") | |
Regex.compile!("([^%]*)%([#{keys}])([^%]*)") | |
|> Regex.scan(format) | |
|> Enum.map(fn [_, s1, key, s2] -> | |
[s1, Map.get(@mapping, key), s2] | |
end) | |
|> to_string() | |
|> Regex.compile!() | |
end | |
@default_value %{ | |
day: 1, | |
month: 1, | |
year: 0, | |
hour: 0, | |
minute: 0, | |
second: 0, | |
utc_offset: 0, | |
tz_name: "UTC", | |
shift: "AM" | |
} | |
def cast_data(nil), do: {:error, "invalid datetime"} | |
def cast_data(captures) do | |
captures | |
|> Enum.reduce_while([], fn {part, value}, acc -> | |
case cast(part, value) do | |
{:ok, data} -> {:cont, [data | acc]} | |
{:error, _} = error -> {:halt, error} | |
end | |
end) | |
|> case do | |
{:error, _} = error -> error | |
data -> Enum.into(data, @default_value) | |
end | |
end | |
@value_rages %{ | |
"hour" => [0, 23], | |
"hour12" => [0, 12], | |
"minute" => [0, 59], | |
"second" => [0, 59], | |
"day" => [0, 31], | |
"month" => [1, 12], | |
"year2" => [0, 99] | |
} | |
defp cast("P", value) do | |
cast("p", String.upcase(value)) | |
end | |
defp cast("p", value) do | |
{:ok, {:shift, value}} | |
end | |
defp cast("tz", value) do | |
{hour, minute} = String.split_at(value, 3) | |
with {:ok, {_, hour}} <- cast("offset_h", hour), | |
{:ok, {_, minute}} <- cast("offset_m", minute) do | |
sign = div(hour, abs(hour)) | |
{:ok, {:utc_offset, sign * (abs(hour) * 3600 + minute * 60)}} | |
else | |
_ -> {:error, "#{value} is invalid timezone offset"} | |
end | |
end | |
defp cast("tz_name", value) do | |
{:ok, {:tz_name, value}} | |
end | |
defp cast(part, value) do | |
value = String.to_integer(value) | |
valid = | |
case Map.get(@value_rages, part) do | |
[min, max] -> | |
value >= min and value <= max | |
_ -> | |
true | |
end | |
if valid do | |
{:ok, {String.to_atom(part), value}} | |
else | |
{:error, "#{value} is not a valid #{part}"} | |
end | |
end | |
defp to_datetime({:error, _} = error), do: error | |
defp to_datetime(%{year2: value} = data) do | |
current_year = DateTime.utc_now() |> Map.get(:year) | |
year = div(current_year, 100) * 100 + value | |
data | |
|> Map.put(:year, year) | |
|> Map.delete(:year2) | |
|> to_datetime() | |
end | |
defp to_datetime(%{hour12: hour} = data) do | |
# 12AM is not valid | |
if hour == 12 and data.shift == "AM" do | |
{:error, "12AM is invalid value"} | |
else | |
hour = | |
cond do | |
hour == 12 and data.shift == "PM" -> hour | |
data.shift == "AM" -> hour | |
data.shift == "PM" -> hour + 12 | |
end | |
data | |
|> Map.put(:hour, hour) | |
|> Map.delete(:hour12) | |
|> to_datetime() | |
end | |
end | |
defp to_datetime(data) do | |
with {:ok, date} <- Date.new(data.year, data.month, data.day), | |
{:ok, time} <- Time.new(data.hour, data.minute, data.second), | |
{:ok, datetime} <- DateTime.new(date, time) do | |
datetime = DateTime.add(datetime, -data.utc_offset, :second) | |
if data.tz_name != "UTC" do | |
DateTime.shift_zone(datetime, data.tz_name) | |
else | |
{:ok, datetime} | |
end | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thank you!