From 1b944e179c5ae27dd4ea478c7a32b44098720266 Mon Sep 17 00:00:00 2001 From: Alex McLain Date: Tue, 27 Dec 2022 21:57:58 -0800 Subject: [PATCH] Initial import --- .formatter.exs | 4 ++ .gitignore | 26 ++++++++ LICENSE.txt | 21 +++++++ README.md | 16 +++++ lib/di.ex | 147 +++++++++++++++++++++++++++++++++++++++++++ mix.exs | 47 ++++++++++++++ test/test_helper.exs | 1 + 7 files changed, 262 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 lib/di.ex create mode 100644 mix.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fc1bde --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +resolve-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..80e7792 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright 2022 Alex McLain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..efd8b25 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Resolve + +Dependency injection and resolution at compile time or runtime. + +## Installation + +The package can be installed by adding `resolve` to your list of dependencies +in `mix.exs`: + +```elixir +def deps do + [ + {:resolve, "~> 0.0.1"} + ] +end +``` diff --git a/lib/di.ex b/lib/di.ex new file mode 100644 index 0000000..7e615ad --- /dev/null +++ b/lib/di.ex @@ -0,0 +1,147 @@ +defmodule DI do + @moduledoc """ + Dependency injection + + ## Usage + + Include DI in the module that requires dependency injection with `use DI`. + Any place in that module that might need a dependency injected can then use + `di()` to allow another module to be injected. The module passed to + `di/1` will be used if another module isn't injected. + + ```elixir + defmodule MyInterface do + use DI + + def some_command, do: di(__MODULE__).some_command + end + ``` + + ### Configuration + + DI can be configured in the project's `config.exs`. + + **Opts** + - `compile` - (false) - Sets the mappings at compile time and doesn't start \ + the process that allows them to be modified at runtime. \ + This method is more secure and more performant. \ + Compiling is intended for production and runtime is \ + intended for unit tests. + - `mappings` - `[]` - A two element tuple of the modules to map from and to: \ + `{from, to}` + + **Example** + + + ```elixir + config :, di: %{ + compile: true, + mappings: [ + {OriginalModule, InjectedModule}, + ] + } + ``` + + ### Runtime + + Dependencies can be injected at runtime with `inject/2`. This is intended for + unit testing, but not necessarily limited to it. Runtime mappings will be + less performant compared to compiled mappings, as each lookup goes through + a read-optimized ETS table. + + ```elixir + DI.inject(OriginalModule, InjectedModule) + ``` + + Modules can also be defined directly in a block, which can be helpful if they + are only needed for certain tests. + + ```elixir + DI.inject(Port, quote do + def open(_name, _opts), do: self() + + def close(_port), do: :ok + + def command(_port, _data), do: :ok + end) + ``` + """ + + defmacro __using__(_) do + quote do + @doc false + defdelegate di(module), to: DI + end + end + + @compile? !!Application.compile_env(:resolve, :di, [])[:compile] + + @mappings \ + Application.compile_env(:resolve, :di, %{}) + |> Map.get(:mappings, []) + |> Enum.into(%{}) + + @doc """ + Flag a module as eligible for dependency injection. + + Defaults to `module` unless a new dependency is injected in its place. + """ + @spec di(module :: module) :: module + def di(module), do: di(module, @compile?) + + defp di(module, _compile? = true) do + @mappings[module] || module + end + + defp di(module, _compile? = false) do + ensure_ets_is_running() + + case :ets.lookup(:di, module) do + [] -> @mappings[module] || module + [{_, injected_module}] -> injected_module + end + end + + @doc """ + Inject a module in place of another one. + """ + @spec inject(target_module :: module, injected_module :: module) :: any + def inject(target_module, injected_module) when is_atom(injected_module) do + ensure_ets_is_running() + + :ets.insert(:di, {target_module, injected_module}) + + :ok + end + + def inject(target_module, module_body) do + unique_number = System.unique_integer([:positive]) + + {:module, injected_module, _, _} = + Module.create(:"Mock#{unique_number}", module_body, Macro.Env.location(__ENV__)) + + inject(target_module, injected_module) + end + + @doc """ + Revert this dependency to the original module. + + This function is idempotent and will not fail if DI already points to the + original module. + """ + @spec revert(module :: module) :: any + def revert(module) do + ensure_ets_is_running() + + :ets.delete(:di, module) + + :ok + end + + defp ensure_ets_is_running do + case :ets.whereis(:di) do + :undefined -> :ets.new(:di, [:public, :named_table, read_concurrency: true]) + table_id -> table_id + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..39afcbc --- /dev/null +++ b/mix.exs @@ -0,0 +1,47 @@ +defmodule Resolve.MixProject do + use Mix.Project + + def project do + [ + app: :resolve, + version: "0.0.1", + elixir: "~> 1.14", + description: description(), + package: package(), + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [] + end + + defp description do + """ + Dependency injection and resolution at compile time or runtime + """ + end + + defp package do + [ + licenses: ["MIT"], + links: %{"GitHub" => "https://github.com/amclain/resolve"}, + maintainers: ["Alex McLain"], + files: [ + "lib", + "mix.exs", + "LICENSE.txt", + "README.md", + ] + ] + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()