analytics/lib/plausible/stats/api_query_parser.ex

358 lines
12 KiB
Elixir

defmodule Plausible.Stats.ApiQueryParser do
@moduledoc false
use Plausible
alias Plausible.Stats.{Filters, Metrics, JSONSchema}
@default_include %Plausible.Stats.QueryInclude{
imports: false,
imports_meta: false,
time_labels: false,
total_rows: false,
trim_relative_date_range: false,
compare: nil,
compare_match_day_of_week: false,
legacy_time_on_page_cutoff: nil
}
def default_include(), do: @default_include
@default_pagination %{limit: 10_000, offset: 0}
def default_pagination(), do: @default_pagination
def parse(schema_type, params) when is_map(params) do
input_date_range = Map.get(params, "date_range")
with :ok <- JSONSchema.validate(schema_type, params),
{:ok, input_date_range} <- parse_input_date_range(input_date_range),
{:ok, metrics} <- parse_metrics(Map.fetch!(params, "metrics")),
{:ok, filters} <- parse_filters(params["filters"]),
{:ok, dimensions} <- parse_dimensions(params["dimensions"]),
{:ok, order_by} <- parse_order_by(params["order_by"]),
{:ok, pagination} <- parse_pagination(params["pagination"]),
{:ok, include} <- parse_include(params["include"]) do
{:ok,
Plausible.Stats.ParsedQueryParams.new!(%{
input_date_range: input_date_range,
metrics: metrics,
filters: filters,
dimensions: dimensions,
order_by: order_by,
pagination: pagination,
include: include
})}
end
end
defp parse_metrics(metrics) when is_list(metrics) do
parse_list(metrics, &parse_metric/1)
end
defp parse_metric(metric_str) do
case Metrics.from_string(metric_str) do
{:ok, metric} -> {:ok, metric}
_ -> {:error, "Unknown metric '#{i(metric_str)}'."}
end
end
def parse_filters(filters) when is_list(filters) do
parse_list(filters, &parse_filter/1)
end
def parse_filters(nil), do: {:ok, []}
defp parse_filter(filter) do
with {:ok, operator} <- parse_operator(filter),
{:ok, second} <- parse_filter_second(operator, filter),
{:ok, rest} <- parse_filter_rest(operator, filter) do
{:ok, [operator, second | rest]}
end
end
defp parse_operator(["is" | _rest]), do: {:ok, :is}
defp parse_operator(["is_not" | _rest]), do: {:ok, :is_not}
defp parse_operator(["matches" | _rest]), do: {:ok, :matches}
defp parse_operator(["matches_not" | _rest]), do: {:ok, :matches_not}
defp parse_operator(["matches_wildcard" | _rest]), do: {:ok, :matches_wildcard}
defp parse_operator(["matches_wildcard_not" | _rest]), do: {:ok, :matches_wildcard_not}
defp parse_operator(["contains" | _rest]), do: {:ok, :contains}
defp parse_operator(["contains_not" | _rest]), do: {:ok, :contains_not}
defp parse_operator(["and" | _rest]), do: {:ok, :and}
defp parse_operator(["or" | _rest]), do: {:ok, :or}
defp parse_operator(["not" | _rest]), do: {:ok, :not}
defp parse_operator(["has_done" | _rest]), do: {:ok, :has_done}
defp parse_operator(["has_not_done" | _rest]), do: {:ok, :has_not_done}
defp parse_operator(filter), do: {:error, "Unknown operator for filter '#{i(filter)}'."}
def parse_filter_second(operator, [_, filters | _rest]) when operator in [:and, :or],
do: parse_filters(filters)
def parse_filter_second(operator, [_, filter | _rest])
when operator in [:not, :has_done, :has_not_done],
do: parse_filter(filter)
def parse_filter_second(_operator, filter), do: parse_filter_dimension(filter)
defp parse_filter_dimension([_operator, filter_dimension | _rest] = filter) do
parse_filter_dimension_string(filter_dimension, "Invalid filter '#{i(filter)}")
end
defp parse_filter_dimension(filter), do: {:error, "Invalid filter '#{i(filter)}'."}
defp parse_filter_rest(operator, filter)
when operator in [
:is,
:is_not,
:matches,
:matches_not,
:matches_wildcard,
:matches_wildcard_not,
:contains,
:contains_not
] do
with {:ok, clauses} <- parse_clauses_list(filter),
{:ok, modifiers} <- parse_filter_modifiers(Enum.at(filter, 3)) do
{:ok, [clauses | modifiers]}
end
end
defp parse_filter_rest(operator, _filter)
when operator in [:not, :and, :or, :has_done, :has_not_done],
do: {:ok, []}
defp parse_clauses_list([operator, dimension, list | _rest] = filter) when is_list(list) do
all_strings? = Enum.all?(list, &is_binary/1)
all_integers? = Enum.all?(list, &is_integer/1)
case {dimension, all_strings?} do
{"visit:city", false} when all_integers? ->
{:ok, list}
{"visit:country", true} when operator in ["is", "is_not"] ->
if Enum.all?(list, &(String.length(&1) == 2)) do
{:ok, list}
else
{:error,
"Invalid visit:country filter, visit:country needs to be a valid 2-letter country code."}
end
{"segment", _} when all_integers? ->
{:ok, list}
{_, true} when dimension !== "segment" ->
{:ok, list}
_ ->
{:error, "Invalid filter '#{i(filter)}'."}
end
end
defp parse_clauses_list(filter), do: {:error, "Invalid filter '#{i(filter)}'"}
defp parse_filter_modifiers(modifiers) when is_map(modifiers) do
{:ok, [atomize_keys(modifiers)]}
end
defp parse_filter_modifiers(nil) do
{:ok, []}
end
defp parse_input_date_range("realtime"), do: {:ok, :realtime}
defp parse_input_date_range("30m"), do: {:ok, :realtime_30m}
defp parse_input_date_range("day"), do: {:ok, :day}
defp parse_input_date_range("month"), do: {:ok, :month}
defp parse_input_date_range("year"), do: {:ok, :year}
defp parse_input_date_range("all"), do: {:ok, :all}
defp parse_input_date_range(shorthand) when is_binary(shorthand) do
case Integer.parse(shorthand) do
{n, "d"} when n > 0 and n <= 5_000 -> {:ok, {:last_n_days, n}}
{n, "mo"} when n > 0 and n <= 100 -> {:ok, {:last_n_months, n}}
_ -> {:error, "Invalid date_range #{i(shorthand)}"}
end
end
defp parse_input_date_range([from, to]) when is_binary(from) and is_binary(to) do
case parse_date_strings(from, to) do
{:ok, dates} -> {:ok, dates}
{:error, _} -> parse_timestamp_strings(from, to)
end
end
defp parse_input_date_range(unknown) do
{:error, "Invalid date_range #{i(unknown)}"}
end
defp parse_date_strings(from, to) do
with {:ok, from_date} <- Date.from_iso8601(from),
{:ok, to_date} <- Date.from_iso8601(to) do
{:ok, {:date_range, from_date, to_date}}
end
end
defp parse_timestamp_strings(from, to) do
with {:ok, from_datetime, _offset} <- DateTime.from_iso8601(from),
{:ok, to_datetime, _offset} <- DateTime.from_iso8601(to) do
{:ok, {:datetime_range, from_datetime, to_datetime}}
else
_ -> {:error, "Invalid date_range '#{i([from, to])}'."}
end
end
defp parse_dimensions(dimensions) when is_list(dimensions) do
parse_list(
dimensions,
&parse_dimension_entry(&1, "Invalid dimensions '#{i(dimensions)}'")
)
end
defp parse_dimensions(nil), do: {:ok, []}
def parse_order_by(order_by) when is_list(order_by) do
parse_list(order_by, &parse_order_by_entry/1)
end
def parse_order_by(nil), do: {:ok, nil}
def parse_order_by(order_by), do: {:error, "Invalid order_by '#{i(order_by)}'."}
defp parse_order_by_entry(entry) do
with {:ok, value} <- parse_metric_or_dimension(entry),
{:ok, order_direction} <- parse_order_direction(entry) do
{:ok, {value, order_direction}}
end
end
defp parse_dimension_entry(key, error_message) do
case {
parse_time(key),
parse_filter_dimension_string(key)
} do
{{:ok, time}, _} -> {:ok, time}
{_, {:ok, dimension}} -> {:ok, dimension}
_ -> {:error, error_message}
end
end
defp parse_metric_or_dimension([value, _] = entry) do
case {
parse_time(value),
parse_metric(value),
parse_filter_dimension_string(value)
} do
{{:ok, time}, _, _} -> {:ok, time}
{_, {:ok, metric}, _} -> {:ok, metric}
{_, _, {:ok, dimension}} -> {:ok, dimension}
_ -> {:error, "Invalid order_by entry '#{i(entry)}'."}
end
end
defp parse_time("time"), do: {:ok, "time"}
defp parse_time("time:minute"), do: {:ok, "time:minute"}
defp parse_time("time:hour"), do: {:ok, "time:hour"}
defp parse_time("time:day"), do: {:ok, "time:day"}
defp parse_time("time:week"), do: {:ok, "time:week"}
defp parse_time("time:month"), do: {:ok, "time:month"}
defp parse_time(_), do: :error
defp parse_order_direction([_, "asc"]), do: {:ok, :asc}
defp parse_order_direction([_, "desc"]), do: {:ok, :desc}
defp parse_order_direction(entry), do: {:error, "Invalid order_by entry '#{i(entry)}'."}
def parse_include(include) when is_map(include) do
parsed_include_params_or_error =
include
|> Enum.reduce_while({:ok, []}, fn {key, value}, {:ok, acc} ->
case parse_include_entry(key, value) do
{:ok, parsed_tuple} -> {:cont, {:ok, acc ++ [parsed_tuple]}}
{:error, msg} -> {:halt, {:error, msg}}
end
end)
with {:ok, parsed_include_params} <- parsed_include_params_or_error do
{:ok, struct!(@default_include, parsed_include_params)}
end
end
def parse_include(nil), do: {:ok, @default_include}
def parse_include(include), do: {:error, "Invalid include '#{i(include)}'."}
@allowed_include_keys Enum.map(Map.keys(@default_include), &Atom.to_string/1)
defp parse_include_entry("compare", value) do
with {:ok, parsed_compare} <- parse_compare(value) do
{:ok, {:compare, parsed_compare}}
end
end
defp parse_include_entry(key, value) when key in @allowed_include_keys do
{:ok, {String.to_existing_atom(key), value}}
end
defp parse_include_entry(key, _value), do: {:error, "Invalid include key'#{i(key)}'."}
defp parse_compare(false), do: {:ok, nil}
defp parse_compare("previous_period"), do: {:ok, :previous_period}
defp parse_compare("year_over_year"), do: {:ok, :year_over_year}
defp parse_compare([from_date, to_date]), do: parse_date_strings(from_date, to_date)
defp parse_compare(compare), do: {:error, "Invalid include.compare '#{i(compare)}'."}
defp parse_pagination(pagination) when is_map(pagination) do
{:ok, Map.merge(@default_pagination, atomize_keys(pagination))}
end
defp parse_pagination(nil), do: {:ok, @default_pagination}
defp atomize_keys(map) when is_map(map) do
Map.new(map, fn {key, value} ->
key = String.to_existing_atom(key)
{key, atomize_keys(value)}
end)
end
defp atomize_keys(value), do: value
defp parse_filter_dimension_string(dimension, error_message \\ "") do
case dimension do
"event:props:" <> property_name ->
if String.length(property_name) > 0 do
{:ok, dimension}
else
{:error, error_message}
end
"event:" <> key ->
if key in Filters.event_props() do
{:ok, dimension}
else
{:error, error_message}
end
"visit:" <> key ->
if key in Filters.visit_props() do
{:ok, dimension}
else
{:error, error_message}
end
"segment" ->
{:ok, dimension}
_ ->
{:error, error_message}
end
end
defp i(value), do: inspect(value, charlists: :as_lists)
defp parse_list(list, parser_function) do
Enum.reduce_while(list, {:ok, []}, fn value, {:ok, results} ->
case parser_function.(value) do
{:ok, result} -> {:cont, {:ok, results ++ [result]}}
{:error, _} = error -> {:halt, error}
end
end)
end
end