lib/ecto_sessions.ex

defmodule EctoSessions do
  @default_table_name "sessions"
  @default_extra_fields [
    {:field, [:user_id, :string]}
  ]

  @moduledoc """
  This lib implements a set of methods to help you handle the storage and
  access to database-backend sessions with ecto.

  In your application, use `EctoSessions`:

  ```elixir
  defmodule MyApp.Sessions do
    use EctoSessions,
      repo: MyApp.Repo,
      prefix: nil,
      table_name: #{inspect(@default_table_name)},
      extra_fields: #{inspect(@default_extra_fields)}
  end
  ```

  Parameters:

    - `repo`: your ecto repo module, required. Ex: `MyApp.Repo`.
    - `prefix`: ecto prefix, optional, default to `nil`.
    - `table_name`: The table name for these sessions.
      Create a module using `EctoSessions` for each table, ex: Sessions, ApiKeys, etc.
    - `extra_fields`: Extra, custom, high-level, fields (columns) for the session schema.

  See `EctoSessions.Migrations` for instructions on how to migrate your
  database.
  """

  defmacro __using__(opts) do
    ecto_sessions_module = __CALLER__.module

    repo = Keyword.fetch!(opts, :repo)
    prefix = Keyword.get(opts, :prefix, nil)
    table_name = Keyword.get(opts, :table_name, @default_table_name)
    extra_fields = Keyword.get(opts, :extra_fields, @default_extra_fields)

    quote do
      defmodule Config do
        use EctoSessions.Config,
          ecto_sessions_module: unquote(ecto_sessions_module)
      end

      defmodule Session do
        use EctoSessions.Session,
          config_module: unquote(Module.concat([ecto_sessions_module, Config])),
          table_name: unquote(table_name),
          extra_fields: unquote(Macro.escape(extra_fields))
      end

      import Ecto.Query
      alias EctoSessions.AuthToken

      @repo unquote(repo)

      def create_session(attrs \\ %{}) do
        Session.changeset(attrs)
        |> @repo.insert(prefix: unquote(prefix))
      end

      def create_session!(attrs \\ %{}) do
        Session.changeset(attrs)
        |> @repo.insert!(prefix: unquote(prefix))
      end

      def get_sessions_query(filters, options \\ []) do
        preload = Keyword.get(options, :preload, [])
        delete_query = Keyword.get(options, :delete_query, false)

        if delete_query do
          from(session in Session)
        else
          from(session in Session,
            preload: ^preload,
            order_by: [desc: session.inserted_at],
            select: %{
              session
              | is_expired:
                  is_nil(session.expires_at) or
                    session.expires_at <= ^DateTime.utc_now()
            }
          )
        end
        |> filter_session_query(filters)
      end

      def filter_session_query(query, filters) when is_list(filters) do
        filters = Keyword.put_new(filters, :status, :valid)

        Enum.reduce(
          filters,
          query,
          fn {field, value}, query_acc ->
            filter_session_query_by(query_acc, field, value)
          end
        )
      end

      def filter_session_query_by(query, :status, :all), do: query

      def filter_session_query_by(query, :status, :valid) do
        from(
          session in query,
          where:
            is_nil(session.expires_at) or
              session.expires_at > ^DateTime.utc_now()
        )
      end

      def filter_session_query_by(query, :status, :expired) do
        from(
          session in query,
          where:
            not is_nil(session.expires_at) and
              session.expires_at <= ^DateTime.utc_now()
        )
      end

      def filter_session_query_by(query, :status, status) do
        raise RuntimeError, "Invalid status #{status}"
      end

      def filter_session_query_by(query, :auth_token, nil) do
        from(session in query, where: false)
      end

      def filter_session_query_by(query, :auth_token, plaintext_auth_token) do
        auth_token_digest =
          AuthToken.get_digest(
            plaintext_auth_token,
            Config.get_hashing_algorithm(),
            Config.get_secret_salt()
          )

        from(
          session in query,
          where: session.auth_token_digest == ^auth_token_digest
        )
      end

      def filter_session_query_by(query, :plaintext_auth_token, plaintext_auth_token) do
        filter_session_query_by(query, :auth_token, plaintext_auth_token)
      end

      def filter_session_query_by(query, field_name, value) do
        from(
          session in query,
          where: field(session, ^field_name) == ^value
        )
      end

      def get_session(filters, options \\ []) when is_list(filters) do
        get_sessions_query(filters, options)
        |> @repo.one(prefix: unquote(prefix))
      end

      def get_session!(filters, options \\ []) do
        get_sessions_query(filters, options)
        |> @repo.one!(prefix: unquote(prefix))
      end

      def list_sessions(filters, options \\ []) do
        get_sessions_query(filters, options)
        |> @repo.all(prefix: unquote(prefix))
      end

      def list_valid_sessions(filters, options \\ []) do
        get_sessions_query(filters, options)
        |> @repo.all(prefix: unquote(prefix))
      end

      def refresh_session(session) do
        Session.changeset(session)
        |> update_session!()
      end

      def expire_session!(session) do
        Session.expire_changeset(session)
        |> update_session!()
      end

      def delete_session(session) do
        session
        |> @repo.delete(prefix: unquote(prefix))
      end

      def delete_session!(session) do
        session
        |> @repo.delete!(prefix: unquote(prefix))
      end

      def update_session!(changeset) do
        @repo.update!(changeset)
      end

      def count(filters \\ [], options \\ []) do
        get_sessions_query(filters, options)
        |> @repo.aggregate(:count, prefix: unquote(prefix))
      end

      def delete_expired() do
        {delete_count, _} =
          get_sessions_query([status: :expired], delete_query: true)
          |> @repo.delete_all(prefix: unquote(prefix))

        delete_count
      end
    end
  end

  @doc """
  Creates a session. `attrs` is a map that contains `EctoSessions.Session` attributes,
  where the keys are atoms.

  Uses `Ecto.Repo.insert`

  ## Examples

      iex> create_session(%{user_id: "1234", data: %{device_name: "Sample Browser"}})
      %Session{
        user_id: "1234",
        data: %{
          device_name: "Sample Browser",
          auth_token: "plaintext-auth-token"
          auth_token_digest: "hashed-token"
        }
      }
  """
  @callback create_session(attrs :: map) :: Ecto.Schema.t()

  @doc """
  Same as `create_session/1` but using `Ecto.Repo.insert!`.
  """
  @callback create_session!(filters :: map, options :: list) :: Ecto.Schema.t()

  @doc """
  Retrieves a query to the sessions.

  Options:
   - `delete_query`: Boolean that indicates a delete query a
     should be returned. Instead of a select one (the default: false).
   - `preload`: Shorthand for `preload` query argument.

  """
  @callback get_sessions_query(attrs :: any) :: Ecto.Query.t()

  @doc """
  Filters a session query.
  """
  @callback filter_session_query(query :: Ecto.Queryable.t(), filters :: any) ::
              Ecto.Queryable.t()

  @doc """
  Filters a session query by the given argument.
  """
  @callback filter_session_query_by(query :: any, filters :: any) :: Ecto.Queryable.t()

  @doc """
  Retrieves a session from the database.
  """
  @callback get_session(filters :: any, options :: list) :: {atom, Ecto.Schema.t()}

  @doc """
  Retrieves a session from the database using `Repo.one!`
  """
  @callback get_session!(filters :: any, options :: list) :: Ecto.Schema.t()

  @doc """
  Retrieve sessions matching the provided filters.
  """
  @callback list_sessions(filters :: any, options :: list) :: list(Ecto.Queryable.t())

  @doc """
  Retrieve valid sessions matching the provided filters.
  """
  @callback list_valid_sessions(filters :: any, options :: list) :: list(Ecto.Queryable.t())

  @doc """
  Given a session, ensure `expires_at` is updated according to the `EctoSessions.Config`.
  """
  @callback refresh_session(Ecto.Schema.t()) :: Ecto.Schema.t()

  @doc """
  Deletes the session using `Repo.delete`.
  """
  @callback delete_session(Ecto.Schema.t()) :: {atom, any}

  @doc """
  Deletes the session using `Repo.delete!`.
  """
  @callback delete_session!(Ecto.Schema.t()) :: any

  @doc """
  Updates a session using `Repo.update!`.
  """
  @callback update_session!(Ecto.Changeset.t()) :: Ecto.Schema.t()

  @doc """
  Count the sessions matching the provided filters.
  """
  @callback count(Ecto.Changeset.t()) :: Ecto.Schema.t()

  @doc """
  Deletes expired sessions.
  """
  @callback delete_expired(Ecto.Changeset.t()) :: Ecto.Schema.t()
end