defmodule CoderRing do
@moduledoc File.read!("README.md")
import Ecto.Query
alias CoderRing.{Code, Memo}
alias Ecto.Changeset
require Logger
# 32 (readable) chars for codes: 2-9, A-Z (minus I and O)
@chars ~w(V S E 2 9 3 A H 7 Q 6 R 4 B T 5 C L X G J Z F P D W U K Y M 8 N)
# Default length of base code.
@default_base_length 4
# How many codes to insert at a time when populating codes table.
@chunk_count 100_000
@doc "Invoke the `name` ring with the given `message`."
@callback call(name :: atom, message :: any) :: any
defstruct base_length: @default_base_length, blacklist: nil, memo: nil, name: nil, repo: nil
@typedoc """
* `:base_length` - Number of characters to use in the base code.
* `:blacklist` - Set this to `:english` to use the `Expletive` package's
English word blacklist. Codes with occurrences of these words will be
skipped in the database seeding step.
* `:memo` - State-variable data, synced to the "code_memos" table.
* `:name` - Name of this coder ring: .
* `:repo` - Ecto.Repo module to use.
"""
@type t :: %CoderRing{
base_length: non_neg_integer,
blacklist: atom,
memo: Memo.t() | nil,
name: atom,
repo: module
}
defmacro __using__(opts) do
otp_app = opts[:otp_app] || raise ":otp_app option is required."
module = opts[:module]
# quote bind_quoted: [module: opts[:module], otp_app: otp_app] do
quote do
import unquote(__MODULE__)
@behaviour unquote(__MODULE__)
@module unquote(module) || __MODULE__
@doc """
List all configured rings.
Memos will not be loaded. Use `CoderRing.load_memo/1` to fetch state
from the database.
"""
@spec rings :: [CoderRing.t()]
def rings, do: CoderRing.rings(unquote(otp_app), @module)
@doc "Get a ring by its name."
@spec ring(atom) :: CoderRing.t() | nil
def ring(name), do: CoderRing.ring(unquote(otp_app), @module, name)
# @impl unquote(__MODULE__)
def call(name, message) do
# Lame logic where memo is fetched & dropped every time.
# Deserves to be overridden.
{reply, _state} =
name
|> ring()
|> CoderRing.load_memo()
|> CoderRing.invoke(message)
reply
end
@doc """
Get the next code in the ring.
## Options
* `:bump` - If `true`, the uniquizer will be incremented and the ring
cycle reset. This should be used during a retry if a duplicate code
returned. (This should only happen if a previously-used "extra"
string is used after switching to a different one in between.)
"""
@spec get_code(atom, keyword) :: String.t()
def get_code(name, opts \\ []), do: call(name, {:get_code, opts})
@doc "Reset the ring from the beginning."
@spec reset(atom) :: :ok
def reset(name), do: call(name, :reset)
@doc """
For each ring, seed its data if it hasn't already been done.
See `CoderRing.populate/2`.
"""
@spec populate_rings_if_empty(keyword) :: :ok
def populate_rings_if_empty(opts \\ []) do
Enum.each(rings(), &CoderRing.populate_if_empty(&1, opts))
end
defoverridable call: 2
end
end
@doc """
Make a new ring struct.
## Options
* `:name` - Ring name atom. Required.
* `:base_length` - Number of characters for the base code, 1-4. Default: 4
* `:repo` - `Ecto.Repo` module to use. Required.
* `:expletive_blacklist` - `Expletive` blacklist to use: `:english`,
`:international` or `nil`. Note that, if enabled, the `expletive` package
must be added as a dependency in your application. Default: `nil`
"""
@spec new(keyword) :: t
def new(opts) do
name = opts[:name] || raise ":name option is required."
is_atom(name) || raise ":name must be an atom."
base_length = opts[:base_length] || @default_base_length
base_length in 1..4 || raise "Only :base_length 1 and 4 are supported."
repo = opts[:repo] || raise ":repo must be an Ecto.Repo module."
bl = opts[:expletive_blacklist]
bl in [nil, :english, :international] || raise "Invalid expletive_blacklist: #{inspect(bl)}"
%CoderRing{
base_length: base_length,
blacklist: bl,
memo: nil,
name: name,
repo: repo
}
end
@doc """
List all configured rings.
Memos will be unloaded. Use `CoderRing.load_memo/1` to fetch state from the
database.
"""
@spec rings(atom, module) :: [t]
def rings(otp_app, mod) do
config = Application.get_env(otp_app, mod) || []
Enum.map(config[:rings] || [], fn {name, opts} ->
[name: name, repo: config[:repo]]
|> Keyword.merge(opts)
|> new()
end)
end
@doc "Get a ring for `module` under `otp_app` by its `name`."
@spec ring(atom, module, atom) :: t | nil
def ring(otp_app, mod, name) do
Enum.find(rings(otp_app, mod), &(&1.name == name))
end
@doc "Invoke the functionality identified by `message`. Memo should be loaded."
@spec invoke(t, message :: any) :: {reply :: any, t}
def invoke(ring, :reset) do
{:ok, do_reset(ring)}
end
def invoke(%{memo: memo, name: name, repo: repo} = ring, {:get_code, opts}) do
bump = opts[:bump] || false
extra = opts[:extra] || ""
memo_cs =
if extra != memo.extra do
# Caller has new extra string. Start ring over so we have a fresh set.
reset_memo_change(ring)
else
if bump do
Logger.warn("CoderRing #{name}: Bumping uniquizer.")
reset_memo_change(ring, memo.uniquizer_num + 1)
else
Memo.changeset(memo, %{})
end
end
{:ok, {base, max_pos, uniquizer_num}} =
repo.transaction(fn ->
{max, uniquizer_num} = get_max(%{ring | memo: Changeset.apply_changes(memo_cs)})
r_pos = Enum.random(0..max.position)
r = repo.one!(from Code, where: [name: ^to_string(name), position: ^r_pos])
r |> Code.changeset(%{value: max.value}) |> repo.update!()
max |> Code.changeset(%{value: r.value}) |> repo.update!()
{r.value, max.position, uniquizer_num}
end)
uniquizer = if uniquizer_num == 0, do: "", else: integer_to_string(uniquizer_num - 1)
code = "#{extra}#{uniquizer}#{base}"
args = %{extra: extra, uniquizer_num: uniquizer_num, last_max_pos: max_pos}
memo = memo_cs |> Memo.changeset(args) |> repo.update!()
{code, %{ring | memo: memo}}
end
# Get the next code to be used as "max" and a possibly updated uniquizer_num.
@spec get_max(t) :: {Code.t(), non_neg_integer}
def get_max(%{memo: %{uniquizer_num: un, last_max_pos: last_max_pos}, name: name} = ring) do
{max_pos, un} =
case last_max_pos do
0 -> {code_count(ring) - 1, un + 1}
_ -> {last_max_pos - 1, un}
end
{ring.repo.one!(from Code, where: [name: ^to_string(name), position: ^max_pos]), un}
end
# Convert a integer to a string, with character set matching @chars.
@spec integer_to_string(non_neg_integer) :: String.t()
defp integer_to_string(int) do
int
|> Integer.to_string(32)
|> String.replace(~w(0 1 I O), fn
"0" -> "X"
"1" -> "W"
"I" -> "Y"
"O" -> "Z"
end)
end
# Get the approximate total number of codes in the ring.
# (Some may be filtered for profanity.)
@spec appx_code_count(t) :: non_neg_integer
defp appx_code_count(%{base_length: base_len}) do
round(:math.pow(length(@chars), base_len))
end
# Get the exact number of codes in the codes table by name.
@spec code_count(t) :: non_neg_integer
defp code_count(%{name: name, repo: repo}) do
repo.aggregate(from(Code, where: [name: ^to_string(name)]), :count)
end
# Reset the ring cycle.
@spec do_reset(t) :: t
defp do_reset(%{repo: repo} = ring) do
%{ring | memo: ring |> reset_memo_change() |> repo.update!()}
end
# Create a changeset on `memo`, resetting the ring cycle.
@spec reset_memo_change(t) :: Changeset.t()
defp reset_memo_change(%{memo: memo} = ring, uniquizer_num \\ 0) do
lmp = code_count(ring)
Memo.changeset(memo, %{uniquizer_num: uniquizer_num, last_max_pos: lmp})
end
@doc "Load the ring into the database if it isn't already there."
@spec populate_if_empty(t, keyword) :: t
def populate_if_empty(ring, opts \\ [])
def populate_if_empty(%{memo: nil} = ring, opts), do: populate(ring, opts)
def populate_if_empty(ring, _), do: ring
@doc "Get the memo from db for the given ring `name`."
@spec get_memo(t) :: Memo.t() | nil
def get_memo(%{name: name, repo: repo}) do
repo.one(from Memo, where: [name: ^to_string(name)])
end
@doc "Load the relevant memo from the database into `ring`."
@spec load_memo(t) :: t
def load_memo(ring), do: %{ring | memo: get_memo(ring)}
@doc """
Load the `ring` into the database.
All `opts` are passed along to `Ecto.Repo` calls to query and insert.
"""
@spec populate(t, keyword) :: t
def populate(%{name: name, repo: repo} = ring, opts \\ []) do
Logger.info("CoderRing #{name}: Loading appx #{appx_code_count(ring)} codes...")
# Create the memo record so the code records' foreign keys link up.
memo = [name: to_string(name)] |> Memo.new() |> repo.insert!(opts)
count = insert_chunks(ring)
# last_max_pos will never be count again. This is the last position in
# the database plus 1 in order to get the counting started correctly.
memo = memo |> Memo.changeset(%{last_max_pos: count}) |> repo.update!()
Logger.info("CoderRing #{name}: Ready with #{count} codes.")
%{ring | memo: memo}
end
# Insert all possible codes in batches to conserve memory.
# Return the total number of code records inserted.
@spec insert_chunks(t, keyword) :: non_neg_integer
defp insert_chunks(%{blacklist: bl, name: name, repo: repo} = ring, opts \\ []) do
expletive_config = bl && Expletive.configure(blacklist: apply(Expletive.Blacklist, bl, []))
ring
|> codes_stream()
|> Stream.chunk_every(@chunk_count)
|> Enum.reduce(0, fn chunk, count ->
{values_str, count} =
Enum.reduce(chunk, {"", count}, fn code, {acc, acc_count} ->
if expletive_config && Expletive.profane?(code, expletive_config),
do: {acc, acc_count},
else: {"('#{name}', #{acc_count}, '#{code}')," <> acc, acc_count + 1}
end)
str = String.trim_trailing(values_str, ",")
repo.query!("INSERT INTO codes (name, position, value) VALUES #{str}", [], opts)
count
end)
end
@doc """
For each ring, seed its data if it hasn't already been done.
See `populate/2`.
"""
@spec populate_rings_if_empty([t], keyword) :: :ok
def populate_rings_if_empty(rings, opts \\ []) do
Enum.each(rings, &populate_if_empty(&1, opts))
end
# Create a stream, generating the full list of all possible codes.
@spec codes_stream(t) :: Enumerable.t()
def codes_stream(%{base_length: base_length}) do
base = length(@chars)
last_idx = base_length - 1
Stream.map(0..(base ** base_length - 1), fn num ->
{out, _} =
Enum.reduce(last_idx..0, {"", num}, fn char, {acc, rem} ->
char_val = base ** char
val = div(rem, char_val)
rem = rem - val * char_val
{acc <> Enum.at(@chars, val), rem}
end)
out
end)
end
end