# SPDX-FileCopyrightText: 2019 ash contributors # # SPDX-License-Identifier: MIT defmodule Ash.Query.Function do @moduledoc """ A function is a predicate with an arguments list. For more information on being a predicate, see `Ash.Filter.Predicate`. Most of the complexities are there. A function must meet both behaviours. """ import Ash.Expr, only: [expr?: 1] @type arg :: any @doc """ The number and types of arguments supported. """ @callback args() :: [arg] | :var_args @doc """ The return type for each corresponding set of args. """ @callback returns() :: [Ash.Type.t() | {Ash.Type.t(), constraints :: Keyword.t()}] | Ash.Type.t() | {Ash.Type.t(), constraints :: Keyword.t()} | :unknown @doc "The name of the function" @callback name() :: atom @doc "Instantiate a new function with the provided arguments" @callback new(list(term)) :: {:ok, term} | {:error, String.t() | Exception.t()} @doc "Evaluate a function when all arguments are known valid values" @callback evaluate(func :: map) :: :unknown | {:known, term} | {:error, term} @doc "Evaluate a function when some or no arguments are known valid values" @callback partial_evaluate(func) :: {:ok, func} | {:error, term} when func: map @doc "Whether or not the function can be evaluated eagerly. For example, `now()` cannot be." @callback eager_evaluate?() :: boolean() @doc "Whether or not the function is a predicate (takes a reference as the first argument, a value as the second, and returns a boolean)" @callback predicate?() :: boolean() @doc "Whether or not the function should be usable when parsing input." @callback private?() :: boolean @doc "Whether or not the function return nil." @callback can_return_nil?(func :: map) :: boolean() @doc """ If `true`, will be allowed to evaluate `nil` inputs. If `false` (the default), any `nil` inputs will cause a `nil` return. """ @callback evaluate_nil_inputs?() :: boolean() @optional_callbacks partial_evaluate: 1 @doc "Evaluate the operator with provided inputs" def evaluate(%mod{arguments: arguments} = func) do if Enum.any?(arguments, &is_nil/1) && !mod.evaluate_nil_inputs?() do {:known, nil} else mod.evaluate(func) end end def new(mod, args) do args = List.wrap(args) case mod.args() do :var_args -> # Varargs is special, and should only be used in rare circumstances (like this one) # no type casting or help can be provided for these functions. mod.new(args) mod_args -> configured_args = List.wrap(mod_args) allowed_arg_counts = Enum.map(configured_args, &Enum.count/1) given_arg_count = Enum.count(args) if given_arg_count in allowed_arg_counts do mod_args |> Enum.filter(fn args -> Enum.count(args) == given_arg_count end) |> Enum.find_value(&try_cast_arguments(&1, args)) |> case do nil -> {:error, "Could not cast function arguments for #{mod.name()}/#{given_arg_count}"} casted -> case mod.new(casted) do {:ok, function} -> if Enum.any?(casted, &expr?/1) do if function_exported?(mod, :partial_evaluate, 1) && match?(%^mod{}, function) do mod.partial_evaluate(function) else {:ok, function} end else case function do %^mod{__predicate__?: _} -> if mod.eager_evaluate?() do if mod.evaluate_nil_inputs?() || Enum.all?(function.arguments, &(not is_nil(&1))) do case mod.evaluate(function) do {:known, result} -> {:ok, result} :unknown -> {:ok, function} {:error, error} -> {:error, error} end else {:ok, nil} end else {:ok, function} end _ -> {:ok, function} end end other -> other end end else did_you_mean = Enum.map_join(allowed_arg_counts, "\n", fn arg_count -> " . * #{mod.name()}/#{arg_count}" end) {:error, """ No such function #{mod.name()}/#{given_arg_count}. Did you mean one of: #{did_you_mean} """} end end end def try_cast_arguments(configured_args, args) do args |> Enum.zip(configured_args) |> Enum.reduce_while({:ok, []}, fn {nil, _}, {:ok, args} -> {:cont, {:ok, [nil | args]}} {arg, :any}, {:ok, args} -> {:cont, {:ok, [arg | args]}} {arg, :same}, {:ok, args} -> {:cont, {:ok, [arg | args]}} {%{__predicate__?: _} = arg, _}, {:ok, args} -> {:cont, {:ok, [arg | args]}} {arg, {type, constraints}}, {:ok, args} when type != :array -> if expr?(arg) do {:cont, {:ok, [arg | args]}} else case Ash.Query.Type.try_cast(arg, type, constraints) do {:ok, value} -> {:cont, {:ok, [value | args]}} :error -> {:halt, :error} end end {arg, type}, {:ok, args} -> if expr?(arg) do {:cont, {:ok, [arg | args]}} else case Ash.Query.Type.try_cast(arg, type, []) do {:ok, value} -> {:cont, {:ok, [value | args]}} :error -> {:halt, :error} end end end) |> case do {:ok, args} -> Enum.reverse(args) _ -> nil end end # Copied from https://github.com/andrewhao/ordinal/blob/master/lib/ordinal.ex @doc """ Attaches the appropriate suffix to refer to an ordinal number, e.g 1 -> "1st" """ def ordinal(num) do cond do Enum.any?([11, 12, 13], &(&1 == Integer.mod(num, 100))) -> "#{num}th" Integer.mod(num, 10) == 1 -> "#{num}st" Integer.mod(num, 10) == 2 -> "#{num}nd" Integer.mod(num, 10) == 3 -> "#{num}rd" true -> "#{num}th" end end defmacro __using__(opts) do quote do @before_compile Ash.Query.Function @behaviour Ash.Query.Function if unquote(opts[:predicate?] || false) do @behaviour Ash.Filter.Predicate end alias Ash.Query.Ref defstruct [ :arguments, name: unquote(opts[:name]), embedded?: false, __function__?: true, __predicate__?: unquote(opts[:predicate?] || false), extra: %{} ] @impl Ash.Query.Function def predicate?, do: unquote(opts[:predicate?] || false) @impl Ash.Query.Function def name, do: unquote(opts[:name]) @impl Ash.Query.Function def new(args), do: {:ok, struct(__MODULE__, arguments: args)} @impl Ash.Query.Function def evaluate(_), do: :unknown @impl Ash.Query.Function def eager_evaluate?, do: unquote(Keyword.get(opts, :eager_evaluate?, true)) @impl Ash.Query.Function def evaluate_nil_inputs?, do: false @impl Ash.Query.Function def private?, do: false @impl Ash.Query.Function def can_return_nil?(_), do: true @impl Ash.Query.Function def returns do :unknown end defoverridable new: 1, evaluate: 1, private?: 0, evaluate_nil_inputs?: 0, can_return_nil?: 1, returns: 0 if !unquote(opts[:no_inspect?]) do defimpl Inspect do import Inspect.Algebra def inspect(%{arguments: args, name: name}, opts) do concat( to_string(name), container_doc("(", args, ")", opts, &to_doc/2, separator: ",") ) end end end end end defmacro __before_compile__(_) do quote generated: true do if Module.defines?(__MODULE__, {:partial_evaluate, 1}, :def) do def has_partial_evaluate?, do: true else def has_partial_evaluate?, do: false end end end end