Current section
Files
Jump to
Current section
Files
lib/nldoc/spec_def/schema.ex
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