diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6576153..7fe9580 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,7 +10,7 @@ on: jobs: test: - runs-on: ubuntu-22.04 + runs-on: ${{matrix.version.os}} env: MIX_ENV: test name: Elixir ${{matrix.version.elixir}} / OTP ${{matrix.version.otp}} @@ -18,20 +18,30 @@ jobs: matrix: version: [ - { elixir: "1.14", otp: "24" }, - { elixir: "1.14", otp: "25" }, + { elixir: "1.14", otp: "24", os: "ubuntu-22.04" }, + { elixir: "1.14", otp: "25", os: "ubuntu-22.04" }, - { elixir: "1.15", otp: "24" }, - { elixir: "1.15", otp: "25" }, - { elixir: "1.15", otp: "26" }, + { elixir: "1.15", otp: "24", os: "ubuntu-22.04" }, + { elixir: "1.15", otp: "25", os: "ubuntu-22.04" }, + { elixir: "1.15", otp: "26", os: "ubuntu-22.04" }, - { elixir: "1.16", otp: "24" }, - { elixir: "1.16", otp: "25" }, - { elixir: "1.16", otp: "26" }, + { elixir: "1.16", otp: "24", os: "ubuntu-22.04" }, + { elixir: "1.16", otp: "25", os: "ubuntu-22.04" }, + { elixir: "1.16", otp: "26", os: "ubuntu-22.04" }, - { elixir: "1.17", otp: "25" }, - { elixir: "1.17", otp: "26" }, - { elixir: "1.17", otp: "27" }, + { elixir: "1.17", otp: "25", os: "ubuntu-22.04" }, + { elixir: "1.17", otp: "26", os: "ubuntu-22.04" }, + { elixir: "1.17", otp: "27", os: "ubuntu-22.04" }, + + { elixir: "1.18", otp: "26", os: "ubuntu-24.04" }, + { elixir: "1.18", otp: "27", os: "ubuntu-24.04" }, + + { elixir: "1.19", otp: "27", os: "ubuntu-24.04" }, + { elixir: "1.19", otp: "28", os: "ubuntu-24.04" }, + + { elixir: "1.20", otp: "27", os: "ubuntu-24.04" }, + { elixir: "1.20", otp: "28", os: "ubuntu-24.04" }, + { elixir: "1.20", otp: "29", os: "ubuntu-24.04" }, ] steps: - uses: actions/checkout@v4 @@ -54,5 +64,8 @@ jobs: mix local.rebar --force mix local.hex --force mix deps.get + mix deps.compile + - name: Compile + run: mix compile --warnings-as-errors - name: Run Tests run: mix test diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c82d1..63303b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # storex +## 0.6.2 + +- Fix type warnings emitted by `use Storex.Store` on Elixir 1.18 and newer + ## 0.6.1 - Add missing `cast` to `Storex.Message` diff --git a/lib/storex/store.ex b/lib/storex/store.ex index de5f8da..4cb765d 100644 --- a/lib/storex/store.ex +++ b/lib/storex/store.ex @@ -23,6 +23,56 @@ defmodule Storex.Store do @callback terminate(session_id :: binary(), params :: %{binary() => any()}, state :: any()) :: any() @optional_callbacks terminate: 3 + @doc false + def __init__(store, session, params) do + apply(store, :init, [session, params]) + |> case do + {:ok, state} -> + {:ok, state, nil} + + {:ok, state, key} -> + {:ok, state, key} + + {:error, reason} -> + {:error, reason} + + _ -> + raise "Return value of store init should be {:ok, state}, {:ok, state, key} or {:error, reason}" + end + end + + @doc false + def __mutation__(store, name, data, session, params, state) do + try do + apply(store, :mutation, [name, data, session, params, state]) + |> case do + {:reply, message, result} -> + {:reply, message, result} + + {:noreply, result} -> + {:noreply, result} + + {:error, error} -> + {:error, error} + + _ -> + {:error, + "Return value of mutation should be {:reply, message, state}, {:noreply, state} or {:error, error}"} + end + rescue + FunctionClauseError -> + {:error, + "No mutation matching #{inspect(name)} with data #{inspect(data)} in store #{inspect(store)}"} + end + end + + @doc false + def __terminate__(store, session, params, state) do + if :erlang.function_exported(store, :terminate, 3) do + apply(store, :terminate, [session, params, state]) + end + end + defmacro __using__(_opts) do quote do @behaviour Storex.Store @@ -59,42 +109,26 @@ defmodule Storex.Store do end def handle_cast(:session_ended, state) do - if :erlang.function_exported(@store, :terminate, 3) do - Kernel.apply(@store, :terminate, [state.session, state.params, state.state]) - end + Storex.Store.__terminate__(@store, state.session, state.params, state.state) {:stop, :normal, state} end def handle_call({name, data}, _, state) do - try do - Kernel.apply(@store, :mutation, [name, data, state.session, state.params, state.state]) - |> case do - {:reply, message, result} -> - diff = Storex.Diff.check(state.state, result) - state = Map.put(state, :state, result) - {:reply, {:ok, message, diff}, state} - - {:noreply, result} -> - diff = Storex.Diff.check(state.state, result) - state = Map.put(state, :state, result) - {:reply, {:ok, diff}, state} - - {:error, error} -> - {:reply, {:error, error}, state} - - _ -> - {:reply, - {:error, - "Return value of mutation should be {:reply, message, state}, {:noreply, state} or {:error, error}"}, - state} - end - rescue - e in FunctionClauseError -> - {:reply, - {:error, - "No mutation matching #{inspect(name)} with data #{inspect(data)} in store #{inspect(@store)}"}, - state} + Storex.Store.__mutation__(@store, name, data, state.session, state.params, state.state) + |> case do + {:reply, message, result} -> + diff = Storex.Diff.check(state.state, result) + state = Map.put(state, :state, result) + {:reply, {:ok, message, diff}, state} + + {:noreply, result} -> + diff = Storex.Diff.check(state.state, result) + state = Map.put(state, :state, result) + {:reply, {:ok, diff}, state} + + {:error, error} -> + {:reply, {:error, error}, state} end end @@ -103,20 +137,7 @@ defmodule Storex.Store do end defp init_store(session, params) do - @store.init(session, params) - |> case do - {:ok, state} -> - {:ok, state, nil} - - {:ok, state, key} -> - {:ok, state, key} - - {:error, reason} -> - {:error, reason} - - _ -> - raise "Return value of store init should be {:ok, state}, {:ok, state, key} or {:error, reason}" - end + Storex.Store.__init__(@store, session, params) end end end diff --git a/mix.exs b/mix.exs index 6233007..a624f68 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Storex.MixProject do use Mix.Project - @version "0.6.1" + @version "0.6.2" def project do [ diff --git a/package.json b/package.json index eee02ea..5503404 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storex", - "version": "0.6.1", + "version": "0.6.2", "main": "./priv/static/storex.umd.js", "module": "./priv/static/storex.esm.js", "types": "./priv/static/storex.d.ts", diff --git a/test/fixtures/stores/invalid_init.ex b/test/fixtures/stores/invalid_init.ex new file mode 100644 index 0000000..07909cf --- /dev/null +++ b/test/fixtures/stores/invalid_init.ex @@ -0,0 +1,11 @@ +defmodule StorexTest.Store.InvalidInit do + use Storex.Store + + def init(_session, _params) do + :not_a_valid_return + end + + def mutation(_mutation, _data, _session_id, _params, state) do + {:noreply, state} + end +end diff --git a/test/fixtures/stores/invalid_mutation.ex b/test/fixtures/stores/invalid_mutation.ex new file mode 100644 index 0000000..9725bbc --- /dev/null +++ b/test/fixtures/stores/invalid_mutation.ex @@ -0,0 +1,15 @@ +defmodule StorexTest.Store.InvalidMutation do + use Storex.Store + + def init(_session, _params) do + {:ok, %{counter: 0}} + end + + def mutation("invalid", _data, _session_id, _params, _state) do + :not_a_valid_return + end + + def mutation("error", _data, _session_id, _params, _state) do + {:error, "Not allowed"} + end +end diff --git a/test/storex/store_test.exs b/test/storex/store_test.exs new file mode 100644 index 0000000..5a30786 --- /dev/null +++ b/test/storex/store_test.exs @@ -0,0 +1,103 @@ +defmodule StorexTest.StoreTest do + use ExUnit.Case + + alias StorexTest.Store.Counter + alias StorexTest.Store.ErrorInit + alias StorexTest.Store.InvalidInit + alias StorexTest.Store.InvalidMutation + alias StorexTest.Store.KeyInit + alias StorexTest.Store.Text + + describe "init dispatch" do + test "{:ok, state} is normalized with a nil key" do + assert Storex.Store.__init__(Counter, "session", %{}) == {:ok, %{counter: 0}, nil} + end + + test "{:ok, state, key} keeps the key" do + assert Storex.Store.__init__(KeyInit, "session", %{}) == {:ok, %{counter: 0}, "user_id"} + end + + test "{:error, reason} is passed through" do + assert Storex.Store.__init__(ErrorInit, "session", %{}) == {:error, "Unauthorized"} + end + + test "params are forwarded to the store" do + assert Storex.Store.__init__(Text, "session", %{"initial_value" => "custom"}) == + {:ok, "custom", nil} + end + + test "an unsupported return value raises" do + assert_raise RuntimeError, + "Return value of store init should be {:ok, state}, {:ok, state, key} or {:error, reason}", + fn -> Storex.Store.__init__(InvalidInit, "session", %{}) end + end + end + + describe "mutation dispatch" do + test "{:noreply, state} is passed through" do + assert Storex.Store.__mutation__(Counter, "increase", [], "session", %{}, %{counter: 0}) == + {:noreply, %{counter: 1}} + end + + test "{:reply, message, state} is passed through" do + assert Storex.Store.__mutation__(Counter, "decrease", [], "session", %{}, %{counter: 0}) == + {:reply, "decreased", %{counter: -1}} + end + + test "{:error, reason} is passed through" do + assert Storex.Store.__mutation__(InvalidMutation, "error", [], "session", %{}, %{}) == + {:error, "Not allowed"} + end + + test "an unsupported return value is reported as an error" do + assert Storex.Store.__mutation__(InvalidMutation, "invalid", [], "session", %{}, %{}) == + {:error, + "Return value of mutation should be {:reply, message, state}, {:noreply, state} or {:error, error}"} + end + + test "an unmatched mutation name is reported as an error" do + assert Storex.Store.__mutation__(Counter, "unknown", [1], "session", %{}, %{counter: 0}) == + {:error, + "No mutation matching \"unknown\" with data [1] in store StorexTest.Store.Counter"} + end + end + + describe "store server" do + setup do + session = "session-#{System.unique_integer([:positive])}" + + on_exit(fn -> + Storex.Registry.session_stores(session) + |> Enum.each(fn {store, _, _, _, _} -> Storex.Supervisor.remove_store(session, store) end) + end) + + %{session: session} + end + + test "starts a store returning {:ok, state}", %{session: session} do + assert {:ok, nil} = + Storex.Supervisor.add_store("StorexTest.Store.Counter", session, self(), %{}) + + assert Storex.Supervisor.get_store_state(session, "StorexTest.Store.Counter") == %{ + counter: 0 + } + end + + test "starts a store returning {:ok, state, key}", %{session: session} do + assert {:ok, "user_id"} = + Storex.Supervisor.add_store("StorexTest.Store.KeyInit", session, self(), %{}) + end + + test "does not start a store returning {:error, reason}", %{session: session} do + assert {:error, "Unauthorized"} = + Storex.Supervisor.add_store("StorexTest.Store.ErrorInit", session, self(), %{}) + end + + test "mutating through the server returns the state diff", %{session: session} do + {:ok, _} = Storex.Supervisor.add_store("StorexTest.Store.Counter", session, self(), %{}) + + assert {:ok, [%{a: "u", p: [:counter], t: 1}]} = + Storex.Supervisor.mutate_store(session, "StorexTest.Store.Counter", "increase", []) + end + end +end