| defmodule Plausible.Stats.QueryBuilder do |
| @moduledoc """ |
| A module used for building the Query struct from already parsed params. |
| """ |
|
|
| use Plausible |
| alias Plausible.Segments |
|
|
| alias Plausible.Stats.{ |
| Query, |
| ParsedQueryParams, |
| Comparisons, |
| Filters, |
| Time, |
| TableDecider, |
| DateTimeRange, |
| QueryError, |
| QueryInclude, |
| QueryPeriod |
| } |
|
|
| @doc """ |
| Runs various validations and builds a `%Query{}` from already parsed params. |
| |
| For convenience, the "parsed params" can also be given as a keyword list, in |
| which case it gets turned in to `ParsedQueryParams` by the function itself. |
| """ |
| def build(site, parsed_query_params, debug_metadata \\ %{}) |
|
|
| def build(site, %ParsedQueryParams{} = parsed_query_params, debug_metadata) do |
| with {:ok, parsed_query_params} <- resolve_segments_in_filters(parsed_query_params, site), |
| {:ok, query} <- do_build(parsed_query_params, site, debug_metadata), |
| :ok <- validate_order_by(query), |
| :ok <- validate_custom_props_access(site, query), |
| :ok <- validate_case_sensitive_filter_modifier(query), |
| :ok <- validate_toplevel_only_filter_dimension(query), |
| :ok <- validate_time_dimension_granularity(query), |
| :ok <- validate_special_metrics_filters(query), |
| :ok <- validate_behavioral_filters(query), |
| :ok <- validate_filtered_goals_exist(query, parsed_query_params), |
| :ok <- validate_revenue_metrics_access(site, query), |
| :ok <- validate_metrics(query), |
| :ok <- validate_include(query) do |
| query = |
| query |
| |> set_time_on_page_data(site) |
| |> put_comparison_utc_time_range() |
| |> Query.put_imported_opts(site) |
|
|
| on_ee do |
| |
| |
| |
| query = Plausible.Stats.Sampling.put_threshold(query, site, %{}) |
| end |
|
|
| {:ok, query} |
| end |
| end |
|
|
| def build(site, params, debug_metadata) when is_list(params) do |
| include = struct!(QueryInclude, Keyword.get(params, :include, [])) |
| parsed_query_params = struct!(ParsedQueryParams, Keyword.put(params, :include, include)) |
|
|
| build(site, parsed_query_params, debug_metadata) |
| end |
|
|
| def build!(site, params, debug_metadata \\ %{}) do |
| case build(site, params, debug_metadata) do |
| {:ok, query} -> |
| query |
|
|
| {:error, %QueryError{message: message}} -> |
| raise "Failed to build query: #{inspect(message)}" |
| end |
| end |
|
|
| defp resolve_segments_in_filters(%ParsedQueryParams{} = parsed_query_params, site) do |
| with {:ok, preloaded_segments} <- |
| Segments.Filters.preload_needed_segments(site, parsed_query_params.filters), |
| {:ok, filters} <- |
| Segments.Filters.resolve_segments(parsed_query_params.filters, preloaded_segments) do |
| {:ok, struct!(parsed_query_params, filters: filters)} |
| end |
| end |
|
|
| defp do_build(parsed_query_params, site, debug_metadata) do |
| parsed_query_params |
| |> ParsedQueryParams.to_query!() |
| |> set_now() |
| |> set_utc_time_range(site, Map.get(parsed_query_params, :relative_date)) |
| |> set_preloaded_goals_and_revenue(site) |
| |> Query.set( |
| site_id: site.id, |
| site_native_stats_start_at: site.native_stats_start_at, |
| timezone: site.timezone, |
| consolidated_site_ids: get_consolidated_site_ids(site), |
| debug_metadata: debug_metadata |
| ) |
| |> maybe_drop_revenue_metrics() |
| end |
|
|
| defp set_now(%Query{now: nil} = query), do: Query.set(query, now: DateTime.utc_now(:second)) |
| defp set_now(query), do: query |
|
|
| defp set_utc_time_range(query, site, relative_date) do |
| utc_time_range = |
| query.input_date_range |
| |> QueryPeriod.build_range_for_site(site, relative_date, query.now) |
| |> DateTimeRange.to_timezone("Etc/UTC") |
|
|
| Query.set(query, utc_time_range: utc_time_range) |
| end |
|
|
| defp set_preloaded_goals_and_revenue(query, site) do |
| {preloaded_goals, revenue_warning, revenue_currencies} = |
| preload_goals_and_revenue(site, query.metrics, query.filters, query.dimensions) |
|
|
| Query.set(query, |
| preloaded_goals: preloaded_goals, |
| revenue_warning: revenue_warning, |
| revenue_currencies: revenue_currencies |
| ) |
| end |
|
|
| def preload_goals_and_revenue(site, metrics, filters, dimensions) do |
| preloaded_goals = |
| Plausible.Stats.Goals.preload_needed_goals(site, dimensions, filters) |
|
|
| {revenue_warning, revenue_currencies} = |
| preload_revenue(site, preloaded_goals, metrics, dimensions) |
|
|
| { |
| preloaded_goals, |
| revenue_warning, |
| revenue_currencies |
| } |
| end |
|
|
| on_ee do |
| def get_consolidated_site_ids(%Plausible.Site{} = site) do |
| if Plausible.Sites.consolidated?(site) do |
| Plausible.ConsolidatedView.Cache.get(site.domain) |
| end |
| end |
| else |
| def get_consolidated_site_ids(_site), do: nil |
| end |
|
|
| def set_time_on_page_data(query, site) do |
| struct!(query, |
| time_on_page_data: %{ |
| new_metric_visible: Plausible.Stats.TimeOnPage.new_time_on_page_visible?(site), |
| cutoff_date: site.legacy_time_on_page_cutoff |
| } |
| ) |
| end |
|
|
| def put_comparison_utc_time_range(%Query{include: %{compare: nil}} = query), do: query |
|
|
| def put_comparison_utc_time_range(%Query{} = query) do |
| datetime_range = Comparisons.get_comparison_utc_time_range(query) |
| struct!(query, comparison_utc_time_range: datetime_range) |
| end |
|
|
| on_ee do |
| alias Plausible.Stats.Goal.Revenue |
|
|
| def preload_revenue(site, preloaded_goals, metrics, dimensions) do |
| Revenue.preload(site, preloaded_goals, metrics, dimensions) |
| end |
|
|
| defp validate_revenue_metrics_access(site, query) do |
| if Revenue.requested?(query.metrics) and not Revenue.available?(site) do |
| {:error, |
| %QueryError{ |
| code: :feature_access, |
| message: "The owner of this site does not have access to the revenue metrics feature." |
| }} |
| else |
| :ok |
| end |
| end |
|
|
| defp maybe_drop_revenue_metrics( |
| %Query{ |
| include: %QueryInclude{drop_unavailable_revenue_metrics: true}, |
| revenue_currencies: revenue_currencies |
| } = query |
| ) |
| when map_size(revenue_currencies) == 0 do |
| if Enum.all?(query.metrics, &(&1 in Revenue.revenue_metrics())) do |
| {:error, |
| %QueryError{ |
| code: :all_metrics_dropped, |
| message: "Revenue metrics were dropped and no other metrics were left to query." |
| }} |
| else |
| {:ok, Query.set(query, metrics: query.metrics -- Revenue.revenue_metrics())} |
| end |
| end |
|
|
| defp maybe_drop_revenue_metrics(%Query{} = query), do: {:ok, query} |
| else |
| defp preload_revenue(_site, _preloaded_goals, _metrics, _dimensions), do: {nil, %{}} |
|
|
| defp validate_revenue_metrics_access(_site, _query), do: :ok |
|
|
| defp maybe_drop_revenue_metrics(query), do: {:ok, query} |
| end |
|
|
| defp validate_order_by(query) do |
| if query.order_by do |
| valid_values = query.metrics ++ query.dimensions |
|
|
| invalid_entry = |
| Enum.find(query.order_by, fn {value, _direction} -> |
| not Enum.member?(valid_values, value) |
| end) |
|
|
| case invalid_entry do |
| nil -> |
| :ok |
|
|
| _ -> |
| {:error, |
| %QueryError{ |
| code: :invalid_order_by, |
| message: |
| "Invalid order_by entry '#{i(invalid_entry)}'. Entry is not a queried metric or dimension." |
| }} |
| end |
| else |
| :ok |
| end |
| end |
|
|
| @pattern_filter_operators [:matches, :matches_not, :matches_wildcard, :matches_wildcard_not] |
| defp validate_case_sensitive_filter_modifier(query) do |
| invalid_filter = |
| query.filters |
| |> Filters.all_leaf_filters() |
| |> Enum.find(fn filter -> |
| case filter do |
| [operator, _, _, modifiers] when operator in @pattern_filter_operators -> |
| is_map_key(modifiers, :case_insensitive) |
|
|
| _ -> |
| false |
| end |
| end) |
|
|
| if invalid_filter do |
| {:error, |
| %QueryError{ |
| code: :invalid_filters, |
| message: |
| "Invalid filters. The case_sensitive modifier is not allowed with pattern operators (#{i(invalid_filter)})" |
| }} |
| else |
| :ok |
| end |
| end |
|
|
| @only_toplevel ["event:goal", "event:hostname"] |
| defp validate_toplevel_only_filter_dimension(query) do |
| not_toplevel = |
| query.filters |
| |> Filters.dimensions_used_in_filters(min_depth: 1, behavioral_filters: :ignore) |
| |> Enum.filter(&(&1 in @only_toplevel)) |
|
|
| if Enum.count(not_toplevel) > 0 do |
| {:error, |
| %QueryError{ |
| code: :invalid_filters, |
| message: |
| "Invalid filters. Dimension `#{List.first(not_toplevel)}` can only be filtered at the top level." |
| }} |
| else |
| :ok |
| end |
| end |
|
|
| @max_hours_for_minute_interval 30 |
|
|
| defp validate_time_dimension_granularity(query) do |
| if Time.time_dimension(query) == "time:minute" and |
| DateTimeRange.length(query.utc_time_range, :minute) > @max_hours_for_minute_interval * 60 do |
| {:error, |
| %QueryError{ |
| code: :invalid_dimensions, |
| message: |
| "Invalid dimensions. Dimension `time:minute` is only supported for time ranges up to 30 hours." |
| }} |
| else |
| :ok |
| end |
| end |
|
|
| @special_metrics [:conversion_rate, :group_conversion_rate] |
| defp validate_special_metrics_filters(query) do |
| special_metric? = Enum.any?(@special_metrics, &(&1 in query.metrics)) |
|
|
| deep_custom_property? = |
| query.filters |
| |> Filters.dimensions_used_in_filters(min_depth: 1) |
| |> Enum.any?(fn dimension -> String.starts_with?(dimension, "event:props:") end) |
|
|
| if special_metric? and deep_custom_property? do |
| {:error, |
| %QueryError{ |
| code: :invalid_filters, |
| message: |
| "Invalid filters. When `conversion_rate` or `group_conversion_rate` metrics are used, custom property filters can only be used on top level." |
| }} |
| else |
| :ok |
| end |
| end |
|
|
| defp validate_behavioral_filters(query) do |
| query.filters |
| |> Filters.traverse(0, fn behavioral_depth, operator -> |
| if operator in [:has_done, :has_not_done] do |
| behavioral_depth + 1 |
| else |
| behavioral_depth |
| end |
| end) |
| |> Enum.reduce_while(:ok, fn {[_operator, dimension | _rest], behavioral_depth}, :ok -> |
| cond do |
| behavioral_depth == 0 -> |
| |
| {:cont, :ok} |
|
|
| behavioral_depth > 1 -> |
| {:halt, |
| {:error, |
| %QueryError{ |
| code: :invalid_filters, |
| message: |
| "Invalid filters. Behavioral filters (has_done, has_not_done) cannot be nested." |
| }}} |
|
|
| not String.starts_with?(dimension, "event:") -> |
| {:halt, |
| {:error, |
| %QueryError{ |
| code: :invalid_filters, |
| message: |
| "Invalid filters. Behavioral filters (has_done, has_not_done) can only be used with event dimension filters." |
| }}} |
|
|
| true -> |
| {:cont, :ok} |
| end |
| end) |
| end |
|
|
| defp validate_filtered_goals_exist(_query, %ParsedQueryParams{skip_goal_existence_check: true}), |
| do: :ok |
|
|
| defp validate_filtered_goals_exist(query, %ParsedQueryParams{}) do |
| |
| goal_filter_clauses = |
| query.filters |
| |> Filters.all_leaf_filters() |
| |> Enum.flat_map(fn |
| [:is, "event:goal", clauses] -> clauses |
| _ -> [] |
| end) |
|
|
| if length(goal_filter_clauses) > 0 do |
| configured_goal_names = |
| query.preloaded_goals.all |
| |> Enum.map(&Plausible.Goal.display_name/1) |
|
|
| validate_list(goal_filter_clauses, &validate_goal_filter(&1, configured_goal_names)) |
| else |
| :ok |
| end |
| end |
|
|
| defp validate_goal_filter(clause, configured_goal_names) do |
| if Enum.member?(configured_goal_names, clause) do |
| :ok |
| else |
| {:error, |
| %QueryError{ |
| code: :invalid_filters, |
| message: |
| "Invalid filters. The goal `#{clause}` is not configured for this site. Find out how to configure goals here: https://plausible.io/docs/stats-api#filtering-by-goals" |
| }} |
| end |
| end |
|
|
| defp validate_custom_props_access(site, query) do |
| allowed_props = Plausible.Props.allowed_for(site, bypass_setup?: true) |
|
|
| validate_custom_props_access(site, query, allowed_props) |
| end |
|
|
| defp validate_custom_props_access(_site, _query, :all), do: :ok |
|
|
| defp validate_custom_props_access(_site, query, allowed_props) do |
| valid? = |
| query.filters |
| |> Filters.dimensions_used_in_filters() |
| |> Enum.concat(query.dimensions) |
| |> Enum.all?(fn |
| "event:props:" <> prop -> prop in allowed_props |
| _ -> true |
| end) |
|
|
| if valid? do |
| :ok |
| else |
| {:error, |
| %QueryError{ |
| code: :feature_access, |
| message: "The owner of this site does not have access to the custom properties feature." |
| }} |
| end |
| end |
|
|
| defp validate_metrics(query) do |
| with :ok <- validate_list(query.metrics, &validate_metric(&1, query)) do |
| TableDecider.validate_no_metrics_dimensions_conflict(query) |
| end |
| end |
|
|
| defp validate_metric(metric, query) when metric in [:conversion_rate, :group_conversion_rate] do |
| if Enum.member?(query.dimensions, "event:goal") or |
| Filters.filtering_on_dimension?(query, "event:goal", behavioral_filters: :ignore) do |
| :ok |
| else |
| {:error, |
| %QueryError{ |
| code: :invalid_metrics, |
| message: "Metric `#{metric}` can only be queried with event:goal filters or dimensions." |
| }} |
| end |
| end |
|
|
| defp validate_metric(:scroll_depth = metric, query) do |
| page_dimension? = Enum.member?(query.dimensions, "event:page") |
| toplevel_page_filter? = not is_nil(Filters.get_toplevel_filter(query, "event:page")) |
|
|
| if page_dimension? or toplevel_page_filter? do |
| :ok |
| else |
| {:error, |
| %QueryError{ |
| code: :invalid_metrics, |
| message: "Metric `#{metric}` can only be queried with event:page filters or dimensions." |
| }} |
| end |
| end |
|
|
| defp validate_metric(:exit_rate = metric, query) do |
| case {Enum.sort(query.dimensions), TableDecider.sessions_join_events?(query)} do |
| {["visit:exit_page"], false} -> |
| :ok |
|
|
| {["visit:exit_page", "visit:exit_page_hostname"], false} -> |
| :ok |
|
|
| {["visit:exit_page"], true} -> |
| {:error, |
| %QueryError{ |
| code: :invalid_metrics, |
| message: "Metric `#{metric}` cannot be queried when filtering on event dimensions." |
| }} |
|
|
| _ -> |
| {:error, |
| %QueryError{ |
| code: :invalid_metrics, |
| message: |
| "Metric `#{metric}` requires a `\"visit:exit_page\"` dimension. No other dimensions are allowed." |
| }} |
| end |
| end |
|
|
| defp validate_metric(:views_per_visit = metric, query) do |
| cond do |
| Filters.filtering_on_dimension?(query, "event:page", behavioral_filters: :ignore) -> |
| {:error, |
| %QueryError{ |
| code: :invalid_metrics, |
| message: "Metric `#{metric}` cannot be queried with a filter on `event:page`." |
| }} |
|
|
| Enum.any?(query.dimensions, &(not Time.time_dimension?(&1))) -> |
| {:error, |
| %QueryError{ |
| code: :invalid_metrics, |
| message: "Metric `#{metric}` cannot be queried with non-time dimensions." |
| }} |
|
|
| true -> |
| :ok |
| end |
| end |
|
|
| defp validate_metric(:time_on_page = metric, query) do |
| cond do |
| Enum.member?(query.dimensions, "event:page") -> |
| :ok |
|
|
| Filters.filtering_on_dimension?(query, "event:page", behavioral_filters: :ignore) -> |
| :ok |
|
|
| true -> |
| {:error, |
| %QueryError{ |
| code: :invalid_metrics, |
| message: |
| "Metric `#{metric}` can only be queried with event:page filters or dimensions." |
| }} |
| end |
| end |
|
|
| defp validate_metric(_, _), do: :ok |
|
|
| defp validate_include(query) do |
| time_dimension? = Enum.any?(query.dimensions, &Time.time_dimension?/1) |
|
|
| if query.include.time_labels and not time_dimension? do |
| {:error, |
| %QueryError{ |
| code: :invalid_include, |
| message: "Invalid include.time_labels: requires a time dimension." |
| }} |
| else |
| :ok |
| end |
| end |
|
|
| defp i(value), do: inspect(value, charlists: :as_lists) |
|
|
| defp validate_list(list, parser_function) do |
| Enum.reduce_while(list, :ok, fn value, :ok -> |
| case parser_function.(value) do |
| :ok -> {:cont, :ok} |
| {:error, _} = error -> {:halt, error} |
| end |
| end) |
| end |
| end |
|
|