lib/grax/schema.ex

defmodule Grax.Schema do
  @moduledoc """
  A special type of struct for graph structures whose fields are mapped to RDF properties and
  the types of values can be specified.

  For now there is no API documentation.
  Read about schemas in the guide [here](https://rdf-elixir.dev/grax/schemas.html).
  """

  alias Grax.Schema.{
    Struct,
    Inheritance,
    DataProperty,
    LinkProperty,
    CustomField,
    AdditionalStatements
  }

  alias RDF.IRI

  import RDF.Utils.Guards

  @type t() :: struct

  defmacro __using__(opts) do
    preload_default = opts |> Keyword.get(:depth) |> Grax.normalize_preload_spec()

    id_spec_from_otp_app =
      if id_spec_from_otp_app = Keyword.get(opts, :id_spec_from_otp_app) do
        Application.get_env(id_spec_from_otp_app, :grax_id_spec)
      end

    id_spec = Keyword.get(opts, :id_spec, id_spec_from_otp_app)

    quote do
      @behaviour Grax.Callbacks

      import unquote(__MODULE__), only: [schema: 1, schema: 2]

      @before_compile unquote(__MODULE__)

      @grax_preload_default unquote(preload_default)
      def __preload_default__(), do: @grax_preload_default

      if unquote(id_spec) do
        def __id_spec__(), do: unquote(id_spec)
      else
        def __id_spec__() do
          __MODULE__
          |> Application.get_application()
          |> Application.get_env(:grax_id_spec)
        end
      end

      def __id_schema__(id_spec \\ nil)
      def __id_schema__(nil), do: if(id_spec = __id_spec__(), do: __id_schema__(id_spec))
      def __id_schema__(id_spec), do: id_spec.id_schema(__MODULE__)

      @impl Grax.Callbacks
      def on_load(schema, _graph, _opts), do: {:ok, schema}

      @impl Grax.Callbacks
      def on_to_rdf(_schema, graph, _opts), do: {:ok, graph}

      defimpl Grax.Schema.Registerable do
        def register(schema), do: schema
      end

      defoverridable on_load: 3, on_to_rdf: 3
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      def build_id(attributes), do: Grax.build_id(__MODULE__, attributes)

      def build(id), do: Grax.build(__MODULE__, id)
      def build(id, initial), do: Grax.build(__MODULE__, id, initial)
      def build!(id), do: Grax.build!(__MODULE__, id)
      def build!(id, initial), do: Grax.build!(__MODULE__, id, initial)

      @spec load(
              RDF.Graph.t() | RDF.Description.t(),
              RDF.IRI.coercible() | RDF.BlankNode.t(),
              opts :: keyword()
            ) ::
              {:ok, __MODULE__.t()} | {:error, any}
      def load(graph, id, opts \\ []), do: Grax.load(graph, id, __MODULE__, opts)

      @spec load!(
              RDF.Graph.t() | RDF.Description.t(),
              RDF.IRI.coercible() | RDF.BlankNode.t(),
              opts :: keyword()
            ) ::
              __MODULE__.t()
      def load!(graph, id, opts \\ []), do: Grax.load!(graph, id, __MODULE__, opts)

      @spec from(Grax.Schema.t()) :: {:ok, __MODULE__.t()} | {:error, any}
      def from(value), do: Grax.Schema.Mapping.from(value, __MODULE__)

      @spec from!(Grax.Schema.t()) :: __MODULE__.t()
      def from!(value), do: Grax.Schema.Mapping.from!(value, __MODULE__)

      Module.delete_attribute(__MODULE__, :rdf_property_acc)
      Module.delete_attribute(__MODULE__, :custom_field_acc)
    end
  end

  defmacro schema(class \\ nil, do_block)

  defmacro schema({:<, _, [class, nil]}, do: block) do
    schema(__CALLER__, class, [], block)
  end

  defmacro schema({:<, _, [class, parent_schema]}, do: block) do
    schema(__CALLER__, class, [inherit: parent_schema], block)
  end

  defmacro schema(opts, do: block) when is_list(opts) do
    {class, opts} = Keyword.pop(opts, :type)
    schema(__CALLER__, class, opts, block)
  end

  defmacro schema(class, do: block) do
    schema(__CALLER__, class, [], block)
  end

  defp schema(caller, class, opts, block) do
    parent_schema = if parent_schema = Keyword.get(opts, :inherit), do: List.wrap(parent_schema)
    load_additional_statements = Keyword.get(opts, :load_additional_statements, true)

    prelude =
      quote do
        if line = Module.get_attribute(__MODULE__, :grax_schema_defined) do
          raise "schema already defined for #{inspect(__MODULE__)} on line #{line}"
        end

        @grax_schema_defined unquote(caller.line)

        @grax_parent_schema unquote(parent_schema)
        def __super__(), do: @grax_parent_schema

        @grax_schema_class unquote(class)
        @grax_schema_class_string if @grax_schema_class, do: IRI.to_string(@grax_schema_class)
        def __class__(), do: @grax_schema_class_string

        @additional_statements AdditionalStatements.default(unquote(class))
        def __additional_statements__(), do: @additional_statements

        @load_additional_statements unquote(load_additional_statements)
        def __load_additional_statements__?(), do: @load_additional_statements

        Module.register_attribute(__MODULE__, :rdf_property_acc, accumulate: true)
        Module.register_attribute(__MODULE__, :custom_field_acc, accumulate: true)

        try do
          import unquote(__MODULE__)
          import Grax.Schema.Type.Constructors
          unquote(block)
        after
          :ok
        end
      end

    postlude =
      quote unquote: false do
        @type t() :: %__MODULE__{}

        @__properties__ Inheritance.inherit_properties(
                          __MODULE__,
                          @grax_parent_schema,
                          Map.new(@rdf_property_acc)
                        )

        @__custom_fields__ Inheritance.inherit_custom_fields(
                             __MODULE__,
                             @grax_parent_schema,
                             Map.new(@custom_field_acc)
                           )

        defstruct Struct.fields(@__properties__, @__custom_fields__, @grax_schema_class)

        def __properties__, do: @__properties__

        def __properties__(:data),
          do: @__properties__ |> Enum.filter(&match?({_, %DataProperty{}}, &1))

        def __properties__(:link),
          do: @__properties__ |> Enum.filter(&match?({_, %LinkProperty{}}, &1))

        def __property__(property), do: @__properties__[property]
        def __domain_properties__(), do: Grax.Schema.domain_properties(@__properties__)

        def __custom_fields__, do: @__custom_fields__
      end

    quote do
      unquote(prelude)
      unquote(postlude)
    end
  end

  defmacro field(name, opts \\ []) do
    quote do
      Grax.Schema.__custom_field__(__MODULE__, unquote(name), unquote(opts))
    end
  end

  defmacro property([{name, iri} | opts]) do
    quote do
      Grax.Schema.__property__(__MODULE__, unquote(name), unquote(iri), unquote(opts))
    end
  end

  defmacro property(name, iri, opts \\ []) do
    quote do
      Grax.Schema.__property__(__MODULE__, unquote(name), unquote(iri), unquote(opts))
    end
  end

  defmacro link(name, iri, opts) do
    iri = property_mapping_destination(iri)

    unless Keyword.has_key?(opts, :type),
      do: raise(ArgumentError, "type missing for link #{name}")

    opts =
      Keyword.put(opts, :preload, opts |> Keyword.get(:depth) |> Grax.normalize_preload_spec())

    quote do
      Grax.Schema.__link__(__MODULE__, unquote(name), unquote(iri), unquote(opts))
    end
  end

  defmacro link([{name, iri} | opts]) do
    quote do
      link(unquote(name), unquote(iri), unquote(opts))
    end
  end

  @doc false
  def __custom_field__(mod, name, opts) do
    custom_field_schema = CustomField.new(mod, name, opts)
    Module.put_attribute(mod, :custom_field_acc, {name, custom_field_schema})
  end

  @doc false
  def __property__(mod, name, iri, opts) when not is_nil(iri) do
    property_schema = DataProperty.new(mod, name, iri, opts)
    Module.put_attribute(mod, :rdf_property_acc, {name, property_schema})
  end

  @doc false
  def __link__(mod, name, iri, opts) do
    property_schema = LinkProperty.new(mod, name, iri, opts)

    Module.put_attribute(mod, :rdf_property_acc, {name, property_schema})
  end

  defp property_mapping_destination({:-, _line, [iri_expr]}), do: {:inverse, iri_expr}
  defp property_mapping_destination(iri_expr), do: iri_expr

  @doc false
  def domain_properties(properties) do
    properties
    |> Map.values()
    |> Enum.map(& &1.iri)
    |> Enum.reject(&match?({:inverse, _}, &1))
  end

  @doc false
  def has_field?(schema, field_name) do
    Map.has_key?(schema.__properties__(), field_name) or
      Map.has_key?(schema.__custom_fields__(), field_name)
  end

  @doc """
  Checks if the given value is a `Grax.Schema` struct.
  """
  @spec struct?(any) :: boolean
  def struct?(%mod{__id__: _, __additional_statements__: _}), do: schema?(mod)
  def struct?(_), do: false

  @doc """
  Checks if the given module or struct is a `Grax.Schema`.
  """
  @spec schema?(module | struct) :: boolean
  def schema?(mod_or_struct)

  def schema?(%mod{}), do: schema?(mod)

  def schema?(mod) when maybe_module(mod) do
    case Code.ensure_compiled(mod) do
      {:module, mod} -> function_exported?(mod, :__properties__, 1)
      _ -> false
    end
  end

  def schema?(_), do: false

  @doc """
  Returns all modules using `Grax.Schema`.
  """
  # ignore dialyzer assumes Grax.Schema.Registerable is always consolidated
  @dialyzer {:nowarn_function, known_schemas: 0}
  @spec known_schemas :: [module]
  def known_schemas do
    case Grax.Schema.Registerable.__protocol__(:impls) do
      {:consolidated, modules} ->
        modules

      :not_consolidated ->
        Protocol.extract_impls(Grax.Schema.Registerable, :code.get_path())
    end
  end

  @doc """
  Checks if the given `Grax.Schema` or `Grax.Schema` struct is inherited from another `Grax.Schema`.
  """
  @spec inherited_from?(module | struct, module) :: boolean
  def inherited_from?(schema, parent)

  def inherited_from?(%schema{}, parent), do: inherited_from?(schema, parent)

  def inherited_from?(schema, parent) do
    schema?(schema) and Inheritance.inherited_schema?(schema, parent)
  end
end