File size: 6,389 Bytes
8da2481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
defmodule Plausible.Stats.Query do
  @moduledoc false
  use Plausible

  defstruct utc_time_range: nil,
            comparison_utc_time_range: nil,
            input_date_range: nil,
            dimensions: [],
            filters: [],
            sample_threshold: nil,
            imports_exist: false,
            imports_in_range: [],
            include_imported: false,
            skip_imported_reason: nil,
            now: nil,
            metrics: [],
            order_by: nil,
            timezone: nil,
            legacy_breakdown: false,
            preloaded_goals: [],
            include: Plausible.Stats.ApiQueryParser.default_include(),
            debug_metadata: %{},
            pagination: nil,
            # Revenue metric specific metadata
            revenue_currencies: %{},
            revenue_warning: nil,
            site_id: nil,
            consolidated_site_ids: nil,
            site_native_stats_start_at: nil,
            # Contains information to determine how to combine legacy and new time on page metrics
            time_on_page_data: %{},
            sql_join_type: :left,
            smear_session_metrics: false

  alias OpenTelemetry.Tracer

  alias Plausible.Stats.{
    DateTimeRange,
    Imported,
    Legacy,
    Comparisons,
    ApiQueryParser,
    ParsedQueryParams,
    QueryBuilder,
    QueryError
  }

  @type t :: %__MODULE__{}

  def parse_and_build(
        %Plausible.Site{domain: domain} = site,
        %{"site_id" => domain} = params,
        opts \\ []
      ) do
    with {:ok, %ParsedQueryParams{} = parsed_query_params} <-
           ApiQueryParser.parse(params, opts) do
      QueryBuilder.build(site, parsed_query_params, Keyword.get(opts, :debug_metadata, %{}))
    end
  end

  def parse_and_build!(site, params, opts \\ []) do
    case parse_and_build(site, params, opts) do
      {:ok, query} ->
        query

      {:error, %QueryError{message: message}} ->
        raise "Failed to build query: #{inspect(message)}"
    end
  end

  @doc """
  Builds query from old-style stats APIv1 params. New code should use `Query.parse_and_build`
  or `QueryBuilder.build` with already parsed params.
  """
  def from(site, params, opts \\ []) do
    Legacy.QueryBuilder.from(
      site,
      params,
      Keyword.get(opts, :debug_metadata, %{}),
      Keyword.get(opts, :now)
    )
  end

  def date_range(query, options \\ []) do
    date_range = DateTimeRange.to_date_range(query.utc_time_range, query.timezone)

    if Keyword.get(options, :trim_trailing) do
      today = query.now |> DateTime.shift_zone!(query.timezone) |> DateTime.to_date()

      Date.range(
        date_range.first,
        clamp(today, date_range)
      )
    else
      date_range
    end
  end

  defp clamp(date, date_range) do
    cond do
      date in date_range -> date
      Date.before?(date, date_range.first) -> date_range.first
      Date.after?(date, date_range.last) -> date_range.last
    end
  end

  def set(query, keywords) do
    new_query = struct!(query, keywords)

    if Keyword.has_key?(keywords, :include_imported) do
      new_query
    else
      refresh_imported_opts(new_query)
    end
  end

  def set_include(query, key, value) do
    struct!(query, include: struct!(query.include, [{key, value}]))
  end

  def add_filter(query, filter) do
    query
    |> struct!(filters: query.filters ++ [filter])
    |> refresh_imported_opts()
  end

  @doc """
  Removes top level filters matching any of passed prefix from the query.

  Note that this doesn't handle cases with AND/OR/NOT and as such is discouraged
  from use.
  """
  def remove_top_level_filters(query, prefixes) do
    new_filters =
      Enum.reject(query.filters, fn [_, dimension_or_filter_tree | _rest] ->
        is_binary(dimension_or_filter_tree) and
          Enum.any?(prefixes, &String.starts_with?(dimension_or_filter_tree, &1))
      end)

    query
    |> struct!(filters: new_filters)
    |> refresh_imported_opts()
  end

  defp refresh_imported_opts(query) do
    put_imported_opts(query, nil)
  end

  def put_imported_opts(query, site) do
    requested? = query.include.imports

    query =
      if site && Imported.schema_supports_interval?(query) do
        site = Plausible.Repo.preload(site, :completed_imports)

        struct!(query,
          imports_exist: Plausible.Imported.any_completed_imports?(site),
          imports_in_range: get_imports_in_range(site, query)
        )
      else
        query
      end

    skip_imported_reason = get_skip_imported_reason(query)

    struct!(query,
      include_imported: requested? and is_nil(skip_imported_reason),
      skip_imported_reason: skip_imported_reason
    )
  end

  defp get_imports_in_range(_site, %__MODULE__{input_date_range: period})
       when period in [:realtime, :realtime_30m] do
    []
  end

  defp get_imports_in_range(site, query) do
    in_range = Plausible.Imported.completed_imports_in_query_range(site, query)

    in_comparison_range =
      if query.include.compare do
        comparison_query = Comparisons.get_comparison_query(query)
        Plausible.Imported.completed_imports_in_query_range(site, comparison_query)
      else
        []
      end

    in_comparison_range ++ in_range
  end

  @spec get_skip_imported_reason(t()) ::
          nil | :no_imported_data | :out_of_range | :unsupported_interval | :unsupported_query
  def get_skip_imported_reason(query) do
    cond do
      not Imported.schema_supports_interval?(query) ->
        :unsupported_interval

      not query.imports_exist ->
        :no_imported_data

      query.imports_in_range == [] ->
        :out_of_range

      not Imported.schema_supports_query?(query) ->
        :unsupported_query

      true ->
        nil
    end
  end

  @spec trace(%__MODULE__{}, [atom()]) :: %__MODULE__{}
  def trace(%__MODULE__{} = query, metrics) do
    filter_dimensions =
      query.filters
      |> Plausible.Stats.Filters.dimensions_used_in_filters()
      |> Enum.sort()
      |> Enum.uniq()
      |> Enum.join(";")

    metrics = metrics |> Enum.sort() |> Enum.join(";")

    Tracer.set_attributes([
      {"plausible.query.dimensions", query.dimensions |> Enum.join(";")},
      {"plausible.query.include_imported", query.include_imported},
      {"plausible.query.filter_keys", filter_dimensions},
      {"plausible.query.metrics", metrics}
    ])

    query
  end
end