lib/mosql/schema.ex

defmodule Mosql.Schema do
  alias __MODULE__
  alias Mosql.Schema.Mapping
  alias Mosql.Store

  require Logger

  @name "name"
  @collections "collections"
  @tables "tables"
  @table "table"
  @columns "columns"
  @sql_column "column"
  @sql_type "type"
  @mongo_key "mongo_key"
  @primary_key "primary_key"
  @primary_keys "primary_keys"
  @mapping "mapping"

  @moduledoc """
  Represents the schema mapping between MongoDB collection and SQL table
  ns is the namespace
  """
  @derive {Poison.Encoder, except: [:description]}
  defstruct ns: "",
            name: "",
            collection: "",
            table: "",
            indexes: [],
            primary_keys: [],
            mappings: [],
            description: ""

  @schema_files_path Application.compile_env!(:mosql, :schema_files_path)

  @typedoc """
  Schema type definition
  """
  @type t :: %__MODULE__{
          ns: String.t(),
          name: String.t(),
          collection: String.t(),
          table: String.t(),
          indexes: term,
          primary_keys: term,
          mappings: term,
          description: String.t()
        }

  @doc """
  Return the saved schema mapping struct for the given namespace and collection
  """
  def saved_schema(ns, collection) do
    mapping_key(%{ns: ns, collection: collection}, @mapping) |> Store.get()
  end

  def all_collections(ns) do
    namespace_key(ns, @collections) |> Store.get()
  end

  @doc """
  Returns the schema name for a schema mapping definition
  """
  def schema_name(schema) do
    mapping_key(schema, @name) |> Store.get()
  end

  @doc """
  Returns the SQL table name for a schema mapping definition
  """
  def table_name(schema) do
    mapping_key(schema, @table) |> Store.get()
  end

  @doc """
  Returns the SQL table column names for a schema mapping definition
  """
  def columns(schema) do
    mapping_key(schema, @columns) |> Store.get()
  end

  @doc """
  Returns the SQL table column type for a schema mapping definition and the given column
  """
  def type(schema, column) do
    mapping_key(schema, column, @sql_type) |> Store.get()
  end

  @doc """
  Returns the mongo document key for a schema mapping definition and the given column
  """
  def mongo_key(schema, column) do
    mapping_key(schema, column, @mongo_key) |> Store.get()
  end

  @doc """
  Returns if the column for a schema mapping is a primary key
  """
  def is_primary_key?(schema, column) do
    mapping_key(schema, column, @primary_key) |> Store.get_if_exists("") == column
  end

  @doc """
  Returns the primary key column name
  """
  def primary_key(schema) do
    mapping_key(schema, schema.table, @primary_key) |> Store.get_if_exists("")
  end

  @doc """

  """
  def load_schema_files(namespace, schema_path) do
    Path.wildcard("#{schema_path}/*.json") |> Enum.map(&load_schema_file(namespace, &1))
  end

  def load_schema_file(namespace, path) do
    Logger.info("Loading schema file #{path} for namespace #{namespace}")
    load_schema_file_from_path(path)
  end

  @doc """
  Creates a schema struct based on the collection schema definition, which is a JSON
  that defines the mapping between MongoDB collection and the SQL table definition
  """
  def load_collection(collection) do
    schema_file_path =
      @schema_files_path
      |> Path.join("#{collection}.json")
      |> Path.absname()

    Logger.info("Schema file path for collection '#{collection}' is '#{schema_file_path}'")
    load_schema_file_from_path(schema_file_path)
  end

  def init_schema_store(ns) do
    namespace_key(ns, @tables) |> Store.set([])
    namespace_key(ns, @collections) |> Store.set([])
  end

  @doc """
  store the schema mapping as key value in the Schema store
    <namespace>.<collection>.name = <value>
    <namespace>.<collection>.table = <value>
    <namespace>.<collection>.columns = [values...]
    <namespace>.<collection>.<mongo_key>.indexes = [values...]
    <namespace>.<collection>.<mongo_key>.column = <value>
    <namespace>.<collection>.<mongo_key>.type = <value>
    <namespace>.<collection>.<sql_column>.type = <value>
    <namespace>.<collection>.<sql_column>.mongo_key = <value>
  """
  def populate_schema_store(schema) do
    # Store the whole mapping first in the store
    mapping_key(schema, @mapping) |> store(schema)

    namespace_key(schema.ns, @tables) |> store_tables(schema.table)
    namespace_key(schema.ns, @collections) |> store_collections(schema.collection)

    schema_name = if schema.name == "", do: "public", else: schema.name
    mapping_key(schema, @name) |> store(schema_name)

    mapping_key(schema, @table) |> store(schema.table)
    mapping_key(schema, @columns) |> store([])

    if Enum.count(schema.primary_keys) > 0 do
      pkeys = Enum.join(schema.primary_keys, ", ")
      mapping_key(schema, @primary_keys) |> Store.set("#{pkeys}")
    end

    store_mappings(schema)
  end

  @doc """
  Default sorting of schemas by alphabetic ordering of the collection
  """
  def sort_schemas(schemas) do
    schemas
    |> Enum.sort_by(fn schema -> schema.collection end, :asc)
    |> Enum.map(&sort_schema_keys(&1))
  end

  # MongoDB collection keys are not ordered by default so apply the
  # default ordering of keys: primary key(s) first then the fields
  # by the data type. This ordering can be changed by modifying the
  # schema files and reimporting it
  defp sort_schema_keys(schema) do
    # grab id mapping so it can go to the top
    id_mapping = Enum.filter(schema.mappings, fn mp -> mp.mongo_key == "_id" end)

    sorted_mappings = Enum.sort_by(schema.mappings, fn mp -> mp.sql_type end, :asc)
    sorted_mappings = Enum.filter(sorted_mappings, fn mp -> mp.mongo_key != "_id" end)

    final_mappings = id_mapping ++ sorted_mappings

    %{schema | mappings: final_mappings}
  end

  defp store_mappings(schema) do
    Enum.each(schema.mappings, &store_map_items(schema, &1))
  end

  defp store_map_items(schema, item) do
    mapping_key(schema, @columns) |> store_columns(item)

    mapping_key(schema, item.mongo_key, @sql_column) |> store(item.sql_column)

    mapping_key(schema, item.mongo_key, @sql_type) |> store(item.sql_type)

    mapping_key(schema, item.sql_column, @sql_type) |> store(item.sql_type)

    mapping_key(schema, item.sql_column, @mongo_key) |> store(item.mongo_key)

    if item.primary_key do
      mapping_key(schema, schema.table, @primary_key) |> store(item.sql_column)
    end
  end

  defp namespace_key(ns, field_name) do
    "#{ns}.#{field_name}"
  end

  defp mapping_key(schema, field_name) do
    "#{schema.ns}.#{schema.collection}.#{field_name}"
  end

  defp mapping_key(schema, field_name, field_value) do
    "#{schema.ns}.#{schema.collection}.#{field_name}.#{field_value}"
  end

  defp store_tables(key, table) do
    tables = Store.get(key)
    store(key, tables ++ [table])
  end

  defp store_collections(key, collection) do
    collections = Store.get(key)
    store(key, collections ++ [collection])
  end

  defp store_columns(key, schema_map_item) do
    IO.puts("columns key #{inspect(key)}: #{inspect(schema_map_item.sql_column)}")
    columns = Store.get(key)
    store(key, columns ++ [schema_map_item.sql_column])
  end

  defp store(key, value) do
    Store.set(key, value)
  end

  defp load_schema_file_from_path(schema_file_path) do
    try do
      with {:ok, raw_json} <- File.read(schema_file_path),
           mappings <- Poison.Parser.parse!(raw_json, %{keys: :atoms!}) do
        schema = struct!(Schema, mappings)

        Logger.info("Parsed schema  from file path '#{schema_file_path}'")

        struct_mappings = Enum.map(schema.mappings, &Mapping.to_struct(&1))
        schema = %{schema | mappings: struct_mappings}
        {:ok, schema}
      else
        {:error, :enoent} ->
          {:error, "No such file or directory to read from '#{schema_file_path}'"}
      end
    rescue
      Poison.ParseError ->
        {:error, "JSON parsing failed for schema at file path '#{schema_file_path}"}
    end
  end
end

defmodule Mosql.Schema.Mapping do
  alias __MODULE__

  @moduledoc """
  Represents the Mongo collection to SQL schema mapping
  """
  @derive [Poison.Encoder]
  defstruct mongo_key: "", sql_column: "", sql_type: "", primary_key: false

  @typedoc """
  Mapping type definition
  """
  @type t :: %__MODULE__{
          mongo_key: String.t(),
          sql_column: String.t(),
          sql_type: String.t(),
          primary_key: boolean()
        }

  def to_struct(values) do
    struct!(Mapping, values)
  end
end