defmodule NLdoc.SpecDef.Schema do @moduledoc """ This module sets some defaults over the Ecto schema for the NLdoc spec. 1. Use this in all schemas for the NLdoc spec, instead of `use Ecto.Schema` or `use TypedEctoSchema`: `use NLdoc.SpecDef.Schema, type: "https://spec.nldoc.nl/Resource/Foo"` 2. Implement the `changeset/2` function in the schema module. 3. Make sure to add `@cast_opts` to all calls to `cast/4` or `cast_embed/3` or `cast_polymorphic_embed/3`, as it ensures strings that only contain whitespace are accepted as valid. Note: the `type` option is required and must be a string. It is the resource type of the schema. It will be available as `@resource_type` in the schema module and through the `resource_type/0` function on the schema module. ## Example defmodule Example do @moduledoc false use NLdoc.SpecDef.Schema, type: "https://spec.foo.com/example" typed_embedded_schema null: false do field :type, :string, default: @resource_type field :message, :string end def changeset(object, attrs) do fields = [:type, :message] object |> cast(attrs, fields, @cast_opts) |> validate_required(fields) |> validate_inclusion(:type, [@resource_type]) end end """ defmacro __using__(opts) do {opts, ecto_opts} = parse_opts(opts) quote location: :keep do use TypedEctoSchema, unquote(ecto_opts) import Ecto.Changeset import PolymorphicEmbed @primary_key false # This function must be implemented in the using module. @spec changeset(struct(), map()) :: Ecto.Changeset.t() # This must be passed to all calls to cast/4, as it ensures strings # that only contain whitespace are accepted as valid. @cast_opts [empty_values: []] # This makes the `type` argument available inside and outside of the schema module. @resource_type unquote(opts[:type]) def resource_type, do: @resource_type # This defines the constructor functions for the schema module. @before_compile {NLdoc.SpecDef.Schema, :def_constructor} end end # coveralls-ignore-start This is part of a macro, so it is only executed at compile-time. defp parse_opts(opts) do if not is_nil(opts[:type]) and (!is_binary(opts[:type]) or opts[:type] == "") do raise """ \n A #{IO.ANSI.yellow()}type#{IO.ANSI.reset()} option must be passed to #{IO.ANSI.yellow()}use NLdoc.SpecDef.Schema#{IO.ANSI.reset()}. Example: #{IO.ANSI.yellow()}use NLdoc.SpecDef.Schema, type: "https://spec.nldoc.nl/Resource/Foo" #{IO.ANSI.reset()} """ end ecto_opts = Keyword.delete(opts, :type) {opts, ecto_opts} end # coveralls-ignore-stop @doc """ This macro defines the `new/1` and `new!/1` functions for the schema module that create a new document from a template, which essentially functions like an input-validating constructor for the struct defined by the schema module. """ defmacro def_constructor(_) do full_module_name = quote do __MODULE__ |> to_string() |> String.split(".") |> List.delete_at(0) |> Enum.join(".") end module_name = quote do __MODULE__ |> to_string() |> String.split(".") |> List.last() end quote location: :keep, bind_quoted: [full_module_name: full_module_name, module_name: module_name] do @doc """ This function creates a new `#{full_module_name}` struct from a map of keys and values, where the keys are snake_case. Returns a tuple with either `{:ok, #{Macro.underscore(module_name)}}` or `{:error, changeset}`. Note: If you want to use a map with camelCase keys, use `NLdoc.Util.Recase.to_snake/1` to convert them to snake_case before passing them to the constructor. """ @spec new(map()) :: {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()} def new(template) when is_map(template) do changeset = changeset(%__MODULE__{}, template) if changeset.valid? do document = Ecto.Changeset.apply_changes(changeset) {:ok, document} else {:error, changeset} end end end end end