Current section
Files
Jump to
Current section
Files
lib/triplex.ex
defmodule Triplex do
@moduledoc """
Triplex is a simple schema/database tenant manager for elixir.
The main objetive of it is to make a little bit easier to manage tenants
through postgres db schemas or equivalents, executing queries and commands
inside and outside the tenant without much boilerplate code.
## Configuring
All you need to do in your project to start using it is to configure the Repo
you will use to execute the database commands with:
config :triplex, repo: ExampleApp.Repo
## Creating tables and schemas
To create a table inside your tenant you can use the task
`mix triplex.gen.migration` or move a normal migration to the
`priv/YOUR_REPO/tenant_migrations` folder.
The schemas look the same way, nothing to change.
To run the tenant migrations, use the task `mix triplex.migrate`, it will
migrate all your existent tenants for you.
## Managing your tenants
You can use the functions `Triplex.create/1`, `Triplex.drop/1` and
`Triplex.rename/2` to manage your tenants. You may want to use them on your
tenant operations.
PS.: we encourage you to use an unchangable field as your tenant name, that
way you will not need to rename your tenant when changing the field.
## Using the tenant
Finally, but not less important, you must call the function
`Triplex.put_tenant\2` to any changeset, schema or query you are executing
on your "tenanted" tables.
"""
import Mix.Ecto, only: [build_repo_priv: 1]
alias Ecto.{
Adapters.SQL,
Migrator
}
@doc """
Sets the tenant as the prefix for the changeset, schema or anything
queryable.
## Examples
defmodule User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :name, :string
end
def changeset(user, params) do
cast(user, params, [:name])
end
end
import Ecto.Query
# For the changeset
%User{}
|> User.changeset(%{name: "John"})
|> Triplex.put_tenant("my_tenant")
|> Repo.insert!()
# For the schema
User
|> Triplex.put_tenant("my_tenant")
|> Repo.all()
# For the queries
from(u in User, select: count(id))
|> Triplex.put_tenant("my_tenant")
|> Repo.all()
"""
def put_tenant(%Ecto.Changeset{} = changeset, tenant) do
new_changes =
changeset.changes
|> Map.to_list
|> Enum.reduce(%{}, fn({key, value}, acc) ->
new_value = case {key, value} do
{key, value} when key in [:__struct__, :__meta__] -> value
{_, %Ecto.Changeset{} = changeset} -> put_tenant(changeset, tenant)
{_, value} -> value
end
Map.put(acc, key, new_value)
end)
changeset = %{changeset | changes: new_changes}
%{changeset | data: put_tenant(changeset.data, tenant)}
end
def put_tenant(%{__struct__: _, __meta__: _} = schema, tenant) do
schema
|> Map.to_list
|> Enum.reduce(%{}, fn({key, value}, acc) ->
new_value = case {key, value} do
{key, value} when key in [:__struct__, :__meta__] -> value
{_, %{__struct__: _, __meta__: _} = struct} ->
put_tenant(struct, tenant)
{_, value} -> value
end
Map.put(acc, key, new_value)
end)
|> Ecto.put_meta(prefix: tenant)
end
def put_tenant(queryable, tenant) do
if Ecto.Queryable.impl_for(queryable) do
query = Ecto.Queryable.to_query(queryable)
Map.put(query, :prefix, tenant)
else
queryable
end
end
@doc """
Execute the given function with the given tenant set.
"""
def with_tenant(tenant, func) do
old_tenant = current_tenant()
put_current_tenant(tenant)
try do
func.()
after
put_current_tenant(old_tenant)
end
end
@doc """
Return the current tenant, set by `put_current_tenant/1`.
"""
def current_tenant, do: Process.get(__MODULE__)
@doc """
Sets the current tenant in the current process.
"""
def put_current_tenant(nil), do: Process.put(__MODULE__, nil)
def put_current_tenant(value) when is_binary(value) do
Process.put __MODULE__, value
end
def put_current_tenant(_) do
raise ArgumentError, "put_current_tenant/1 only accepts binary tenants"
end
@doc """
Returns the list of reserverd tenants.
By default, there are some limitations for the name of a tenant depending on
the database, like "public" or anything that start with "pg_".
You also can configure your own reserved tenant names if you want with:
config :triplex, reserved_tenants: ["www", "api", ~r/^db\d+$/]
Notice that you can use regexes, and they will be applied to the tenant
names.
"""
def reserved_tenants do
config = Application.get_env(:triplex, :reserved_tenants) || []
[nil, "public", "information_schema", ~r/^pg_/ | config]
end
@doc """
Returns if the given tenant is reserved or not.
"""
def reserved_tenant?(tenant) do
Enum.any? reserved_tenants(), fn (i) ->
if Regex.regex?(i) do
Regex.match?(i, tenant)
else
i == tenant
end
end
end
@doc """
Creates the given tenant on the given repo.
Besides creating the database itself, this function also loads their
structure executing all migrations from inside
`priv/YOUR_REPO/tenant_migrations` folder.
If the repo is not given, it uses the one you configured.
"""
def create(tenant, repo \\ default_repo()) do
create_schema(tenant, repo, &(migrate(&1, &2)))
end
@doc """
Drops the given tenant on the given repo.
If the repo is not given, it uses the one you configured.
"""
def drop(tenant, repo \\ default_repo()) do
if reserved_tenant?(tenant) do
{:error, reserved_message(tenant)}
else
case SQL.query(repo, "DROP SCHEMA \"#{tenant}\" CASCADE", []) do
{:error, e} ->
{:error, Postgrex.Error.message(e)}
result -> result
end
end
end
@doc """
Renames the given tenant on the given repo.
If the repo is not given, it uses the one you configured.
"""
def rename(old_tenant, new_tenant, repo \\ default_repo()) do
if reserved_tenant?(new_tenant) do
{:error, reserved_message(new_tenant)}
else
sql = "ALTER SCHEMA \"#{old_tenant}\" RENAME TO \"#{new_tenant}\""
case SQL.query(repo, sql, []) do
{:error, e} ->
{:error, Postgrex.Error.message(e)}
result -> result
end
end
end
@doc """
Returns all the tenants on the given repo.
If the repo is not given, it uses the one you configured.
"""
def all(repo \\ default_repo()) do
sql = """
SELECT schema_name
FROM information_schema.schemata
"""
%Postgrex.Result{rows: result} = SQL.query!(repo, sql, [])
result
|> List.flatten
|> Enum.filter(&(!reserved_tenant?(&1)))
end
@doc """
Returns if the tenant exists or not on the given repo.
If the repo is not given, it uses the one you configured.
"""
def exists?(tenant, repo \\ default_repo()) do
if reserved_tenant?(tenant) do
false
else
sql = """
SELECT COUNT(*)
FROM information_schema.schemata
WHERE schema_name = $1
"""
%Postgrex.Result{rows: [[count]]} = SQL.query!(repo, sql, [tenant])
count == 1
end
end
@doc """
Migrates the given tenant.
If the repo is not given, it uses the one you configured.
"""
def migrate(tenant, repo \\ default_repo()) do
try do
{:ok, Migrator.run(repo, migrations_path(repo), :up,
all: true,
prefix: tenant)}
rescue
e in Postgrex.Error ->
{:error, Postgrex.Error.message(e)}
end
end
@doc """
Return the path for your tenant migrations.
If the repo is not given, it uses the one you configured.
"""
def migrations_path(repo \\ default_repo()) do
if repo do
Path.join(build_repo_priv(repo), "tenant_migrations")
else
""
end
end
defp create_schema(tenant, repo, func) do
if reserved_tenant?(tenant) do
{:error, reserved_message(tenant)}
else
case SQL.query(repo, "CREATE SCHEMA \"#{tenant}\"", []) do
{:ok, _} = result ->
if func do
func.(tenant, repo)
else
result
end
{:error, e} ->
{:error, Postgrex.Error.message(e)}
end
end
end
defp reserved_message(tenant) do
"""
You cannot create the schema because \"#{inspect(tenant)}\" is a reserved
tenant
"""
end
defp default_repo do
Application.get_env(:triplex, :repo)
end
end