diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aff27bd..3881ceb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,8 @@ jobs: with: elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }} + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable - name: Restore deps and _build cache uses: actions/cache@v5 with: @@ -73,6 +75,8 @@ jobs: with: elixir-version: "1.18.4" otp-version: "26.2.5" + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable - name: Restore dependencies cache uses: actions/cache@v5 with: diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 0000000..7322fce --- /dev/null +++ b/.github/workflows/native.yml @@ -0,0 +1,32 @@ +name: Native (Rust NIF) +on: + push: + branches: [main] + paths: ["native/**", ".github/workflows/native.yml"] + pull_request: + branches: [main] + paths: ["native/**", ".github/workflows/native.yml"] + workflow_dispatch: +permissions: + contents: read +jobs: + lint-and-test: + name: fmt + clippy + test + runs-on: ubuntu-latest + defaults: + run: + working-directory: native/arke_native + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: native/arke_native + - name: Format + run: cargo fmt --all --check + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - name: Test + run: cargo test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..445edf2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: Release NIF binaries +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag to build binaries for (e.g. v0.6.1)" + required: true + +permissions: + contents: write + +jobs: + build: + name: ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + defaults: + run: + working-directory: native/arke_native + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + cross: true + - target: aarch64-unknown-linux-gnu + runner: ubuntu-latest + cross: true + - target: aarch64-apple-darwin + runner: macos-14 + cross: false + env: + NIF_VERSION: "2.15" + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: taiki-e/install-action@v2 + if: matrix.cross + with: + tool: cross + + - name: Resolve version + id: ver + shell: bash + run: | + ref="${{ github.event.inputs.tag || github.ref_name }}" + echo "version=${ref#v}" >> "$GITHUB_OUTPUT" + + - name: Build + shell: bash + run: | + if [ "${{ matrix.cross }}" = "true" ]; then + cross build --release --target ${{ matrix.target }} + else + cargo build --release --target ${{ matrix.target }} + fi + + - name: Package + shell: bash + run: | + v="${{ steps.ver.outputs.version }}" + out="target/${{ matrix.target }}/release" + src="$out/libarke_native.so" + [ -f "$src" ] || src="$out/libarke_native.dylib" + name="libarke_native-v${v}-nif-${NIF_VERSION}-${{ matrix.target }}.so" + cp "$src" "$name" + tar -czf "${name}.tar.gz" "$name" + + - uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + files: native/arke_native/*.so.tar.gz diff --git a/.gitignore b/.gitignore index c262507..5140aed 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ arke-*.tar .elixir_ls/ .idea/ .idea + +# Rust NIF build artifacts +native/*/target/ +priv/native/ diff --git a/lib/arke/native/date_parser.ex b/lib/arke/native/date_parser.ex new file mode 100644 index 0000000..3c97dc5 --- /dev/null +++ b/lib/arke/native/date_parser.ex @@ -0,0 +1,42 @@ +# Copyright 2023 Arkemis S.r.l. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +defmodule Arke.Native.DateParser do + @moduledoc """ + Rust NIFs for ISO8601 parsing and month arithmetic. Binary in, integer + tuples out (`nil` on failure); the caller rebuilds the structs. + + If the NIF isn't loaded, functions raise `:nif_not_loaded`; + `Arke.Utils.DatetimeHandler` rescues that and falls back to pure Elixir. + """ + @version Mix.Project.config()[:version] + + use RustlerPrecompiled, + otp_app: :arke, + crate: "arke_native", + base_url: "https://github.com/arkemis/arke/releases/download/v#{@version}", + version: @version, + targets: ~w( + x86_64-unknown-linux-gnu + aarch64-unknown-linux-gnu + aarch64-apple-darwin + ), + # download the binary instead of building once a release + checksum exist + force_build: System.get_env("ARKE_FORCE_BUILD_NIF", "true") in ["1", "true"] + + def parse_date(_binary), do: :erlang.nif_error(:nif_not_loaded) + def parse_time(_binary), do: :erlang.nif_error(:nif_not_loaded) + def parse_datetime(_binary), do: :erlang.nif_error(:nif_not_loaded) + def add_months(_y, _m, _d, _months), do: :erlang.nif_error(:nif_not_loaded) +end diff --git a/lib/arke/utils/datetime_handler.ex b/lib/arke/utils/datetime_handler.ex index da6663d..7bbe3eb 100644 --- a/lib/arke/utils/datetime_handler.ex +++ b/lib/arke/utils/datetime_handler.ex @@ -13,116 +13,136 @@ # limitations under the License. defmodule Arke.Utils.DatetimeHandler do - use Timex + @moduledoc """ + Date/time helpers. ISO parsing and month shifting go through the Rust NIF + (`Arke.Native.DateParser`), with a pure-Elixir fallback; the rest uses the + stdlib. + """ + alias Arke.Native.DateParser @datetime_msg "must be %DateTime{} | %NaiveDatetime{} | ~N[YYYY-MM-DDTHH:MM:SS] | ~N[YYYY-MM-DD HH:MM:SS] | ~U[YYYY-MM-DD HH:MM:SS] format" - @date_msg "must be %Date{} | ~D[YYYY-MM-DD] | iso8601 (YYYY-MM-DD) format" - @time_msg "must be must be %Time{} |~T[HH:MM:SS] | iso8601 (HH:MM:SS) format" @general_msg " values must be %Date{} | ~D[YYYY-MM-DD]| %DateTime{} | %NaiveDateTime{} | ~N[YYYY-MM-DDTHH:MM:SS] | ~N[YYYY-MM-DD HH:MM:SS] | ~U[YYYY-MM-DD HH:MM:SS]" - defp check_datetime(v, only_value) do - case Timex.is_valid?(v) do - true -> - datetime = Timex.to_datetime(v, "Etc/UTC") + # ----- now / from_unix ----- - case only_value do - true -> datetime - false -> {:ok, datetime} - end + def now(:datetime), do: DateTime.utc_now() |> DateTime.truncate(:second) + def now(:date), do: Date.utc_today() + def now(:time), do: Time.utc_now() |> Time.truncate(:second) - false -> - {:error, @datetime_msg} - end - end + def from_unix(s, unit \\ :second), do: DateTime.from_unix!(s, unit) - defp check_date(v, only_value) do - case Timex.is_valid?(v) do - true -> - date = Timex.to_date(v) + # ----- DATETIME ----- - case only_value do - true -> date - false -> {:ok, date} - end + def parse_datetime(value, only_value \\ false) + def parse_datetime(value, true) when is_nil(value), do: value + def parse_datetime(value, _only_value) when is_nil(value), do: {:ok, value} - false -> - {:error, @date_msg} - end - end + def parse_datetime(%DateTime{} = value, only_value), + do: wrap(to_utc(value), only_value) - defp check_time(v, only_value) do - try do - # it will crash if the time is not valid the return the %Time{} - Time.to_iso8601(v) + def parse_datetime(%NaiveDateTime{} = value, only_value), + do: wrap(DateTime.from_naive!(value, "Etc/UTC"), only_value) - case only_value do - true -> v - false -> {:ok, v} - end - rescue - e -> - {:error, @time_msg} + def parse_datetime(value, only_value) when is_binary(value) do + case nif_parse_datetime(value) do + {:ok, datetime} -> wrap(datetime, only_value) + :error -> {:error, @datetime_msg} + :fallback -> stdlib_parse_datetime(value, only_value) end end - def now(:datetime), do: Timex.set(Timex.now(), microsecond: 0) - def now(:date), do: Timex.now() |> Timex.to_date() - def now(:time), do: Time.utc_now() |> Time.truncate(:second) - - - # ----- DATETIME ----- - - def from_unix(s, unit \\ :second), do: Timex.from_unix(s, unit) - def parse_datetime(value, only_value \\ false) - def parse_datetime(value, true) when is_nil(value), do: value - def parse_datetime(value, _only_value) when is_nil(value), do: {:ok, value} + def parse_datetime(_value, _only_value), do: {:error, @datetime_msg} + + defp nif_parse_datetime(value) do + case DateParser.parse_datetime(value) do + {{y, m, d}, {h, mi, s, micro, prec}} -> + with {:ok, date} <- Date.new(y, m, d), + {:ok, time} <- Time.new(h, mi, s, {micro, prec}), + {:ok, datetime} <- DateTime.new(date, time, "Etc/UTC") do + {:ok, datetime} + else + _ -> :error + end - def parse_datetime(%DateTime{} = value, only_value), do: check_datetime(value, only_value) + nil -> + :error + end + rescue + ErlangError -> :fallback + end - def parse_datetime(%NaiveDateTime{} = value, only_value), do: check_datetime(value, only_value) + defp stdlib_parse_datetime(value, only_value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> + wrap(to_utc(datetime), only_value) - def parse_datetime(value, only_value) do - case Timex.parse(value, "{ISO:Extended:Z}") do - {:ok, datetime} -> check_datetime(datetime, only_value) - {:error, _} -> {:error, @datetime_msg} + {:error, _} -> + case NaiveDateTime.from_iso8601(value) do + {:ok, naive} -> wrap(DateTime.from_naive!(naive, "Etc/UTC"), only_value) + {:error, _} -> {:error, @datetime_msg} + end end end - def format(value, format \\ "{ISO:Extended}"), do: Timex.format(value,format) - def format!(value, format \\ "{ISO:Extended}"), do: Timex.format!(value,format) def shift_datetime(datetime, opts) do case parse_datetime(datetime) do - {:ok, value} -> Timex.shift(value, opts) + {:ok, value} -> do_shift(value, opts) {:error, msg} -> {:error, msg} end end - def shift_datetime(opts), do: Timex.shift(now(:datetime), opts) + def shift_datetime(opts), do: do_shift(now(:datetime), opts) # ----- DATE ----- + def parse_date(value, only_value \\ false) def parse_date(value, true) when is_nil(value), do: nil def parse_date(value, _only_value) when is_nil(value), do: {:ok, nil} - def parse_date(%Date{} = value, only_value), do: check_date(value, only_value) + def parse_date(%Date{} = value, only_value), do: wrap(value, only_value) + + def parse_date(value, only_value) when is_binary(value) do + case nif_parse_date(value) do + {:ok, date} -> wrap(date, only_value) + :error -> {:error, @date_msg} + :fallback -> stdlib_parse_date(value, only_value) + end + end + + def parse_date(_value, _only_value), do: {:error, @date_msg} + + defp nif_parse_date(value) do + case DateParser.parse_date(value) do + {y, m, d} -> + case Date.new(y, m, d) do + {:ok, date} -> {:ok, date} + _ -> :error + end + + nil -> + :error + end + rescue + ErlangError -> :fallback + end - def parse_date(value, only_value) do - case Timex.parse(value, "{ISOdate}") do - {:ok, parsed} -> check_date(parsed, only_value) + defp stdlib_parse_date(value, only_value) do + case Date.from_iso8601(value) do + {:ok, date} -> wrap(date, only_value) {:error, _} -> {:error, @date_msg} end end def shift_date(date, opts) do case parse_date(date) do - {:ok, value} -> Timex.shift(value, opts) + {:ok, value} -> do_shift(value, opts) {:error, msg} -> {:error, msg} end end - def shift_date(opts), do: Timex.shift(now(:date), opts) + def shift_date(opts), do: do_shift(now(:date), opts) # ----- TIME ----- @@ -131,39 +151,135 @@ defmodule Arke.Utils.DatetimeHandler do def parse_time(value, _only_value) when is_nil(value), do: {:ok, nil} def parse_time(value, _only_value) when is_number(value), do: {:error, @time_msg} - def parse_time(%Time{} = value, only_value), do: check_time(value, only_value) + def parse_time(%Time{} = value, only_value), do: wrap(value, only_value) - def parse_time(value, only_value) do - case Time.from_iso8601(value) do - {:ok, time} -> - case only_value do - true -> - time + def parse_time(value, only_value) when is_binary(value) do + case nif_parse_time(value) do + {:ok, time} -> wrap(time, only_value) + :error -> {:error, @time_msg} + :fallback -> stdlib_parse_time(value, only_value) + end + end - false -> - {:ok, time} + def parse_time(_value, _only_value), do: {:error, @time_msg} + + defp nif_parse_time(value) do + case DateParser.parse_time(value) do + {h, mi, s, micro, prec} -> + case Time.new(h, mi, s, {micro, prec}) do + {:ok, time} -> {:ok, time} + _ -> :error end - {:error, _} -> - {:error, @time_msg} + nil -> + :error end + rescue + ErlangError -> :fallback end - def after?(first_date, second_date) do - try do - Timex.after?(first_date, second_date) - rescue - _ -> - @general_msg + defp stdlib_parse_time(value, only_value) do + case Time.from_iso8601(value) do + {:ok, time} -> wrap(time, only_value) + {:error, _} -> {:error, @time_msg} end end - def before?(first_date, second_date) do - try do - Timex.before?(first_date, second_date) - rescue - _ -> - @general_msg + # ----- format / compare ----- + + def format(value, format \\ "{ISO:Extended}"), do: {:ok, iso8601(value, format)} + def format!(value, format \\ "{ISO:Extended}"), do: iso8601(value, format) + + def after?(a, b), do: compare(a, b, :gt) + def before?(a, b), do: compare(a, b, :lt) + + # ----- helpers ----- + + defp wrap(value, true), do: value + defp wrap(value, false), do: {:ok, value} + + defp to_utc(%DateTime{time_zone: "Etc/UTC"} = dt), do: dt + defp to_utc(%DateTime{} = dt), do: DateTime.shift_zone!(dt, "Etc/UTC") + + defp iso8601(%Date{} = v, fmt), do: Date.to_iso8601(v, iso_mode(fmt)) + defp iso8601(%Time{} = v, fmt), do: Time.to_iso8601(v, iso_mode(fmt)) + defp iso8601(%DateTime{} = v, fmt), do: DateTime.to_iso8601(v, iso_mode(fmt)) + defp iso8601(%NaiveDateTime{} = v, fmt), do: NaiveDateTime.to_iso8601(v, iso_mode(fmt)) + + defp iso_mode(fmt) do + if String.contains?(fmt, "Basic"), do: :basic, else: :extended + end + + defp compare(%Time{} = a, %Time{} = b, want), do: Time.compare(a, b) == want + defp compare(%Date{} = a, %Date{} = b, want), do: Date.compare(a, b) == want + + defp compare(a, b, want) do + with {:ok, da} <- parse_datetime(a), + {:ok, db} <- parse_datetime(b) do + DateTime.compare(da, db) == want + else + _ -> @general_msg end end + + # ----- shift ----- + # months via NIF, the rest via stdlib + + defp do_shift(value, opts) do + months = opt(opts, :years) * 12 + opt(opts, :months) + days = opt(opts, :weeks) * 7 + opt(opts, :days) + seconds = opt(opts, :hours) * 3600 + opt(opts, :minutes) * 60 + opt(opts, :seconds) + micros = opt(opts, :microseconds) + opt(opts, :milliseconds) * 1000 + + value + |> add_months(months) + |> add_delta(days, seconds, micros) + end + + defp add_months(value, 0), do: value + + defp add_months(value, months) do + {y, m, d} = ymd(value) + + {ny, nm, nd} = + try do + case DateParser.add_months(y, m, d, months) do + {a, b, c} -> {a, b, c} + nil -> add_months_stdlib(y, m, d, months) + end + rescue + ErlangError -> add_months_stdlib(y, m, d, months) + end + + put_ymd(value, ny, nm, nd) + end + + defp add_months_stdlib(y, m, d, months) do + total = y * 12 + (m - 1) + months + ny = Integer.floor_div(total, 12) + nm = Integer.mod(total, 12) + 1 + last = Date.days_in_month(Date.new!(ny, nm, 1)) + {ny, nm, min(d, last)} + end + + defp add_delta(%Date{} = v, days, _seconds, _micros), do: Date.add(v, days) + + defp add_delta(v, days, seconds, micros) do + v + |> DateTime.add(days, :day) + |> DateTime.add(seconds, :second) + |> add_micros(micros) + end + + # adding 0 µs would bump precision to 6; skip it + defp add_micros(v, 0), do: v + defp add_micros(v, micros), do: DateTime.add(v, micros, :microsecond) + + defp ymd(%Date{year: y, month: m, day: d}), do: {y, m, d} + defp ymd(%DateTime{year: y, month: m, day: d}), do: {y, m, d} + + defp put_ymd(%Date{} = v, y, m, d), do: %{v | year: y, month: m, day: d} + defp put_ymd(%DateTime{} = v, y, m, d), do: %{v | year: y, month: m, day: d} + + defp opt(opts, key), do: Keyword.get(opts, key, 0) || 0 end diff --git a/mix.exs b/mix.exs index 0895768..4580d8a 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Arke.MixProject do use Mix.Project - @version "0.6.1" + @version "0.6.2" @scm_url "https://github.com/arkemis/arke" @site_url "https://arkehub.com" @@ -52,10 +52,11 @@ defmodule Arke.MixProject do [ {:typed_struct, "~> 0.2.1"}, {:uuid, "~> 1.1"}, + {:rustler_precompiled, "~> 0.8"}, + {:rustler, "~> 0.34.0", optional: true}, {:ex_doc, "~> 0.28", only: :dev, runtime: false}, {:excoveralls, "~> 0.10", only: :test}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, - {:timex, "~> 3.7.11"}, {:google_api_storage, "~> 0.34.0"}, {:goth, "~> 1.4.5"}, {:httpoison, "~> 2.0"}, diff --git a/mix.lock b/mix.lock index d0bd5dc..86eaa71 100644 --- a/mix.lock +++ b/mix.lock @@ -34,10 +34,13 @@ "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, + "req": {:hex, :req, "0.5.1", "90584216d064389a4ff2d4279fe2c11ff6c812ab00fa01a9fb9d15457f65ba70", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "7ea96a1a95388eb0fefa92d89466cdfedba24032794e5c1147d78ec90db7edca"}, + "rustler": {:hex, :rustler, "0.34.0", "e9a73ee419fc296a10e49b415a2eb87a88c9217aa0275ec9f383d37eed290c1c", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "1d0c7449482b459513003230c0e2422b0252245776fe6fd6e41cb2b11bd8e628"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "tesla": {:hex, :tesla, "1.16.0", "de77d083aea08ebd1982600693ff5d779d68a4bb835d136a0394b08f69714660", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "eb3bdfc0c6c8a23b4e3d86558e812e3577acff1cb4acb6cfe2da1985a1035b89"}, - "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, + "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, "typed_struct": {:hex, :typed_struct, "0.2.1", "e1993414c371f09ff25231393b6430bd89d780e2a499ae3b2d2b00852f593d97", [:mix], [], "hexpm", "8f5218c35ec38262f627b2c522542f1eae41f625f92649c0af701a6fab2e11b3"}, "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, diff --git a/native/arke_native/.cargo/config.toml b/native/arke_native/.cargo/config.toml new file mode 100644 index 0000000..4db0b25 --- /dev/null +++ b/native/arke_native/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.'cfg(target_os = "macos")'] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] diff --git a/native/arke_native/Cargo.lock b/native/arke_native/Cargo.lock new file mode 100644 index 0000000..1e9f432 --- /dev/null +++ b/native/arke_native/Cargo.lock @@ -0,0 +1,184 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "arke_native" +version = "0.1.0" +dependencies = [ + "chrono", + "rustler", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustler" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94bdfa68c0388cbd725f1ca54e975956482c262599e5cced04a903eec918b7f" +dependencies = [ + "inventory", + "rustler_codegen", + "rustler_sys", +] + +[[package]] +name = "rustler_codegen" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "996dc019acb78b91b4e0c1bd6fa2cd509a835d309de762dc15213b97eac399da" +dependencies = [ + "heck", + "inventory", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rustler_sys" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd0e2c955cfc86ea4680067e1d5e711427b43f7befcb6e23c7807cf3dd90e97" +dependencies = [ + "regex", + "unreachable", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unreachable" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" +dependencies = [ + "void", +] + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" diff --git a/native/arke_native/Cargo.toml b/native/arke_native/Cargo.toml new file mode 100644 index 0000000..b609ece --- /dev/null +++ b/native/arke_native/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "arke_native" +version = "0.1.0" +edition = "2021" +authors = [] +license = "Apache-2.0" +description = "Rust NIFs for the Arke core (datetime parsing PoC)" + +[lib] +name = "arke_native" +path = "src/lib.rs" +crate-type = ["cdylib"] + +[dependencies] +rustler = "0.34" +chrono = { version = "0.4", default-features = false, features = ["alloc"] } diff --git a/native/arke_native/src/lib.rs b/native/arke_native/src/lib.rs new file mode 100644 index 0000000..db24811 --- /dev/null +++ b/native/arke_native/src/lib.rs @@ -0,0 +1,113 @@ +// Copyright 2023 Arkemis S.r.l. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// ISO8601 parsing + month arithmetic for Arke.Utils.DatetimeHandler. +// In: binary. Out: integer tuples (or nil). Elixir rebuilds the structs. + +use chrono::{DateTime, Datelike, Months, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Utc}; + +type DateTuple = (i32, u32, u32); +// h, mi, s, micro, precision (fractional digits, 0..6) +type TimeTuple = (u32, u32, u32, u32, u32); +// nested to fit rustler's tuple arity limit +type DateTimeTuple = (DateTuple, TimeTuple); + +// fractional-second digits, capped at 6 — keeps the rebuilt struct's precision +fn frac_precision(s: &str) -> u32 { + match s.find('.') { + None => 0, + Some(i) => { + let n = s[i + 1..].bytes().take_while(u8::is_ascii_digit).count(); + n.min(6) as u32 + } + } +} + +#[rustler::nif] +fn parse_date(s: &str) -> Option { + let d = NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()?; + Some((d.year(), d.month(), d.day())) +} + +#[rustler::nif] +fn parse_time(s: &str) -> Option { + let s = s.trim(); + let t = NaiveTime::parse_from_str(s, "%H:%M:%S%.f") + .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M:%S")) + .ok()?; + Some(( + t.hour(), + t.minute(), + t.second(), + t.nanosecond() / 1_000, + frac_precision(s), + )) +} + +// offset-aware -> UTC; naive (T or space separated) assumed UTC +#[rustler::nif] +fn parse_datetime(s: &str) -> Option { + let s = s.trim(); + + let prec = frac_precision(s); + + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Some(to_tuple(dt.with_timezone(&Utc).naive_utc(), prec)); + } + + for fmt in [ + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S%.f", + "%Y-%m-%d %H:%M:%S", + ] { + if let Ok(ndt) = NaiveDateTime::parse_from_str(s, fmt) { + return Some(to_tuple(ndt, prec)); + } + } + + None +} + +// add months (signed), end-of-month clamped (Jan 31 + 1 -> Feb 28/29) +#[rustler::nif] +fn add_months(y: i32, m: u32, d: u32, months: i64) -> Option { + let date = NaiveDate::from_ymd_opt(y, m, d)?; + let shifted = if months >= 0 { + date.checked_add_months(Months::new(months as u32))? + } else { + date.checked_sub_months(Months::new(months.unsigned_abs() as u32))? + }; + Some((shifted.year(), shifted.month(), shifted.day())) +} + +fn to_tuple(ndt: NaiveDateTime, prec: u32) -> DateTimeTuple { + ( + (ndt.year(), ndt.month(), ndt.day()), + ( + ndt.hour(), + ndt.minute(), + ndt.second(), + ndt.nanosecond() / 1_000, + prec, + ), + ) +} + +rustler::init!("Elixir.Arke.Native.DateParser"); + +#[cfg(test)] +mod tests { + use super::frac_precision; + + #[test] + fn frac_precision_counts_capped_digits() { + assert_eq!(frac_precision("12:00:00"), 0); + assert_eq!(frac_precision("12:00:00.5"), 1); + assert_eq!(frac_precision("12:00:00.123456"), 6); + assert_eq!(frac_precision("12:00:00.1234567890"), 6); + assert_eq!(frac_precision("2020-01-01T12:00:00.25+02:00"), 2); + } +} diff --git a/test/utils/datetime_handler_test.exs b/test/utils/datetime_handler_test.exs new file mode 100644 index 0000000..83ff257 --- /dev/null +++ b/test/utils/datetime_handler_test.exs @@ -0,0 +1,174 @@ +defmodule Arke.Utils.DatetimeHandlerTest do + use ExUnit.Case, async: true + alias Arke.Utils.DatetimeHandler, as: D + + describe "parse_date/2" do + test "valid iso date" do + assert D.parse_date("2020-01-31") == {:ok, ~D[2020-01-31]} + assert D.parse_date("2020-01-31", true) == ~D[2020-01-31] + end + + test "passthrough for %Date{}" do + assert D.parse_date(~D[1993-11-15]) == {:ok, ~D[1993-11-15]} + end + + test "nil" do + assert D.parse_date(nil) == {:ok, nil} + assert D.parse_date(nil, true) == nil + end + + test "invalid -> error" do + assert {:error, _} = D.parse_date("31-01-1999") + assert {:error, _} = D.parse_date("not a date") + assert {:error, _} = D.parse_date("2020-13-40") + end + end + + describe "parse_time/2" do + test "no fraction keeps precision 0 (struct equality with Timex path)" do + assert D.parse_time("23:59:12") == {:ok, ~T[23:59:12]} + end + + test "microsecond fraction" do + assert D.parse_time("06:45:15.123456") == {:ok, ~T[06:45:15.123456]} + end + + test "invalid -> error" do + assert {:error, _} = D.parse_time("25:00:00") + assert {:error, _} = D.parse_time("nope") + end + end + + describe "parse_datetime/2" do + test "naive space-separated, assumed UTC, precision 0" do + assert D.parse_datetime("1999-01-31 23:59:12") == {:ok, ~U[1999-01-31 23:59:12Z]} + end + + test "Z suffix" do + assert D.parse_datetime("2020-01-01T12:00:00Z") == {:ok, ~U[2020-01-01 12:00:00Z]} + end + + test "offset normalized to UTC" do + assert D.parse_datetime("2020-01-01T12:00:00+02:00") == {:ok, ~U[2020-01-01 10:00:00Z]} + end + + test "fractional seconds preserved" do + assert D.parse_datetime("2020-01-01T12:00:00.500000Z") == + {:ok, ~U[2020-01-01 12:00:00.500000Z]} + end + + test "passthrough for %DateTime{} / %NaiveDateTime{}" do + assert D.parse_datetime(~U[2000-01-31 23:59:12Z]) == {:ok, ~U[2000-01-31 23:59:12Z]} + assert D.parse_datetime(~N[2000-01-31 23:59:12]) == {:ok, ~U[2000-01-31 23:59:12Z]} + end + + test "invalid -> error" do + assert {:error, _} = D.parse_datetime("not a date") + assert {:error, _} = D.parse_datetime("2020-13-01 00:00:00") + end + end + + # parse the same ISO string with NIF and with the stdlib, compare + describe "randomized: NIF == Elixir stdlib parser" do + test "datetime" do + for _ <- 1..2000 do + iso = DateTime.to_iso8601(rand_datetime()) + {:ok, expected, _offset} = DateTime.from_iso8601(iso) + assert D.parse_datetime(iso) == {:ok, expected} + end + end + + test "date" do + for _ <- 1..2000 do + iso = + Date.to_iso8601( + Date.new!(Enum.random(1970..2100), Enum.random(1..12), Enum.random(1..28)) + ) + + {:ok, expected} = Date.from_iso8601(iso) + assert D.parse_date(iso) == {:ok, expected} + end + end + + test "time incl. precision" do + for _ <- 1..2000 do + iso = Time.to_iso8601(rand_time()) + {:ok, expected} = Time.from_iso8601(iso) + assert D.parse_time(iso) == {:ok, expected} + end + end + end + + describe "now / from_unix / format / compare (stdlib, no Timex)" do + test "now" do + assert %DateTime{microsecond: {0, 0}} = D.now(:datetime) + assert %Date{} = D.now(:date) + assert %Time{microsecond: {0, 0}} = D.now(:time) + end + + test "from_unix -> UTC" do + assert D.from_unix(0) == ~U[1970-01-01 00:00:00Z] + end + + test "format extended/basic" do + assert D.format(~U[2020-01-02 03:04:05Z]) == {:ok, "2020-01-02T03:04:05Z"} + assert D.format(~U[2020-01-02 03:04:05Z], "{ISO:Basic:Z}") == {:ok, "20200102T030405Z"} + assert D.format!(~D[2020-01-02]) == "2020-01-02" + end + + test "after?/before?" do + assert D.after?(~U[2020-01-02 00:00:00Z], ~U[2020-01-01 00:00:00Z]) + refute D.before?(~U[2020-01-02 00:00:00Z], ~U[2020-01-01 00:00:00Z]) + assert D.before?(~D[2020-01-01], ~D[2020-01-02]) + end + end + + describe "shift (NIF calendar math + stdlib deltas)" do + test "month add with end-of-month clamp" do + assert D.shift_datetime(~U[2020-01-31 00:00:00Z], months: 1) == ~U[2020-02-29 00:00:00Z] + assert D.shift_datetime(~U[2021-01-31 00:00:00Z], months: 1) == ~U[2021-02-28 00:00:00Z] + end + + test "years/days/hours mix" do + assert D.shift_datetime(~U[2020-01-01 00:00:00Z], years: 1, days: 2, hours: 3) == + ~U[2021-01-03 03:00:00Z] + end + + test "negative shift" do + assert D.shift_datetime(~U[2020-03-31 00:00:00Z], months: -1) == ~U[2020-02-29 00:00:00Z] + end + + test "shift_date" do + assert D.shift_date(~D[2020-01-31], months: 1) == ~D[2020-02-29] + assert D.shift_date(~D[2020-01-01], weeks: 1) == ~D[2020-01-08] + end + + test "NIF == stdlib month math (randomized)" do + for _ <- 1..2000 do + y = Enum.random(1970..2100) + m = Enum.random(1..12) + d = Enum.random(1..28) + n = Enum.random(-48..48) + base = ~U[2000-01-01 00:00:00Z] + from_nif = D.shift_datetime(%{base | year: y, month: m, day: d}, months: n) + # same math in plain Elixir + total = y * 12 + (m - 1) + n + ey = Integer.floor_div(total, 12) + em = Integer.mod(total, 12) + 1 + ed = min(d, Date.days_in_month(Date.new!(ey, em, 1))) + assert {from_nif.year, from_nif.month, from_nif.day} == {ey, em, ed} + end + end + end + + defp rand_time do + prec = Enum.random(0..6) + micro = if prec == 0, do: 0, else: Enum.random(0..999_999) + Time.new!(Enum.random(0..23), Enum.random(0..59), Enum.random(0..59), {micro, prec}) + end + + defp rand_datetime do + date = Date.new!(Enum.random(1970..2100), Enum.random(1..12), Enum.random(1..28)) + DateTime.new!(date, rand_time(), "Etc/UTC") + end +end