Skip to content

Instantly share code, notes, and snippets.

@christhekeele
Last active July 2, 2017 12:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save christhekeele/343edf61a90d57df33eea8763bc2e323 to your computer and use it in GitHub Desktop.
Save christhekeele/343edf61a90d57df33eea8763bc2e323 to your computer and use it in GitHub Desktop.
How Mnemonix's repository pattern is implemented

Synopsis

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:

Feature Sets

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

Feature Set Registry

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.

Function Delegation

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

All together now

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment