Mnemonix is a key/value adapter library that employs a repository pattern. I wanted to support a few things with it:
- Multiple 'feature sets'--collections of functions that may or may not be implemented for an adapter
- A single unified API inside the core module incorporating all feature sets
- Compile-time and runtime support for configuring repositories
- Application-controlled and DIY repo supervision
- The ability for library users to build custom modules with only particular feature sets
- The ability for library users to build custom 'singleton' modules that don't require the 'repo' param
- Never repeating the implementation of a function in the source code while realizing all of the above
With a little metaprogramming, I pulled it off. 😁 Currently you can do most of these things, in the upcoming version you will be able to do all of them (start_link stuff isn't included when you use things yet, you have to define it on 3rd party modules yourself):
##
# Simple usage
##
{:ok, repo} = Mnemonix.start_link(Mnemonix.Adapters.SomeAdapter)
# Use the original implementation of a function from a specific feature set
Mnemonix.Features.Map.get(repo, :key)
# Or just use the federated API
Mnemonix.get(repo, :key)
##
# Module usage
##
# Create a repo module with just map functions
defmodule MyMapRepo do
use Mnemonix.Features.Map
end
{:ok, repo} = MyMapRepo.start_link(Mnemonix.Adapters.SomeAdapter)
MyMapRepo.get(repo, :key)
# Or create a federated repo module with all functions
defmodule MyRepo do
use Mnemonix.Builder
end
{:ok, repo} = MyRepo.start_link(Mnemonix.Adapters.SomeAdapter)
MyRepo.get(repo, :key)
##
# Singleton usage
##
# Create a singleton module with just map functions
defmodule MyMapSingleton do
use Mnemonix.Features.Map, singleton: true
end
MyMapSingleton.start_link(Mnemonix.Adapters.SomeAdapter)
MyMapSingleton.get(:key)
# Or create a federated singleton module with all functions
defmodule MySingleton do
use Mnemonix.Builder, singleton: true
end
{:ok, repo} = MySingleton.start_link(Mnemonix.Adapters.SomeAdapter)
MySingleton.get(:key)
##
# Supervison usage
##
# Supervise a bunch of repos
repos = [
{Mnemonix.Adapters.SomeAdapter1, [name: Foo]},
{Mnemonix.Adapters.SomeAdapter2, [name: Bar]},
]
Mnemonix.Store.Supervisor.start_link(repos)
Mnemonix.get(Foo, :key)
defmodule BarSingleton do
use Mnemonix.Builder, singleton: Bar
end
BarSingleton.get(:key)
##
# Application usage
##
# Have Mnemonix manage repos rather than call start_link yourself
config :mnemonix, stores: [Fizz, Buzz]
# opts are empty because `name: Fizz` is implied
config :mnemonix, Fizz, {Mnemonix.Adapters.SomeAdapter, []}
# Buzz can remain unconfigured, it will just use a default adapter
Application.ensure_started(:mnemonix)
Mnemonix.get(Fizz, :key)
Mnemonix.get(Buzz, :key)All the code is available here. However, here's a quick rundown of the pieces relevant to the repository definitions. It's less than 100 SLOC but spread out over several files and macros:
Each feature set module defines its functions as normal, always requiring the repository as the first argument.
defmodule Mnemonix.Features.SomeFeature do
# ...
def operation(repo, other, params) do
# ...
end
# ...
endEach feature set defines a __using__ macro that instructs host modules that use it to use the Mnemonix.Feature registry macro, passing through options and the name of the feature set module.
defmodule Mnemonix.Features.SomeFeature do
# ...
defmacro __using__(opts) do
quote do
use Mnemonix.Feature, [unquote_splicing(opts), module: unquote(__MODULE__)]
end
end
# ...
endThe first time that the Mnemonix.Feature registry macro is used it ensures the feature set has an accumulating :features module attribute and a @before_compile hook to call the Mnemonix.Feature.Registry.
Every use adds the feature set in question to the :features accumulator.
defmodule Mnemonix.Feature do
# ...
defmacro __using__(opts) do
quote do
@before_compile Registry
Module.register_attribute(__MODULE__, :features, accumulate: true)
Module.put_attribute(__MODULE__, :features, Keyword.pop(unquote(opts), :module))
end
end
# ...
endThe before compile hook in Mnemonix.Feature.Registry takes every registered feature set module and uses the Mnemonix.Delegator with it.
defmodule Mnemonix.Feature do
# ...
defmodule Registry do
defmacro __before_compile__(env) do
for {feature, opts} <- Module.get_attribute(env.module, :features) do
quote do
use Mnemonix.Delegator, [unquote_splicing(opts), module: unquote(feature)]
end
end
end
end
# ...
endAt this point, we can define unlimited feature set modules and use them into unlimited host modules. At compile time, every host module will invoke the Mnemonix.Delegator for every feature set module it has used, passing through all options it was used with.
So, what does the Mnemonix.Delegator do? It makes all functions on the feature set module available on the host module.
Furthermore, it checks if the feature set module was used with the :singleton option. If the option was true, it assumes that the name of the host module can be used as the leading repo argument to all functions. If the options was set to something other than true, it assumes that that value can be used instead. Either way, it fills the first argument in for you during delegation.
defmodule Mnemonix.Delegator do
defmacro __using__(opts) do
module = Keyword.fetch!(opts, :module)
singleton = opts[:singleton]
for {name, arity} <- module.__info__(:functions),
params = arity_to_params(if singleton, do: arity - 1, else: arity)
do
quote location: :keep do
if unquote(singleton) do
@repo if unquote(singleton) == true, do: __MODULE__, else: unquote(singleton)
def unquote(name)(unquote_splicing(params)) do
unquote(module).unquote(name)(@repo, unquote_splicing(params))
end
else
def unquote(name)(unquote_splicing(params)) do
unquote(module).unquote(name)(unquote_splicing(params))
end
end
end
end
end
def arity_to_params(0) do
[]
end
def arity_to_params(arity) when is_integer arity and arity > 0 do
for num <- 1..arity, do: Macro.var(:"arg#{num}", nil)
end
endNow that our feature sets can be used individually, we offer a Mnemonix.Builder utility that adds them all in one swell foop:
defmodule Mnemonix.Builder do
defmacro __using__(opts) do
quote location: :keep do
use Mnemonix.Features.SomeFeature1, unquote(opts)
use Mnemonix.Features.SomeFeature2, unquote(opts)
# ...
end
end
endThe main Mnemonix module just has to use the builder, and we're pretty much done:
defmodule Mnemonix do
use Mnemonix.Builder
endThe implementation of Mnemonix.Application and Mnemonix.Store.Supervisor aren't included here, since they dive into specifics of Mnemonix that have nothing to do with the repo system, aside from the fact that they leverage the start_links made available by the mechanisms above.