lib/ex_tenant.ex

defmodule ExTenant do
  @moduledoc """
    Call the `use ExTenant` macro to inject all the required behaviour into your
    Application Repo module to enable all multi-tenancy functions.

    Setup
    -----

    In the application `Config` file the `repo` and `tenanted_field` settings need to be configured.
    ExTenant will default the tenanted_field setting to be `tenant_id`.

    Config
    ------

      config :ex_tenant,
        tenant_repo: MyAppRepo,
        tenanted_field: "tenant_id",
        get_tenant_from_subdomain_func: &your_application_get_tenant_from_subdomain_function/1

    - If the `repo` is not configured ExTenant will raise an exception.
    - the `get_tenant_from_subdomain_func` is optional if you decide to use the Plug - see example below

    Repo
    ----

    In your application `Repo` file call the `use` macro as per this example

      def YourApplication.Repo do
        use ExTenant,
          config: [
            otp_app: :name_of_your_elixir_phoenix_app_as_atom,
            adapter: Ecto.Adapters.Postgres,
            tenanted_field: "tenant_id"
          ]
      end

    In order to get the `tenant_id` into the progress dictionary in Phoenix
    we recommend to use a plug - there you should retrieve the `tenant name`
    from something like the `sub domain` in the url. Then using the `tenant name`
    retrieve the `tenant`, and call:

    - Repo.put_tenant_id(tenant_id)

    To insert the `tenant_id` into the process dictionary.

    Plug
    ----

    - An example of how a Plug can be used to insert the tenant_id into the process dictionary

    - Take a look at the module `ExTenant.SubdomainPlug`


    From here all your Repo callbacks (Repo.get/Repo.one/Repo.all etc) will
    have a where clause applied to them with the `tenant_id` injected into the clause.

    In order to set the `tenant_id` on `insert` and `update` functions the tenant_id
    needs to be inserted into the attributes to be inserted. The `tenanted_schema`
    macro & the `tenanted()` function inserts the correct `belongs_to` tenanted foreign
    key based relationship.

    Further the `cast_tenant` method overloads the standard `Ecto.Changeset.cast` function
    by injecting the `tenant_id` into the params and allowed keys. This function raises
    exceptions when the `Repo` was not configured correctly in `config.exs` and also
    if the `tenant_id` value is not set in the process_dictionary.

    Ecto Schema/Changeset
    ---------------------

      defmodule Post do
        use ExTenant.Schema
        use ExTenant.Changeset

        tenanted_schema "posts" do
          field(:name, :string)
          field(:body, :string)

          tenanted()
        end

        defp changeset(attrs) do
          %__MODULE__{}
          |> cast_tenanted(params, [:name, :body])
        end
      end

    NB: If the `tenant_id` is not set in the changeset, Repo.insert/update callbacks
    will raise a `Postgrex.Error` (not_null_violation)

    Migrations
    ----------

    - documentation to be added.

  """

  @doc false
  defmacro __using__(opts) do
    config = Keyword.get(opts, :config)
    tenanted_field = config[:tenanted_field]
    application = config[:otp_app]
    database_adapter = config[:adapter]
    tenant_key = {__CALLER__.module, String.to_atom(tenanted_field)}

    quote do
      import ExTenant
      require Ecto.Query
      use Ecto.Repo, otp_app: unquote(application), adapter: unquote(database_adapter)

      generate_put_tenant_function(unquote(tenant_key))
      generate_get_tenant_function(unquote(tenant_key))
      generate_default_options_callback(unquote(tenanted_field))
      generate_inject_tenant_id_callback(unquote(tenanted_field))
      generate_prepare_query_callback(unquote(tenanted_field))
    end
  end

  @doc false
  defmacro generate_prepare_query_callback(tenanted_field) do
    quote do
      def prepare_query(operation, query, opts) do
        tenant_field = String.to_existing_atom(unquote(tenanted_field))

        cond do
          opts[:skip_tenant_id] || opts[:schema_migration] ->
            {query, opts}

          tenant_id = opts[tenant_field] ->
            filtered_where =
              query
              |> Ecto.Query.where(
                [q],
                inject_where(q, tenant_field, tenant_id, :==)
              )

            {filtered_where, opts}

          true ->
            raise ExTenant.TenantNotSetError
        end
      end
    end
  end

  @doc false
  defmacro generate_inject_tenant_id_callback(tenant_field) do
    quote do
      def inject_tenant_id(params) do
        string_params = for {key, val} <- params, into: %{}, do: {"#{key}", val}

        string_params
        |> Map.put(unquote(tenant_field), get_tenant_id())
      end
    end
  end

  @doc false
  defmacro generate_default_options_callback(tenanted_field) do
    quote do
      def default_options(_operation) do
        [
          {
            String.to_existing_atom(unquote(tenanted_field)),
            get_tenant_id()
          }
        ]
      end
    end
  end

  @doc false
  defmacro generate_put_tenant_function(tenant_key) do
    quote do
      def put_tenant_id(tenant_id) do
        Process.put(unquote(tenant_key), tenant_id)
      end
    end
  end

  @doc false
  defmacro generate_get_tenant_function(tenant_key) do
    quote do
      def get_tenant_id() do
        Process.get(unquote(tenant_key))
      end
    end
  end

  @doc false
  defmacro inject_where(t, f, v, sign) do
    {sign, [context: Elixir, import: Kernel],
     [
       {:field, [], [t, {:^, [], [f]}]},
       {:^, [], [v]}
     ]}
  end
end