Search code examples
elixirphoenix-frameworkecto

Polymorphic embedded JSON value types?


I have an app where users can send custom data and it will be stored in my application. Currently I do something like this:

defmodule MyApp.CustomField do
  use Ecto.Schema
  import Ecto.Changeset
  alias MyApp.{ Time }


  defmodule ValueTypes do
    def c_INTEGER, do: "integer"
    def c_BOOLEAN, do: "boolean"
    def c_STRING,  do: "string"


    def c_TRUE,    do: "true"
    def c_FALSE,   do: "false"
  end


  embedded_schema do
    field :field
    field :value
    field :type
  end


  @required_fields ~w( field value )
  @optional_fields ~w( type )


  def changeset(model, params \\ :empty) do
    {:ok, value, type} = cast(model.field, model.value)
    params = %{value: value, type: type}

    model
    |> cast(params, @required_fields, @optional_fields)
    |> validate_length(:field, min: 1, max: 20)
    |> validate_length(:value, min: 1, max: 255)
    |> put_change(:type, type)
  end

  # date in unix timestamp
  def cast(field, value) when is_integer(value) do
    value = Integer.to_string(value)
    case String.slice(field, -3, 3) do
      "_at" -> {:ok, value, ValueTypes.c_DATE}
      _     -> {:ok, value, ValueTypes.c_INTEGER}


    end
  end

  def cast(field, value) when is_boolean(value), do: {:ok, (if value, do: ValueTypes.c_TRUE, else: ValueTypes.c_FALSE), ValueTypes.c_BOOLEAN}
  def cast(field, value) when is_binary(value),  do: {:ok, value, ValueTypes.c_STRING}
  def cast(field, value), do: {:error, "Invalid field value"}


end

Currently I save everything as a string and keep a Type field to convert datatypes between my app and the DB. Also, I have to convert everything to a string, because Ecto (AFAIK) does not support having polymorphic types in embedded JSON.

Is this problematic with regards to indexing the Field and Value?

I thought of using custom Ecto types, but this is not possible due to how I depend on two values in my cast function (when casting dates, I look at the end of the field name for "_at")

Is there a better way to accomplish this?


Solution

  • If you are using Postgres as your DB, then the behavior you want is already there. You will need to use the :map data type in your schema. You can do something like:

    defmodule MyApp.CustomField do
      use Ecto.Schema
      import Ecto.Changeset
      alias MyApp.{ Time }
    
      embedded_schema do
        field :data, :map
      end
    end
    

    You can then do %CustomField{data: %{"field" => "custom", "value" => 1}} |> Repo.insert!. 1 here will be stored as an integer in JSON in your data column. When you get this record from the DB, Ecto will use a JSON serializer like Poison to decode the JSON data and it will return 1 as an integer in Elixir.