| defmodule Plausible.Stats.ApiQueryParser do |
| @moduledoc false |
|
|
| use Plausible |
|
|
| alias Plausible.Stats.{Filters, Metrics, JSONSchema, QueryError} |
|
|
| @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(params, opts \\ []) when is_map(params) do |
| with :ok <- JSONSchema.validate(params), |
| {:ok, input_date_range} <- parse_input_date_range(params["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, |
| now: Keyword.get(opts, :now) |
| })} |
| end |
| end |
|
|
| def 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, |
| %QueryError{code: :invalid_metrics, message: "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, |
| %QueryError{code: :invalid_filters, message: "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, %QueryError{code: :invalid_filters, message: "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, |
| %QueryError{ |
| code: :invalid_filters, |
| message: |
| "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, %QueryError{code: :invalid_filters, message: "Invalid filter '#{i(filter)}'."}} |
| end |
| end |
|
|
| defp parse_clauses_list(filter), |
| do: {:error, %QueryError{code: :invalid_filters, message: "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 |
|
|
| def parse_input_date_range("day"), do: {:ok, :day} |
| def parse_input_date_range("24h"), do: {:ok, :"24h"} |
| def parse_input_date_range("month"), do: {:ok, :month} |
| def parse_input_date_range("year"), do: {:ok, :year} |
| def parse_input_date_range("all"), do: {:ok, :all} |
|
|
| def 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, |
| %QueryError{code: :invalid_date_range, message: "Invalid date_range #{i(shorthand)}"}} |
| end |
| end |
|
|
| def 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 |
|
|
| def parse_input_date_range(unknown) do |
| {:error, %QueryError{code: :invalid_date_range, message: "Invalid date_range #{i(unknown)}"}} |
| end |
|
|
| def 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, |
| %QueryError{code: :invalid_date_range, message: "Invalid date_range '#{i([from, to])}'."}} |
| end |
| end |
|
|
| def parse_dimensions(dimensions) when is_list(dimensions) do |
| parse_list( |
| dimensions, |
| &parse_dimension_entry(&1, "Invalid dimensions '#{i(dimensions)}'") |
| ) |
| end |
|
|
| def 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, |
| %QueryError{code: :invalid_order_by, message: "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_dimension(key), |
| parse_non_time_dimension(key) |
| } do |
| {{:ok, time}, _} -> {:ok, time} |
| {_, {:ok, dimension}} -> {:ok, dimension} |
| _ -> {:error, %QueryError{code: :invalid_dimensions, message: error_message}} |
| end |
| end |
|
|
| defp parse_metric_or_dimension([value, _] = entry) do |
| case { |
| parse_time_dimension(value), |
| parse_metric(value), |
| parse_non_time_dimension(value) |
| } do |
| {{:ok, time_dimension}, _, _} -> |
| {:ok, time_dimension} |
|
|
| {_, {:ok, metric}, _} -> |
| {:ok, metric} |
|
|
| {_, _, {:ok, non_time_dimension}} -> |
| {:ok, non_time_dimension} |
|
|
| _ -> |
| {:error, |
| %QueryError{code: :invalid_order_by, message: "Invalid order_by entry '#{i(entry)}'."}} |
| end |
| end |
|
|
| defp parse_time_dimension("time"), do: {:ok, "time"} |
| defp parse_time_dimension("time:minute"), do: {:ok, "time:minute"} |
| defp parse_time_dimension("time:hour"), do: {:ok, "time:hour"} |
| defp parse_time_dimension("time:day"), do: {:ok, "time:day"} |
| defp parse_time_dimension("time:week"), do: {:ok, "time:week"} |
| defp parse_time_dimension("time:month"), do: {:ok, "time:month"} |
| defp parse_time_dimension(_), do: :error |
|
|
| @breakdown_only_non_time_dimensions ["event:prop_key"] |
|
|
| defp parse_non_time_dimension(dimension) do |
| if dimension in @breakdown_only_non_time_dimensions do |
| {:ok, dimension} |
| else |
| parse_filter_dimension_string(dimension) |
| end |
| end |
|
|
| defp parse_order_direction([_, "asc"]), do: {:ok, :asc} |
| defp parse_order_direction([_, "desc"]), do: {:ok, :desc} |
|
|
| defp parse_order_direction(entry), |
| do: |
| {:error, |
| %QueryError{code: :invalid_order_by, message: "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, query_error} -> {:halt, {:error, query_error}} |
| 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, %QueryError{code: :invalid_include, message: "Invalid include '#{i(include)}'."}} |
|
|
| @allowed_include_keys Enum.map(Map.keys(@default_include), &Atom.to_string/1) |
|
|
| 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, %QueryError{code: :invalid_include, message: "Invalid include key'#{i(key)}'."}} |
|
|
| def parse_pagination(pagination) when is_map(pagination) do |
| {:ok, Map.merge(@default_pagination, atomize_keys(pagination))} |
| end |
|
|
| def parse_pagination(nil), do: {:ok, @default_pagination} |
|
|
| defp atomize_keys(map) when is_map(map) do |
| Map.new(map, fn {key, value} -> |
| {String.to_existing_atom(key), value} |
| end) |
| end |
|
|
| 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, %QueryError{code: :invalid_filters, message: error_message}} |
| end |
|
|
| "event:" <> key -> |
| if key in Filters.event_props() do |
| {:ok, dimension} |
| else |
| {:error, %QueryError{code: :invalid_filters, message: error_message}} |
| end |
|
|
| "visit:" <> key -> |
| if key in Filters.visit_props() do |
| {:ok, dimension} |
| else |
| {:error, %QueryError{code: :invalid_filters, message: error_message}} |
| end |
|
|
| "segment" -> |
| {:ok, dimension} |
|
|
| _ -> |
| {:error, %QueryError{code: :invalid_filters, message: 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 |
|
|