358 lines
12 KiB
Elixir
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
|