lib/strukt.ex

defmodule Strukt do
  import Kernel, except: [defstruct: 1, defstruct: 2]
  import Strukt.Field, only: [is_supported: 1]

  @doc """
  See `c:new/1`
  """
  @callback new() :: {:ok, struct()} | {:error, Ecto.Changeset.t()}

  @doc """
  This callback can be overridden to provide custom initialization behavior.

  The default implementation provided for you performs all of the necessary
  validation and autogeneration of fields with those options set.

  NOTE: It is critical that if you do override this callback, that you call
  `super/1` to run the default implementation at some point in your implementation.
  """
  @callback new(Keyword.t() | map()) :: {:ok, struct()} | {:error, Ecto.Changeset.t()}

  @doc """
  See `c:change/2`
  """
  @callback change(Ecto.Changeset.t() | term()) :: Ecto.Changeset.t()

  @doc """
  This callback can be overridden to provide custom change behavior.

  The default implementation provided for you creates a changeset and applies
  all of the inline validations defined on the schema.

  NOTE: It is recommended that if you need to perform custom validations, that
  you use the `validation/1` and `validation/2` facility for performing custom
  validations in a module or function, and if necessary, override `c:validate/1` 
  instead of performing validations in this callback. If you need to override this 
  callback specifically for some reason, make sure you call `super/2` at some point during
  your implementation to ensure that validations are run.
  """
  @callback change(Ecto.Changeset.t() | term(), Keyword.t() | map()) :: Ecto.Changeset.t()

  @doc """
  This callback can be overridden to manually implement your own validation logic.

  The default implementation handles invoking the validation rules expressed inline
  or via the `validation/1` and `validation/2` macros. You may still invoke the default
  validations from your own implementation using `super/1`.

  This function can be called directly on a changeset, and is automatically invoked
  by the default `new/1` and `change/2` implementations.
  """
  @callback validate(Ecto.Changeset.t()) :: Ecto.Changeset.t()

  @field_types [
    :field,
    :embeds_one,
    :embeds_many,
    :belongs_to,
    :has_many,
    :has_one,
    :many_to_many,
    :timestamps
  ]

  @schema_attrs [
    :primary_key,
    :schema_prefix,
    :foreign_key_type,
    :timestamps_opts,
    :derive,
    :field_source_mapper
  ]

  @special_attrs @schema_attrs ++ [:moduledoc, :derives]

  defmacro __using__(_) do
    quote do
      import Kernel, except: [defstruct: 1, defstruct: 2, validation: 1, validation: 2]
      import unquote(__MODULE__), only: :macros
    end
  end

  @doc """
  Defines a validation rule for the current struct validation pipeline.

  A validation pipeline is constructed by expressing the rules in the order
  in which you want them applied, from top down. The validations may be defined
  anywhere in the module, but the order of application is always top down.

  You may define either module validators or function validators, much like `Plug.Builder`.
  For module validators, the module is expected to implement the `Strukt.Validator` behavior,
  consisting of the `init/1` and `validate/2` callbacks. For function validators, they are
  expected to be of arity 2. Both the `validate/2` callback and function validators receive
  the changeset to validate/manipulate as their first argument, and options passed to the
  `validation/2` macro, if provided.

  ## Guards

  Validation rules that should be applied conditionally can either handle the conditional
  logic in their implementation, or if simple, can use guards to express this instead, which
  can be more efficient.

  Guards may use the changeset being validated in their conditions by referring to `changeset`.
  See the example below to see how these can be expressed.

  ## Example

      defmodule Upload do
        use Strukt

        @allowed_content_types ["application/json", "application/pdf", "text/csv"]

        defstruct do
          field :filename, :string
          field :content, :binary, default: <<>>
          field :content_type, :string, required: true
        end

        # A simple function validator, expects a function in the same module
        validation :validate_filename

        # A function validator with a guard clause, only applied when the guard is successful
        validation :validate_content_type when is_map_key(changeset.changes, :content_type)

        # A module validator with options
        validation MyValidations.EnsureContentMatchesType, @allowed_content_types

        # A validator with options and a guard clause
        validation :example, [foo: :bar] when changeset.action == :update

        defp validate_filename(changeset, _opts), do: changeset
      end
  """
  defmacro validation(validator, opts \\ [])

  defmacro validation({:when, _meta, [validator | guards]}, opts) do
    validator = Macro.expand(validator, %{__CALLER__ | function: {:init, 1}})

    quote do
      @strukt_validators {unquote(validator), unquote(opts),
                          unquote(Macro.escape(guards, unquote: true))}
    end
  end

  defmacro validation(validator, opts) do
    validator = Macro.expand(validator, %{__CALLER__ | function: {:init, 1}})

    quote do
      @strukt_validators {unquote(validator), unquote(opts), true}
    end
  end

  @doc ~S"""
  This variant of `defstruct` can accept a list of fields, just like `Kernel.defstruct/1`, in which
  case it simply defers to `Kernel.defstruct/1` and does nothing; or it can be passed a block
  containing an `Ecto.Schema` definition. The resulting struct/schema is defined in the current
  module scope, and will inherit attributes like `@derive`, `@primary_key`, etc., which are already
  defined in the current scope.

  ## Example

      defmodule Passthrough do
        use Strukt

        defstruct [:name]
      end

      defmodule Person do
        use Strukt

        @derive [Jason.Encoder]
        defstruct do
          field :name, :string
        end

        def say_hello(%__MODULE__{name: name}), do: "Hello #{name}!"
      end

  Above, even though `Strukt.defstruct/1` is in scope, it simply passes through the list of fields
  to `Kernel.defstruct/1`, as without a proper schema, there isn't much useful we can do. This allows
  intermixing uses of `defstruct/1` in the same scope without conflict.
  """
  defmacro defstruct(arg)

  defmacro defstruct(do: block) do
    define_struct(__CALLER__, nil, block)
  end

  defmacro defstruct(fields) do
    quote bind_quoted: [fields: fields] do
      Kernel.defstruct(fields)
    end
  end

  @doc ~S"""
  This variant of `defstruct` takes a module name and block containing a struct schema and
  any other module contents desired, and defines a new module with that name, generating
  a struct just like `Strukt.defstruct/1`.

  ## Example

      use Strukt

      defstruct Person do
        @derive [Jason.Encoder]

        field :name, :string

        def say_hello(%__MODULE__{name: name}), do: "Hello #{name}!"
      end

  NOTE: Unlike `Strukt.defstruct/1`, which inherits attributes like `@derive` or `@primary_key` from
  the surrounding scope; this macro requires them to be defined in the body, as shown above.
  """
  defmacro defstruct(name, do: body) do
    define_struct(__CALLER__, name, body)
  end

  defp define_struct(env, name, {:__block__, meta, body}) do
    {special_attrs, body} =
      Enum.split_with(body, fn
        {:@, _, [{attr, _, _}]} -> attr in @special_attrs
        _ -> false
      end)

    {fields, body} =
      Enum.split_with(body, fn
        {field_type, _, _} -> field_type in @field_types
        _ -> false
      end)

    {schema_attrs, special_attrs} =
      Enum.split_with(special_attrs, fn {:@, _, [{attr, _, _}]} -> attr in @schema_attrs end)

    moduledoc = Enum.find(special_attrs, fn {:@, _, [{attr, _, _}]} -> attr == :moduledoc end)

    derives =
      case Enum.find(special_attrs, fn {:@, _, [{attr, _, _}]} -> attr == :derives end) do
        {_, _, [{_, _, [derives]}]} ->
          derives

        nil ->
          []
      end

    fields = Strukt.Field.parse(fields)

    define_struct(env, name, meta, moduledoc, derives, schema_attrs, fields, body)
  end

  # This clause handles the edge case where the definition only contains
  # a single field and nothing else
  defp define_struct(env, name, {type, _, _} = field) when is_supported(type) do
    fields = Strukt.Field.parse([field])

    define_struct(env, name, [], nil, [], [], fields, [])
  end

  defp define_struct(_env, name, meta, moduledoc, derives, schema_attrs, fields, body) do
    # Extract macros which should be defined at the top of the module
    {macros, body} =
      Enum.split_with(body, fn
        {node, _meta, _body} -> node in [:use, :import, :alias]
        _ -> false
      end)

    # Extract child struct definitions
    children =
      fields
      |> Enum.filter(fn %{type: t, block: block} ->
        t in [:embeds_one, :embeds_many] and block != nil
      end)
      |> Enum.map(fn %{value_type: value_type, block: block} ->
        quote do
          Strukt.defstruct unquote(value_type) do
            unquote(block)
          end
        end
      end)

    # Generate validation metadata for the generated module
    validated_fields =
      for %{name: name, type: t} = f <- fields, t != :timestamps, reduce: {:%{}, [], []} do
        {node, meta, elements} ->
          kvs =
            Keyword.merge(
              [type: t, value_type: f.value_type, default: f.options[:default]],
              f.validations
            )

          element = {name, {:%{}, [], kvs}}
          {node, meta, [element | elements]}
      end

    # Get a list of fields valid for `cast/3`
    cast_fields = for %{type: :field} = f <- fields, do: f.name

    # Get a list of embeds valid for `cast_embed/3`
    cast_embed_fields = for %{type: t} = f <- fields, t in [:embeds_one, :embeds_many], do: f.name

    # Expand fields back to their final AST form
    fields_ast =
      fields
      |> Stream.map(&Strukt.Field.to_ast/1)
      # Drop any extraneous args (such as inline schema definitions, which have been extracted)
      |> Enum.map(fn {type, meta, args} -> {type, meta, Enum.take(args, 3)} end)

    # Make sure the default primary key is defined and castable
    defines_primary_key? =
      Enum.any?(fields, &(&1.type == :field and Keyword.has_key?(&1.options, :primary_key)))

    quoted =
      quote location: :keep do
        unquote(moduledoc)
        unquote_splicing(macros)

        # Capture schema attributes from outer scope, since `use Ecto.Schema` will reset them
        schema_attrs =
          unquote(@schema_attrs)
          |> Enum.map(&{&1, Module.get_attribute(__MODULE__, &1)})
          |> Enum.reject(fn {_, value} -> is_nil(value) end)

        use Ecto.Schema
        import Ecto.Changeset, except: [change: 2]

        @behaviour unquote(__MODULE__)
        @before_compile unquote(__MODULE__)

        Module.register_attribute(__MODULE__, :strukt_validators, accumulate: true)

        # Generate child structs before generating the parent
        unquote_splicing(children)

        # Ensure any schema attributes are set, starting with outer scope, then inner
        for {schema_attr, value} <- schema_attrs do
          Module.put_attribute(__MODULE__, schema_attr, value)
        end

        # Schema attributes defined in module body
        unquote_splicing(schema_attrs)

        # Ensure a primary key is defined, if one hasn't been by this point
        defines_primary_key? = unquote(defines_primary_key?)

        case Module.get_attribute(__MODULE__, :primary_key) do
          nil when not defines_primary_key? ->
            # Provide the default primary key
            Module.put_attribute(__MODULE__, :primary_key, {:uuid, Ecto.UUID, autogenerate: true})

          pk when defines_primary_key? ->
            # Primary key is being overridden
            Module.put_attribute(__MODULE__, :primary_key, false)

          _pk ->
            # Primary key is set and not overridden
            nil
        end

        @schema_name Macro.underscore(__MODULE__)
        @validated_fields unquote(validated_fields)
        @cast_embed_fields unquote(Macro.escape(cast_embed_fields))

        # Ensure primary key can be cast, if applicable
        case Module.get_attribute(__MODULE__, :primary_key) do
          false ->
            # Primary key was explicitly disabled
            Module.put_attribute(__MODULE__, :cast_fields, unquote(Macro.escape(cast_fields)))

          {pk, _type, _opts} ->
            # Primary key was defaulted, or set manually via attribute
            Module.put_attribute(__MODULE__, :cast_fields, [
              pk | unquote(Macro.escape(cast_fields))
            ])
        end

        # Inject or override @derives, without Jason.Encoder if present
        case Module.get_attribute(__MODULE__, :derives) do
          derives when derives in [false, nil] or derives == [] ->
            case unquote(derives) do
              nil ->
                nil

              ds ->
                if Enum.member?(ds, Jason.Encoder) do
                  Module.put_attribute(__MODULE__, :derives_jason, true)

                  Module.put_attribute(
                    __MODULE__,
                    :derives,
                    Enum.reject(ds, &(&1 == Jason.Encoder))
                  )
                end
            end

          derives ->
            if Enum.member?(derives, Jason.Encoder) do
              Module.put_attribute(__MODULE__, :derives_jason, true)

              Module.put_attribute(
                __MODULE__,
                :derives,
                Enum.reject(derives, &(&1 == Jason.Encoder))
              )
            end
        end

        embedded_schema do
          unquote({:__block__, meta, fields_ast})
        end

        @doc """
        Creates a `#{__MODULE__}`, using the provided params.

        This operation is fallible, so it returns `{:ok, t}` or `{:error, Ecto.Changeset.t}`.

        If this struct has an autogenerated primary key, it will be generated, assuming it
        was not provided in the set of params. By default, all structs generated by `defstruct`
        are given a primary key field of `:uuid`, which is autogenerated using `UUID.uuid/4`.
        See the docs for `defstruct` if you wish to change this.
        """
        @impl Strukt
        def new(params \\ %{})

        def new(params) do
          struct =
            struct(__MODULE__)
            |> Strukt.Autogenerate.generate()

          formed_params = Strukt.Params.transform(__MODULE__, params, struct)

          struct
          |> changeset(formed_params, :insert)
          |> from_changeset()
        end

        @doc """
        Prepares an `Ecto.Changeset` from a struct, or an existing `Ecto.Changeset`, by applying
        the provided params as changes. The resulting changeset is validated.

        See `from_changeset/1`, for converting the changeset back to a struct.
        """
        @impl Strukt
        def change(entity_or_changeset, params \\ %{})

        def change(entity_or_changeset, params) do
          case entity_or_changeset do
            %Ecto.Changeset{} = cs ->
              cs
              |> Ecto.Changeset.change(params)
              |> validate()

            %__MODULE__{} = entity ->
              changeset(entity, params, :update)
          end
        end

        @doc """
        Validates a changeset for this type.
        """
        @impl Strukt
        def validate(changeset) do
          changeset
          |> __validate__()
          |> validator_builder_call([])
        end

        defoverridable unquote(__MODULE__)

        unquote(body)
      end

    if is_nil(name) do
      quoted
    else
      quote do
        defmodule unquote(name) do
          unquote(quoted)
        end
      end
    end
  end

  @doc false
  defmacro __before_compile__(env) do
    schema_module = env.module
    validators = Module.get_attribute(env.module, :strukt_validators)

    {changeset, validate_body} = Strukt.Validator.Builder.compile(env, validators, [])

    quote location: :keep do
      # Injects the type spec for this module based on the schema
      typespec_ast =
        Strukt.Typespec.generate(%Strukt.Typespec{
          caller: __MODULE__,
          info: @validated_fields,
          fields: @cast_fields,
          embeds: @cast_embed_fields
        })

      Module.eval_quoted(__ENV__, typespec_ast)

      defp validator_builder_call(unquote(changeset), opts),
        do: unquote(validate_body)

      @doc """
      Generates an `Ecto.Changeset` for this type, using the provided params.

      This function automatically performs validations based on the schema, and additionally,
      it invokes `validate/1` in order to apply custom validations, if present.

      Use `from_changeset/1` to apply the changes in the changeset,
      and get back a valid instance of this type
      """
      @spec changeset(t) :: Ecto.Changeset.t()
      @spec changeset(t, Keyword.t() | map()) :: Ecto.Changeset.t()
      def changeset(%__MODULE__{} = entity, params \\ %{}) do
        changeset(entity, params, nil)
      end

      # This function is used to build and validate a changeset for the corresponding action.
      @doc false
      def changeset(%__MODULE__{} = entity, params, action)
          when action in [:insert, :update, :delete, nil] do
        params =
          case params do
            %__MODULE__{} ->
              Map.from_struct(params)

            m when is_map(m) ->
              m

            other ->
              Enum.into(other, %{})
          end

        cast(entity, params, @cast_fields)
        |> Map.put(:action, action)
        |> __cast_embeds__(@cast_embed_fields)
        |> validate()
      end

      defp __cast_embeds__(changeset, []), do: changeset

      if length(@cast_embed_fields) > 0 do
        defp __cast_embeds__(%Ecto.Changeset{params: params} = changeset, [field | fields]) do
          # If we get a struct(s) in the params for an embed, there is no need to cast, presume validity and apply the change directly
          f = to_string(field)
          prev = Ecto.Changeset.fetch_field!(changeset, field)

          # Ensure a change can always be applied, whether inserting or updated
          changeset =
            case Map.get(params, f) do
              nil ->
                changeset

              %_{} = entity when is_nil(prev) ->
                # In this case, we don't have a previous instance, and we don't need to cast
                Ecto.Changeset.put_embed(changeset, field, Map.from_struct(entity))

              %_{} = entity ->
                # In this case, we have a previous instance, so we need to change appropriately, but we don't need to cast
                cs = Ecto.Changeset.change(prev, Map.from_struct(entity))
                Ecto.Changeset.put_embed(changeset, field, cs)

              [%_{} | _] = entities ->
                # When we have a list of entities, we are overwriting the embeds with a new set
                Ecto.Changeset.put_embed(changeset, field, Enum.map(entities, &Map.from_struct/1))

              other when is_map(other) or is_list(other) ->
                # For all other parameters, we need to cast. Depending on how the embedded entity is configured, this may raise an error
                cast_embed(changeset, field)
            end

          __cast_embeds__(changeset, fields)
        end
      end

      @doc """
      Applies the changes in the changset if the changeset is valid, returning the
      updated data. The action must be one of `:insert`, `:update`, or `:delete` and
      is used

      Returns `{:ok, t}` or `{:error, Ecto.Changeset.t}`, depending on validity of the changeset
      """
      @spec from_changeset(Ecto.Changeset.t()) :: {:ok, t} | {:error, Ecto.Changeset.t()}
      def from_changeset(changeset)

      def from_changeset(%Ecto.Changeset{valid?: true} = cs),
        do: {:ok, Ecto.Changeset.apply_changes(cs)}

      def from_changeset(%Ecto.Changeset{} = cs), do: {:error, cs}

      @doc "Deserialize this type from a JSON string or iodata"
      @spec from_json(binary | iodata) :: {:ok, t} | {:error, reason :: term}
      def from_json(input) do
        with {:ok, map} <- Jason.decode(input, keys: :atoms!, strings: :copy) do
          {:ok, Ecto.embedded_load(__MODULE__, map, :json)}
        end
      end

      # Generate the __validate__ function
      validate_ast = Strukt.Validation.generate(__MODULE__, @validated_fields)
      Module.eval_quoted(__ENV__, validate_ast)

      # Handle conditional implementation of Jason.Encoder
      if Module.get_attribute(__MODULE__, :derives_jason) do
        defimpl Jason.Encoder, for: unquote(schema_module) do
          def encode(value, opts) do
            value
            |> Ecto.embedded_dump(:json)
            |> Jason.Encode.map(opts)
          end
        end
      end
    end
  end
end