diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5416203..9527a17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,26 @@ jobs: run: mix deps.get - name: Run formatter run: mix format --check-formatted + dialyzer: + name: Dialyzer + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: "1.17.2" + otp-version: "27.0.1" + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + - name: Install dependencies + run: mix deps.get + - name: Run dialyzer + run: mix dialyzer test: name: Test runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 2153aff..743c470 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,11 @@ erl_crash.dump # Ignore package tarball (built via "mix hex.build"). commandex-*.tar +# Ignore dialyzer files. +/priv/plts/*.plt +/priv/plts/*.plt.hash + +# Misc +.DS_Store .iex.exs *.swp diff --git a/CHANGELOG.md b/CHANGELOG.md index cf17531..bdad1e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [0.5.0] - 2024-09-08 ### Added -- New `run/0` function for commands that don't define any parameters. +- `run/0` function for commands that don't define any parameters. + +### Changed + +- Raise `ArgumentError` if an invalid `pipeline` is defined. ## [0.4.1] - 2020-06-26 diff --git a/lib/commandex.ex b/lib/commandex.ex index 0ee42e6..0ff63f6 100644 --- a/lib/commandex.ex +++ b/lib/commandex.ex @@ -2,7 +2,7 @@ defmodule Commandex do @moduledoc """ Defines a command struct. - Commandex structs are a loose implementation of the command pattern, making it easy + Commandex is a loose implementation of the command pattern, making it easy to wrap parameters, data, and errors into a well-defined struct. ## Example @@ -70,7 +70,7 @@ defmodule Commandex do or map with atom/string keys. `&run/1` takes a command struct and runs it through the pipeline functions defined - in the command. Functions are executed *in the order in which they are defined*. + in the command. **Functions are executed in the order in which they are defined**. If a command passes through all pipelines without calling `halt/1`, `:success` will be set to `true`. Otherwise, subsequent pipelines after the `halt/1` will be ignored and `:success` will be set to `false`. @@ -88,6 +88,21 @@ defmodule Commandex do %{success: false, errors: _error} -> # I'm a lazy programmer that writes catch-all error handling end + + ## Parameter-less Commands + + If a command does not have any parameters defined, a `run/0` will be generated + automatically. Useful for diagnostic jobs and internal tasks. + + iex> GenerateReport.run() + %GenerateReport{ + pipelines: [:fetch_data, :calculate_results], + data: %{total_valid: 183220, total_invalid: 781215}, + params: %{}, + halted: false, + errors: %{}, + success: true + } """ @typedoc """ @@ -418,10 +433,30 @@ defmodule Commandex do Module.put_attribute(mod, :data, {name, nil}) end - def __pipeline__(mod, name) do + def __pipeline__(mod, name) when is_atom(name) do Module.put_attribute(mod, :pipelines, name) end + def __pipeline__(mod, fun) when is_function(fun, 1) do + Module.put_attribute(mod, :pipelines, fun) + end + + def __pipeline__(mod, fun) when is_function(fun, 3) do + Module.put_attribute(mod, :pipelines, fun) + end + + def __pipeline__(mod, {m, f}) do + Module.put_attribute(mod, :pipelines, {m, f}) + end + + def __pipeline__(mod, {m, f, a}) do + Module.put_attribute(mod, :pipelines, {m, f, a}) + end + + def __pipeline__(_mod, name) do + raise ArgumentError, "pipeline #{inspect(name)} is not valid" + end + defp get_param(params, key, default) do case Map.get(params, key) do nil -> diff --git a/mix.exs b/mix.exs index 8e0b283..7e40223 100644 --- a/mix.exs +++ b/mix.exs @@ -2,12 +2,13 @@ defmodule Commandex.MixProject do use Mix.Project @source_url "https://github.com/codedge-llc/commandex" - @version "0.4.1" + @version "0.5.0" def project do [ app: :commandex, deps: deps(), + dialyzer: dialyzer(), docs: docs(), elixir: "~> 1.9", elixirc_paths: elixirc_paths(Mix.env()), @@ -15,6 +16,7 @@ defmodule Commandex.MixProject do package: package(), source_url: "https://github.com/codedge-llc/commandex", start_permanent: Mix.env() == :prod, + test_coverage: test_coverage(), version: @version ] end @@ -22,6 +24,7 @@ defmodule Commandex.MixProject do defp deps do [ {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, {:ex_doc, "~> 0.31", only: :dev} ] end @@ -29,6 +32,12 @@ defmodule Commandex.MixProject do defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] + defp dialyzer do + [ + plt_file: {:no_warn, "priv/plts/dialyzer.plt"} + ] + end + defp docs do [ extras: [ @@ -57,6 +66,16 @@ defmodule Commandex.MixProject do ] end + defp test_coverage do + [ + ignore_modules: [ + GenerateReport, + RegisterUser + ], + summary: [threshold: 70] + ] + end + def application do [ extra_applications: [:logger] diff --git a/mix.lock b/mix.lock index 2753930..160a6bb 100644 --- a/mix.lock +++ b/mix.lock @@ -2,8 +2,10 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, + "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, "excoveralls": {:hex, :excoveralls, "0.12.1", "a553c59f6850d0aff3770e4729515762ba7c8e41eedde03208182a8dc9d0ce07", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, diff --git a/test/commandex_test.exs b/test/commandex_test.exs index 2eb7e06..227e282 100644 --- a/test/commandex_test.exs +++ b/test/commandex_test.exs @@ -1,7 +1,6 @@ defmodule CommandexTest do use ExUnit.Case doctest Commandex - alias Commandex.RegisterUser @email "example@example.com" @password "test1234" @@ -58,18 +57,105 @@ defmodule CommandexTest do end end + describe "param/2 macro" do + test "raises if duplicate defined" do + assert_raise ArgumentError, fn -> + defmodule ExampleParamInvalid do + import Commandex + + command do + param :key_1 + param :key_2 + param :key_1 + end + end + end + end + end + + describe "data/1 macro" do + test "raises if duplicate defined" do + assert_raise ArgumentError, fn -> + defmodule ExampleDataInvalid do + import Commandex + + command do + data :key_1 + data :key_2 + data :key_1 + end + end + end + end + end + + describe "pipeline/1 macro" do + test "accepts valid pipeline arguments" do + try do + defmodule ExamplePipelineValid do + import Commandex + + command do + pipeline :example + pipeline {ExamplePipelineValid, :example} + pipeline {ExamplePipelineValid, :example_args, ["test"]} + pipeline &ExamplePipelineValid.example_single/1 + pipeline &ExamplePipelineValid.example/3 + end + + def example(command, _params, _data) do + command + end + + def example_single(command) do + command + end + + def example_args(command, _params, _data, _custom_value) do + command + end + end + + ExamplePipelineValid.run() + rescue + FunctionClauseError -> flunk("Should not raise.") + end + end + + test "raises if invalid argument defined" do + assert_raise ArgumentError, fn -> + defmodule ExamplePipelineInvalid do + import Commandex + + command do + pipeline 1234 + end + end + end + end + end + + describe "halt/1" do + test "ignores remaining pipelines" do + command = RegisterUser.run(%{agree_tos: false}) + + refute command.success + assert command.errors === %{tos: :not_accepted} + end + end + describe "run/0" do test "is defined if no params are defined" do - assert Kernel.function_exported?(Commandex.GenerateReport, :run, 0) + assert Kernel.function_exported?(GenerateReport, :run, 0) - command = Commandex.GenerateReport.run() + command = GenerateReport.run() assert command.success assert command.data.total_valid > 0 assert command.data.total_invalid > 0 end test "is not defined if params are defined" do - refute Kernel.function_exported?(Commandex.RegisterUser, :run, 0) + refute Kernel.function_exported?(RegisterUser, :run, 0) end end diff --git a/test/support/generate_report.ex b/test/support/generate_report.ex index 7b5dbf5..7887418 100644 --- a/test/support/generate_report.ex +++ b/test/support/generate_report.ex @@ -1,4 +1,4 @@ -defmodule Commandex.GenerateReport do +defmodule GenerateReport do @moduledoc """ Example command that generates fake data. @@ -11,17 +11,18 @@ defmodule Commandex.GenerateReport do data :total_valid data :total_invalid - pipeline :calculate_valid - pipeline :calculate_invalid + pipeline :fetch_data + pipeline :calculate_results end - def calculate_valid(command, _params, _data) do + def fetch_data(command, _params, _data) do + # Not real. command - |> put_data(:total_valid, :rand.uniform(1_000_000)) end - def calculate_invalid(command, _params, _data) do + def calculate_results(command, _params, _data) do command - |> put_data(:total_invalid, :rand.uniform(1_000_000)) + |> put_data(:total_valid, 183_220) + |> put_data(:total_invalid, 781_215) end end diff --git a/test/support/register_user.ex b/test/support/register_user.ex index d5b23fe..56a40e0 100644 --- a/test/support/register_user.ex +++ b/test/support/register_user.ex @@ -1,4 +1,4 @@ -defmodule Commandex.RegisterUser do +defmodule RegisterUser do @moduledoc """ Example command that registers a user. """