require Logger defmodule ExoSQL.Builtins do @moduledoc """ Builtin functions. There are two categories, normal functions and aggregate functions. Aggregate functions receive as first parameter a ExoSQL.Result with a full table, and the rest of parameters are the function calling parameters, unsolved. These expressions must be first simplified with `ExoSQL.executor.simplify_expr_columns` and then executed on the rows with `ExoSQL.Expr.run_expr`. """ import ExoSQL.Utils, only: [to_number: 1, to_float: 1] @functions %{ "round" => {ExoSQL.Builtins, :round}, "concat" => {ExoSQL.Builtins, :concat}, "not" => {ExoSQL.Builtins, :not_}, "if" => {ExoSQL.Builtins, :if_}, "bool" => {ExoSQL.Builtins, :bool}, "lower" => {ExoSQL.Builtins, :lower}, "upper" => {ExoSQL.Builtins, :upper}, "to_string" => {ExoSQL.Builtins, :to_string}, "to_datetime" => {ExoSQL.Builtins, :to_datetime}, "to_timestamp" => {ExoSQL.Builtins, :to_timestamp}, "substr" => {ExoSQL.Builtins, :substr}, "now" => {ExoSQL.Builtins, :now}, "strftime" => {ExoSQL.Builtins, :strftime}, "format" => {ExoSQL.Builtins, :format}, "width_bucket" => {ExoSQL.Builtins, :width_bucket}, "generate_series" => {ExoSQL.Builtins, :generate_series}, "urlparse" => {ExoSQL.Builtins, :urlparse}, "jp" => {ExoSQL.Builtins, :jp}, ## Aggregates "count" => {ExoSQL.Builtins, :count}, "sum" => {ExoSQL.Builtins, :sum}, "avg" => {ExoSQL.Builtins, :avg}, "max" => {ExoSQL.Builtins, :max_}, "min" => {ExoSQL.Builtins, :min_}, } def call_function(name, args) do case @functions[name] do nil -> raise BadFunctionError, {:builtin, name} {mod, fun} -> apply(mod, fun, args) end end def round(n, r) do {:ok, n} = to_float(n) {:ok, r} = to_number(r) Float.round(n, r) end def concat(a, b) do a = to_string(a) b = to_string(b) a <> b end def not_(a) do not bool(a) end def if_(cond_, then_, else_ \\ nil) do if cond_ do then_ else else_ end end def bool(nil), do: false def bool(0), do: false def bool(""), do: false def bool(false), do: false def bool(_), do: true def lower(s), do: String.downcase(s) def upper(s), do: String.upcase(s) def to_string_(%DateTime{} = d), do: DateTime.to_iso8601(d) def to_string_(s), do: to_string(s) def now(), do: DateTime.utc_now() def to_datetime(other), do: ExoSQL.DateTime.to_datetime(other) def to_timestamp(%DateTime{} = d), do: DateTime.to_unix(d) def substr(nil, _skip, _len) do "" end def substr(str, skip, len) do str = to_string_(str) # force string {:ok, skip} = to_number(skip) {:ok, len} = to_number(len) if len < 0 do String.slice(str, skip, max(0, String.length(str) + len - skip)) else String.slice(str, skip, len) end end def substr(str, skip) do substr(str, skip, 10_000) # A upper limit on what to return, should be enought end @doc ~S""" Convert datetime to string. If no format is given, it is as to_string, which returns the ISO 8601. Format allows all substitutions from [Timex.format](https://hexdocs.pm/timex/Timex.Format.DateTime.Formatters.Strftime.html), for example: %d day of month: 00 %H hour: 00-24 %m month: 01-12 %M minute: 00-59 %s seconds since 1970-01-01 %S seconds: 00-59 %Y year: 0000-9999 %i ISO 8601 format %V Week number %% % """ def strftime(%DateTime{} = d), do: to_string_(d) def strftime(%DateTime{} = d, format), do: ExoSQL.DateTime.strftime(d, format) def strftime(other, format), do: strftime(to_datetime(other), format) @doc ~S""" sprintf style formatting. Uses exprintf. """ def format(str, args) when is_list(args) do ExoSQL.Format.format(str, args) end @doc ~S""" Very simple sprintf formatter. Knows this formats: * %% * %s * %d * %f (only two decimals) * %.{ndec}f """ def format(str, arg1), do: format(str, [arg1]) def format(str, arg1, arg2), do: format(str, [arg1, arg2]) def format(str, arg1, arg2, arg3), do: format(str, [arg1, arg2, arg3]) def format(str, arg1, arg2, arg3, arg4), do: format(str, [arg1, arg2, arg3, arg4]) def format(str, arg1, arg2, arg3, arg4, arg5), do: format(str, [arg1, arg2, arg3, arg4, arg5]) def format(str, arg1, arg2, arg3, arg4, arg5, arg6), do: format(str, [arg1, arg2, arg3, arg4, arg5, arg6]) def format(str, arg1, arg2, arg3, arg4, arg5, arg6, arg7), do: format(str, [arg1, arg2, arg3, arg4, arg5, arg6, arg7]) def format(str, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8), do: format(str, [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8]) def format(str, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9), do: format(str, [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9]) @doc ~S""" Returns to which bucket it belongs. Only numbers, but datetimes can be transformed to unix datetime. """ def width_bucket(n, start_, end_, nbuckets) do import ExoSQL.Utils, only: [to_float!: 1, to_number!: 1] n = to_float!(n) start_ = to_float!(start_) end_ = to_float!(end_) nbuckets = to_number!(nbuckets) ((n - start_) * nbuckets / (end_- start_)) |> Kernel.round end @doc ~S""" Generates a table with the series of numbers as given. Use for histograms without holes. """ def generate_series(end_), do: generate_series(1,end_,1) def generate_series(start_,end_), do: generate_series(start_,end_,1) def generate_series(start_,end_,step) do import ExoSQL.Utils, only: [to_number!: 1] start_ = to_number!(start_) end_ = to_number!(end_) step = to_number!(step) if step < 0 and start_ < end_ do raise ArgumentError, "Start, end and step invalid. Will never reach end." end if step >= 0 and start_ > end_ do raise ArgumentError, "Start, end and step invalid. Will never reach end." end %{ columns: ["generate_series"], rows: generate_series_range(start_, end_, step) } end defp generate_series_range(current, stop, step) do cond do step > 0 and current > stop -> [] step < 0 and current < stop -> [] true -> [ [current] | generate_series_range(current + step, stop, step)] end end @doc ~S""" Parses an URL and return some part of it. If not what is provided, returns a JSON object with: * host * port * scheme * path * query * user If what is passed, it performs a JSON Pointer search (jp function). It must receive a url with scheme://server or the result may not be well formed. For example, for emails, just use "email://connect@serverboards.io" or similar. """ def urlparse(url), do: urlparse(url, nil) def urlparse(nil, what), do: urlparse("", what) def urlparse(url, what) do parsed = URI.parse(url) query = case parsed.query do nil -> nil q -> URI.decode_query(q) end json = %{ "host" => parsed.host, "port" => parsed.port, "scheme" => parsed.scheme, "path" => parsed.path, "query" => query, "user" => parsed.userinfo, } if what do jp(json, what) else json end end @doc ~S""" Performs a JSON Pointer search on JSON data. It just uses / to separate keys. """ def jp(nil, _), do: nil def jp(json, str) when is_binary(str), do: jp(json, String.split(str, "/")) def jp(json, [ head | rest]) when is_list(json) do n = ExoSQL.Utils.to_number!(head) jp(Enum.at(json, n), rest) end def jp(json, ["" | rest]), do: jp(json, rest) def jp(json, [head | rest]), do: jp(Map.get(json, head, nil), rest) def jp(json, []), do: json ### Aggregate functions def is_aggregate("count"), do: true def is_aggregate("avg"), do: true def is_aggregate("sum"), do: true def is_aggregate(_other), do: false def count(data, _) do # Logger.debug("Count #{inspect data}") Enum.count(data.rows) end def avg(data, expr) do # Logger.debug("Avg of #{inspect data} by #{inspect expr}") sum(data, expr) / count(data, nil) end def sum(data, expr) do # Logger.debug("Sum of #{inspect data} by #{inspect expr}") expr = ExoSQL.Executor.simplify_expr_columns(expr, data.columns, nil) # Logger.debug("Simplified expression #{inspect expr}") Enum.reduce(data.rows, 0, fn row, acc -> n = ExoSQL.Expr.run_expr(expr, row) n = case ExoSQL.Utils.to_number(n) do {:ok, n} -> n {:error, nil} -> 0 end acc + n end) end def max_(data, expr) do expr = ExoSQL.Executor.simplify_expr_columns(expr, data.columns, nil) Enum.reduce(data.rows, nil, fn row, acc -> n = ExoSQL.Expr.run_expr(expr, row) {:ok, n} = ExoSQL.Utils.to_number(n) if not acc or n > acc do n else acc end end) end def min_(data, expr) do expr = ExoSQL.Executor.simplify_expr_columns(expr, data.columns, nil) Enum.reduce(data.rows, nil, fn row, acc -> n = ExoSQL.Expr.run_expr(expr, row) {:ok, n} = ExoSQL.Utils.to_number(n) if not acc or n < acc do n else acc end end) end end