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
# ...
end
Each 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
# ...
end
The 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
# ...
end
The 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
# ...
end
At 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
end
Now 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
end
The main Mnemonix
module just has to use the builder, and we're pretty much done:
defmodule Mnemonix do
use Mnemonix.Builder
end
The 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.