Skip to content

Commit b735382

Browse files
authored
QueryBuilder follow-up (#5971)
* add QueryError struct Also adds back the behaviour that segment update/insert will crash when an unexpected non-filters-related query builder error is encountered. * add querybuilder convenience fn * remove unreachable case clause * rename file * remove redundant braces and type def
1 parent f2766e0 commit b735382

File tree

23 files changed

+442
-248
lines changed

23 files changed

+442
-248
lines changed

extra/lib/plausible/stats/consolidated_view.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,12 @@ defmodule Plausible.Stats.ConsolidatedView do
9090
to_datetime = now |> DateTime.from_naive!("Etc/UTC")
9191

9292
graph_query =
93-
QueryBuilder.build!(view, %ParsedQueryParams{
93+
QueryBuilder.build!(view,
9494
metrics: [:visitors],
9595
input_date_range: {:datetime_range, from_datetime, to_datetime},
9696
dimensions: ["time:hour"],
9797
order_by: [{"time:hour", :asc}]
98-
})
98+
)
9999

100100
%Stats.QueryResult{results: results} = Stats.query(view, graph_query)
101101

extra/lib/plausible_web/live/funnel_settings/form.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
1010

1111
import PlausibleWeb.Live.Components.Form
1212
alias Plausible.{Goals, Funnels}
13-
alias Plausible.Stats.{QueryBuilder, ParsedQueryParams}
13+
alias Plausible.Stats.QueryBuilder
1414

1515
def mount(_params, %{"domain" => domain} = session, socket) do
1616
site =
@@ -350,10 +350,10 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
350350
)
351351

352352
query =
353-
QueryBuilder.build!(site, %ParsedQueryParams{
353+
QueryBuilder.build!(site,
354354
metrics: [:pageviews],
355355
input_date_range: :month
356-
})
356+
)
357357

358358
{:ok, {definition, query}}
359359
end

lib/plausible/segments/filters.ex

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule Plausible.Segments.Filters do
33
This module contains functions that enable resolving segments in filters.
44
"""
55
alias Plausible.Segments
6-
alias Plausible.Stats.{Filters, ApiQueryParser}
6+
alias Plausible.Stats.{Filters, ApiQueryParser, QueryError}
77

88
@max_segment_filters_count 10
99

@@ -15,7 +15,7 @@ defmodule Plausible.Segments.Filters do
1515
{:ok, [10, 20]}
1616
1717
iex> get_segment_ids([[:and, [[:is, "segment", Enum.to_list(1..6)], [:is, "segment", Enum.to_list(1..6)]]]])
18-
{:error, "Invalid filters. You can only use up to 10 segment filters in a query."}
18+
{:error, %Plausible.Stats.QueryError{code: :invalid_filters, message: "Invalid filters. You can only use up to 10 segment filters in a query."}}
1919
"""
2020
def get_segment_ids(filters) do
2121
ids =
@@ -28,7 +28,11 @@ defmodule Plausible.Segments.Filters do
2828

2929
if length(ids) > @max_segment_filters_count do
3030
{:error,
31-
"Invalid filters. You can only use up to #{@max_segment_filters_count} segment filters in a query."}
31+
%QueryError{
32+
code: :invalid_filters,
33+
message:
34+
"Invalid filters. You can only use up to #{@max_segment_filters_count} segment filters in a query."
35+
}}
3236
else
3337
{:ok, Enum.uniq(ids)}
3438
end
@@ -56,7 +60,12 @@ defmodule Plausible.Segments.Filters do
5660
)},
5761
:ok <-
5862
if(Enum.any?(segment_ids, fn id -> is_nil(Map.get(segments_by_id, id)) end),
59-
do: {:error, "Invalid filters. Some segments don't exist or aren't accessible."},
63+
do:
64+
{:error,
65+
%QueryError{
66+
code: :invalid_filters,
67+
message: "Invalid filters. Some segments don't exist or aren't accessible."
68+
}},
6069
else: :ok
6170
) do
6271
{:ok, segments_by_id}

lib/plausible/segments/segment.ex

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ defmodule Plausible.Segments.Segment do
55
use Plausible
66
use Ecto.Schema
77
import Ecto.Changeset
8-
alias Plausible.Stats.{ApiQueryParser, QueryBuilder, ParsedQueryParams}
8+
alias Plausible.Stats.{ApiQueryParser, QueryBuilder, ParsedQueryParams, QueryError}
99

1010
@segment_types [:personal, :site]
1111

@@ -126,7 +126,11 @@ defmodule Plausible.Segments.Segment do
126126
:ok <- maybe_validate_filters_depth(parsed_filters, restricted_depth?) do
127127
:ok
128128
else
129-
{:error, message} -> {:error, {:invalid_filters, message}}
129+
{:error, %QueryError{code: :invalid_filters, message: message}} ->
130+
{:error, {:invalid_filters, message}}
131+
132+
{:error, :deep_filters_not_supported} ->
133+
{:error, {:invalid_filters, "Invalid filters. Deep filters are not supported."}}
130134
end
131135
end
132136

@@ -138,7 +142,7 @@ defmodule Plausible.Segments.Segment do
138142
if Enum.all?(filters, &dashboard_compatible_filter?/1) do
139143
:ok
140144
else
141-
{:error, "Invalid filters. Deep filters are not supported."}
145+
{:error, :deep_filters_not_supported}
142146
end
143147
end
144148

lib/plausible/stats/api_query_parser.ex

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule Plausible.Stats.ApiQueryParser do
33

44
use Plausible
55

6-
alias Plausible.Stats.{Filters, Metrics, JSONSchema}
6+
alias Plausible.Stats.{Filters, Metrics, JSONSchema, QueryError}
77

88
@default_include %Plausible.Stats.QueryInclude{
99
imports: false,
@@ -50,8 +50,12 @@ defmodule Plausible.Stats.ApiQueryParser do
5050

5151
defp parse_metric(metric_str) do
5252
case Metrics.from_string(metric_str) do
53-
{:ok, metric} -> {:ok, metric}
54-
_ -> {:error, "Unknown metric '#{i(metric_str)}'."}
53+
{:ok, metric} ->
54+
{:ok, metric}
55+
56+
_ ->
57+
{:error,
58+
%QueryError{code: :invalid_metrics, message: "Unknown metric '#{i(metric_str)}'."}}
5559
end
5660
end
5761

@@ -82,7 +86,11 @@ defmodule Plausible.Stats.ApiQueryParser do
8286
defp parse_operator(["not" | _rest]), do: {:ok, :not}
8387
defp parse_operator(["has_done" | _rest]), do: {:ok, :has_done}
8488
defp parse_operator(["has_not_done" | _rest]), do: {:ok, :has_not_done}
85-
defp parse_operator(filter), do: {:error, "Unknown operator for filter '#{i(filter)}'."}
89+
90+
defp parse_operator(filter),
91+
do:
92+
{:error,
93+
%QueryError{code: :invalid_filters, message: "Unknown operator for filter '#{i(filter)}'."}}
8694

8795
def parse_filter_second(operator, [_, filters | _rest]) when operator in [:and, :or],
8896
do: parse_filters(filters)
@@ -97,7 +105,8 @@ defmodule Plausible.Stats.ApiQueryParser do
97105
parse_filter_dimension_string(filter_dimension, "Invalid filter '#{i(filter)}'.")
98106
end
99107

100-
defp parse_filter_dimension(filter), do: {:error, "Invalid filter '#{i(filter)}'."}
108+
defp parse_filter_dimension(filter),
109+
do: {:error, %QueryError{code: :invalid_filters, message: "Invalid filter '#{i(filter)}'."}}
101110

102111
defp parse_filter_rest(operator, filter)
103112
when operator in [
@@ -133,7 +142,11 @@ defmodule Plausible.Stats.ApiQueryParser do
133142
{:ok, list}
134143
else
135144
{:error,
136-
"Invalid visit:country filter, visit:country needs to be a valid 2-letter country code."}
145+
%QueryError{
146+
code: :invalid_filters,
147+
message:
148+
"Invalid visit:country filter, visit:country needs to be a valid 2-letter country code."
149+
}}
137150
end
138151

139152
{"segment", _} when all_integers? ->
@@ -143,11 +156,12 @@ defmodule Plausible.Stats.ApiQueryParser do
143156
{:ok, list}
144157

145158
_ ->
146-
{:error, "Invalid filter '#{i(filter)}'."}
159+
{:error, %QueryError{code: :invalid_filters, message: "Invalid filter '#{i(filter)}'."}}
147160
end
148161
end
149162

150-
defp parse_clauses_list(filter), do: {:error, "Invalid filter '#{i(filter)}'."}
163+
defp parse_clauses_list(filter),
164+
do: {:error, %QueryError{code: :invalid_filters, message: "Invalid filter '#{i(filter)}'."}}
151165

152166
defp parse_filter_modifiers(modifiers) when is_map(modifiers) do
153167
{:ok, [atomize_keys(modifiers)]}
@@ -164,9 +178,15 @@ defmodule Plausible.Stats.ApiQueryParser do
164178

165179
defp parse_input_date_range(shorthand) when is_binary(shorthand) do
166180
case Integer.parse(shorthand) do
167-
{n, "d"} when n > 0 and n <= 5_000 -> {:ok, {:last_n_days, n}}
168-
{n, "mo"} when n > 0 and n <= 100 -> {:ok, {:last_n_months, n}}
169-
_ -> {:error, "Invalid date_range #{i(shorthand)}"}
181+
{n, "d"} when n > 0 and n <= 5_000 ->
182+
{:ok, {:last_n_days, n}}
183+
184+
{n, "mo"} when n > 0 and n <= 100 ->
185+
{:ok, {:last_n_months, n}}
186+
187+
_ ->
188+
{:error,
189+
%QueryError{code: :invalid_date_range, message: "Invalid date_range #{i(shorthand)}"}}
170190
end
171191
end
172192

@@ -178,7 +198,7 @@ defmodule Plausible.Stats.ApiQueryParser do
178198
end
179199

180200
defp parse_input_date_range(unknown) do
181-
{:error, "Invalid date_range #{i(unknown)}"}
201+
{:error, %QueryError{code: :invalid_date_range, message: "Invalid date_range #{i(unknown)}"}}
182202
end
183203

184204
defp parse_date_strings(from, to) do
@@ -193,7 +213,9 @@ defmodule Plausible.Stats.ApiQueryParser do
193213
{:ok, to_datetime, _offset} <- DateTime.from_iso8601(to) do
194214
{:ok, {:datetime_range, from_datetime, to_datetime}}
195215
else
196-
_ -> {:error, "Invalid date_range '#{i([from, to])}'."}
216+
_ ->
217+
{:error,
218+
%QueryError{code: :invalid_date_range, message: "Invalid date_range '#{i([from, to])}'."}}
197219
end
198220
end
199221

@@ -211,7 +233,11 @@ defmodule Plausible.Stats.ApiQueryParser do
211233
end
212234

213235
def parse_order_by(nil), do: {:ok, nil}
214-
def parse_order_by(order_by), do: {:error, "Invalid order_by '#{i(order_by)}'."}
236+
237+
def parse_order_by(order_by),
238+
do:
239+
{:error,
240+
%QueryError{code: :invalid_order_by, message: "Invalid order_by '#{i(order_by)}'."}}
215241

216242
defp parse_order_by_entry(entry) do
217243
with {:ok, value} <- parse_metric_or_dimension(entry),
@@ -227,7 +253,7 @@ defmodule Plausible.Stats.ApiQueryParser do
227253
} do
228254
{{:ok, time}, _} -> {:ok, time}
229255
{_, {:ok, dimension}} -> {:ok, dimension}
230-
_ -> {:error, error_message}
256+
_ -> {:error, %QueryError{code: :invalid_dimensions, message: error_message}}
231257
end
232258
end
233259

@@ -237,10 +263,18 @@ defmodule Plausible.Stats.ApiQueryParser do
237263
parse_metric(value),
238264
parse_filter_dimension_string(value)
239265
} do
240-
{{:ok, time}, _, _} -> {:ok, time}
241-
{_, {:ok, metric}, _} -> {:ok, metric}
242-
{_, _, {:ok, dimension}} -> {:ok, dimension}
243-
_ -> {:error, "Invalid order_by entry '#{i(entry)}'."}
266+
{{:ok, time}, _, _} ->
267+
{:ok, time}
268+
269+
{_, {:ok, metric}, _} ->
270+
{:ok, metric}
271+
272+
{_, _, {:ok, dimension}} ->
273+
{:ok, dimension}
274+
275+
_ ->
276+
{:error,
277+
%QueryError{code: :invalid_order_by, message: "Invalid order_by entry '#{i(entry)}'."}}
244278
end
245279
end
246280

@@ -254,15 +288,19 @@ defmodule Plausible.Stats.ApiQueryParser do
254288

255289
defp parse_order_direction([_, "asc"]), do: {:ok, :asc}
256290
defp parse_order_direction([_, "desc"]), do: {:ok, :desc}
257-
defp parse_order_direction(entry), do: {:error, "Invalid order_by entry '#{i(entry)}'."}
291+
292+
defp parse_order_direction(entry),
293+
do:
294+
{:error,
295+
%QueryError{code: :invalid_order_by, message: "Invalid order_by entry '#{i(entry)}'."}}
258296

259297
def parse_include(include) when is_map(include) do
260298
parsed_include_params_or_error =
261299
include
262300
|> Enum.reduce_while({:ok, []}, fn {key, value}, {:ok, acc} ->
263301
case parse_include_entry(key, value) do
264302
{:ok, parsed_tuple} -> {:cont, {:ok, acc ++ [parsed_tuple]}}
265-
{:error, msg} -> {:halt, {:error, msg}}
303+
{:error, query_error} -> {:halt, {:error, query_error}}
266304
end
267305
end)
268306

@@ -272,15 +310,18 @@ defmodule Plausible.Stats.ApiQueryParser do
272310
end
273311

274312
def parse_include(nil), do: {:ok, @default_include}
275-
def parse_include(include), do: {:error, "Invalid include '#{i(include)}'."}
313+
314+
def parse_include(include),
315+
do: {:error, %QueryError{code: :invalid_include, message: "Invalid include '#{i(include)}'."}}
276316

277317
@allowed_include_keys Enum.map(Map.keys(@default_include), &Atom.to_string/1)
278318

279319
defp parse_include_entry(key, value) when key in @allowed_include_keys do
280320
{:ok, {String.to_existing_atom(key), value}}
281321
end
282322

283-
defp parse_include_entry(key, _value), do: {:error, "Invalid include key'#{i(key)}'."}
323+
defp parse_include_entry(key, _value),
324+
do: {:error, %QueryError{code: :invalid_include, message: "Invalid include key'#{i(key)}'."}}
284325

285326
defp parse_pagination(pagination) when is_map(pagination) do
286327
{:ok, Map.merge(@default_pagination, atomize_keys(pagination))}
@@ -300,28 +341,28 @@ defmodule Plausible.Stats.ApiQueryParser do
300341
if String.length(property_name) > 0 do
301342
{:ok, dimension}
302343
else
303-
{:error, error_message}
344+
{:error, %QueryError{code: :invalid_filters, message: error_message}}
304345
end
305346

306347
"event:" <> key ->
307348
if key in Filters.event_props() do
308349
{:ok, dimension}
309350
else
310-
{:error, error_message}
351+
{:error, %QueryError{code: :invalid_filters, message: error_message}}
311352
end
312353

313354
"visit:" <> key ->
314355
if key in Filters.visit_props() do
315356
{:ok, dimension}
316357
else
317-
{:error, error_message}
358+
{:error, %QueryError{code: :invalid_filters, message: error_message}}
318359
end
319360

320361
"segment" ->
321362
{:ok, dimension}
322363

323364
_ ->
324-
{:error, error_message}
365+
{:error, %QueryError{code: :invalid_filters, message: error_message}}
325366
end
326367
end
327368

lib/plausible/stats/goal_suggestions.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ defmodule Plausible.Stats.GoalSuggestions do
22
@moduledoc false
33

44
alias Plausible.{Repo, ClickhouseRepo}
5-
alias Plausible.Stats.{Query, QueryBuilder, ParsedQueryParams, QueryInclude}
5+
alias Plausible.Stats.{Query, QueryBuilder}
66
import Plausible.Stats.Base
77
import Ecto.Query
88

@@ -39,11 +39,11 @@ defmodule Plausible.Stats.GoalSuggestions do
3939
from_date = Date.shift(to_date, month: -6)
4040

4141
query =
42-
QueryBuilder.build!(site, %ParsedQueryParams{
42+
QueryBuilder.build!(site,
4343
input_date_range: {:date_range, from_date, to_date},
4444
metrics: [:pageviews],
45-
include: %QueryInclude{imports: true}
46-
})
45+
include: [imports: true]
46+
)
4747

4848
native_q =
4949
from(e in base_event_query(query),

lib/plausible/stats/json_schema.ex

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ defmodule Plausible.Stats.JSONSchema do
77
"""
88
use Plausible
99
alias Plausible.Stats.JSONSchema.Utils
10+
alias Plausible.Stats.QueryError
1011

1112
@json_schema_filepath "priv/json-schemas/query-api-schema.json"
1213
@external_resource @json_schema_filepath
@@ -28,8 +29,12 @@ defmodule Plausible.Stats.JSONSchema do
2829

2930
def validate(params) do
3031
case ExJsonSchema.Validator.validate(@query_schema, params) do
31-
:ok -> :ok
32-
{:error, errors} -> {:error, format_errors(errors, params)}
32+
:ok ->
33+
:ok
34+
35+
{:error, errors} ->
36+
{:error,
37+
%QueryError{code: :failed_schema_validation, message: format_errors(errors, params)}}
3338
end
3439
end
3540

0 commit comments

Comments
 (0)