lib/ecto_adapters_dynamodb/info.ex

defmodule Ecto.Adapters.DynamoDB.Info do
  @moduledoc """
  Get information on dynamo tables and schema 
  """

  alias Ecto.Adapters.DynamoDB
  alias Ecto.Repo
  alias ExAws.Dynamo

  @typep table_name_t :: String.t()
  @typep dynamo_response_t :: %{required(String.t()) => term}

  @doc """
  Returns the raw amazon dynamo DB table schema information. The raw json is presented as an elixir map.

  Here is an example of what it may look like
  ```
  %{"AttributeDefinitions" => [%{"AttributeName" => "id",
       "AttributeType" => "S"},
     %{"AttributeName" => "person_id", "AttributeType" => "S"}],
    "CreationDateTime" => 1489615412.651,
    "GlobalSecondaryIndexes" => [%{"IndexArn" => "arn:aws:dynamodb:ddblocal:000000000000:table/circle_members/index/person_id",
       "IndexName" => "person_id", "IndexSizeBytes" => 7109,
       "IndexStatus" => "ACTIVE", "ItemCount" => 146,
       "KeySchema" => [%{"AttributeName" => "person_id", "KeyType" => "HASH"}],
       "Projection" => %{"ProjectionType" => "ALL"},
       "ProvisionedThroughput" => %{"ReadCapacityUnits" => 100,
         "WriteCapacityUnits" => 50}}], "ItemCount" => 146,
    "KeySchema" => [%{"AttributeName" => "id", "KeyType" => "HASH"},
     %{"AttributeName" => "person_id", "KeyType" => "RANGE"}],
    "ProvisionedThroughput" => %{"LastDecreaseDateTime" => 0.0,
      "LastIncreaseDateTime" => 0.0, "NumberOfDecreasesToday" => 0,
      "ReadCapacityUnits" => 100, "WriteCapacityUnits" => 50},
    "TableArn" => "arn:aws:dynamodb:ddblocal:000000000000:table/circle_members",
    "TableName" => "circle_members", "TableSizeBytes" => 7109,
    "TableStatus" => "ACTIVE"}
  ```
  """
  @spec table_info(Repo.t(), table_name_t) :: dynamo_response_t | no_return
  def table_info(repo, tablename) do
    # Fetch and cache the raw schema definition from DynamoDB
    Ecto.Adapters.DynamoDB.Cache.describe_table!(repo, tablename)
  end

  @doc "Get all the raw information on indexes for a given table, returning as a map."
  @spec index_details(Repo.t(), table_name_t) :: %{primary: [map], secondary: [map]}
  def index_details(repo, tablename) do
    # Extract the primary key data (required) and the optional secondary global or local indexes
    %{"KeySchema" => primary_key} = schema = table_info(repo, tablename)

    indexes =
      Map.get(schema, "GlobalSecondaryIndexes", []) ++
        Map.get(schema, "LocalSecondaryIndexes", [])

    # return only the relevant index/key data
    %{:primary => primary_key, :secondary => indexes}
  end

  @doc """
  Get a list of the available indexes on a table. The format of this list is described in normalise_dynamo_index!
  """
  @spec indexes(Repo.t(), table_name_t) :: [{:primary | String.t(), [String.t()]}]
  def indexes(repo, tablename) do
    [primary_key!(repo, tablename) | secondary_indexes(repo, tablename)]
  end

  @doc """
  Returns the primary key/ID for a table. It may be a single field that is a HASH, OR
  it may be the dynamoDB {HASH, SORT} type of index. we return
  \\{:primary, [index]}
  in a format described in normalise_dynamo_index!
  """
  @spec primary_key!(Repo.t(), table_name_t) :: {:primary, [String.t()]} | no_return
  def primary_key!(repo, tablename) do
    indexes = index_details(repo, tablename)
    {:primary, normalise_dynamo_index!(indexes[:primary])}
  end

  @spec repo_primary_key(module()) :: String.t() | no_return
  def repo_primary_key(repo) do
    case repo.__schema__(:primary_key) do
      [pkey] ->
        Atom.to_string(pkey)

      [] ->
        error("DynamoDB repos must have a primary key, but repo #{repo} has none")

      _ ->
        error("DynamoDB repos must have a single primary key, but repo #{repo} has more than one")
    end
  end

  # @doc "return true if this HASH key/{HASH/SORT} key is the table primary key"
  # def primary_key?(tablename, key) do
  #  case primary_key!(tablename) do
  #    {:primary, ^key} -> true
  #    _ -> false
  #  end
  # end

  # @doc "return true is this is a secondary key (HASH/{HASH,SORT}) for the table"
  # def secondary_key?(tablename, key) do
  #  indexes = secondary_indexes(tablename)
  #  Enum.member?(indexes, key)
  # end

  @doc """
  returns a simple list of the secondary indexes (global and local) for the table. Uses same format
  for each member of the list as 'primary_key!'.
  """
  @spec secondary_indexes(Repo.t(), table_name_t) :: [{String.t(), [String.t()]}] | no_return
  def secondary_indexes(repo, tablename) do
    # Extract the secondary index value from the index_details map
    %{:secondary => indexes} = index_details(repo, tablename)
    for index <- indexes, do: {index["IndexName"], normalise_dynamo_index!(index["KeySchema"])}
  end

  def ttl_info(repo, tablename) do
    tablename
    |> Dynamo.describe_time_to_live()
    |> ExAws.request(DynamoDB.ex_aws_config(repo))
  end

  @doc """
  returns a list of any indexed attributes in the table
  """
  @spec indexed_attributes(Repo.t(), table_name_t) :: [String.t()]
  def indexed_attributes(repo, table_name) do
    indexes(repo, table_name)
    |> Enum.map(fn {_, fields} -> fields end)
    |> List.flatten()
    |> Enum.uniq()
  end

  # dynamo raw index data is complex, and can contain either one or two fields along with their type (hash or range)
  # This parses it and returns a simple list format. The first element of the list is the HASH key, the second
  # (optional) is the range/sort key. eg:
  # [hash_field_name, sort_field_name] or [hash_field_name]

  @spec normalise_dynamo_index!([%{required(String.t()) => String.t()}]) ::
          [String.t()] | no_return
  defp normalise_dynamo_index!(index_fields) do
    # The data structure can look a little like these examples:
    #   [%{"AttributeName" => "person_id", "KeyType" => "HASH"}]
    # [%{"AttributeName" => "id", "KeyType" => "HASH"}, %{"AttributeName" => "person_id", "KeyType" => "RANGE"}]
    case index_fields do
      # Just one entry in the fields list; it must be a simple hash.
      [%{"AttributeName" => fieldname}] ->
        [fieldname]

      # Two entries, it's a HASH + SORT - but they might not be returned in order - So figure out 
      # which is the hash and which is the sort by matching for the "HASH" attribute in the first,
      # then second element of the list. Match explicitly as we want a crash if we get anything else.
      [
        %{"AttributeName" => fieldname_hash, "KeyType" => "HASH"},
        %{"AttributeName" => fieldname_sort}
      ] ->
        [fieldname_hash, fieldname_sort]

      [
        %{"AttributeName" => fieldname_sort},
        %{"AttributeName" => fieldname_hash, "KeyType" => "HASH"}
      ] ->
        [fieldname_hash, fieldname_sort]
    end
  end

  defp error(msg) do
    raise ArgumentError, message: msg
  end
end