Skip to content

Commit d6673fb

Browse files
authored
DashboardQueryParser & DashboardQuerySerializer (#5938)
* rename query_parser_test to api_query_parser_test * allow metrics to be nil in ParsedQueryParams * swap now with relative_date in ParsedQueryParams * add DashboardQueryParser * stop defining defaults in ParsedQueryParams * add DashboardQuerySerializer * make sure parse -> serialize is a reversible transformation * fix codespell * fix test and silence credo * fix another test * parse and serialize with_imported * cleaner decode_filters * precompile do_not_url_encode_map and simplify uri_encode_permissive * remove prepending ? logic
1 parent d9456d7 commit d6673fb

File tree

12 files changed

+515
-110
lines changed

12 files changed

+515
-110
lines changed

assets/js/dashboard/util/url-search-params.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { v1 } from './url-search-params-v1'
33
import { v2 } from './url-search-params-v2'
44

55
/**
6-
* These charcters are not URL encoded to have more readable URLs.
6+
* These characters are not URL encoded to have more readable URLs.
77
* Browsers seem to handle this just fine.
88
* `?f=is,page,/my/page/:some_param` vs `?f=is,page,%2Fmy%2Fpage%2F%3Asome_param``
99
*/

lib/plausible/stats/api_query_parser.ex

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,23 @@ defmodule Plausible.Stats.ApiQueryParser do
55

66
alias Plausible.Stats.{Filters, Metrics, DateTimeRange, JSONSchema}
77

8+
@default_include %{
9+
imports: false,
10+
imports_meta: false,
11+
time_labels: false,
12+
total_rows: false,
13+
trim_relative_date_range: false,
14+
comparisons: nil,
15+
legacy_time_on_page_cutoff: nil
16+
}
17+
18+
def default_include(), do: @default_include
19+
20+
@default_pagination %{limit: 10_000, offset: 0}
21+
22+
def default_pagination(), do: @default_pagination
23+
824
def parse(schema_type, params) when is_map(params) do
9-
now = Plausible.Stats.Query.Test.get_fixed_now()
1025
input_date_range = Map.get(params, "date_range")
1126

1227
with :ok <- JSONSchema.validate(schema_type, params),
@@ -19,7 +34,6 @@ defmodule Plausible.Stats.ApiQueryParser do
1934
{:ok, include} <- parse_include(params["include"]) do
2035
{:ok,
2136
Plausible.Stats.ParsedQueryParams.new!(%{
22-
now: now,
2337
input_date_range: input_date_range,
2438
metrics: metrics,
2539
filters: filters,
@@ -61,7 +75,7 @@ defmodule Plausible.Stats.ApiQueryParser do
6175
parse_list(filters, &parse_filter/1)
6276
end
6377

64-
def parse_filters(nil), do: {:ok, nil}
78+
def parse_filters(nil), do: {:ok, []}
6579

6680
defp parse_filter(filter) do
6781
with {:ok, operator} <- parse_operator(filter),
@@ -208,7 +222,7 @@ defmodule Plausible.Stats.ApiQueryParser do
208222
)
209223
end
210224

211-
defp parse_dimensions(nil), do: {:ok, nil}
225+
defp parse_dimensions(nil), do: {:ok, []}
212226

213227
def parse_order_by(order_by) when is_list(order_by) do
214228
parse_list(order_by, &parse_order_by_entry/1)
@@ -261,17 +275,18 @@ defmodule Plausible.Stats.ApiQueryParser do
261275
defp parse_order_direction(entry), do: {:error, "Invalid order_by entry '#{i(entry)}'."}
262276

263277
def parse_include(include) when is_map(include) do
264-
with {:ok, include} <- atomize_include_keys(include) do
265-
parse_comparison_date_range(include)
278+
with {:ok, include} <- atomize_include_keys(include),
279+
{:ok, include} <- parse_comparison_date_range(include) do
280+
{:ok, Map.merge(@default_include, include)}
266281
end
267282
end
268283

269-
def parse_include(nil), do: {:ok, nil}
284+
def parse_include(nil), do: {:ok, @default_include}
270285
def parse_include(include), do: {:error, "Invalid include '#{i(include)}'."}
271286

272287
defp atomize_include_keys(map) do
273288
expected_keys =
274-
Plausible.Stats.ParsedQueryParams.default_include()
289+
@default_include
275290
|> Map.keys()
276291
|> Enum.map(&Atom.to_string/1)
277292

@@ -291,11 +306,10 @@ defmodule Plausible.Stats.ApiQueryParser do
291306
defp parse_comparison_date_range(include), do: {:ok, include}
292307

293308
defp parse_pagination(pagination) when is_map(pagination) do
294-
{:ok,
295-
Map.merge(Plausible.Stats.ParsedQueryParams.default_pagination(), atomize_keys(pagination))}
309+
{:ok, Map.merge(@default_pagination, atomize_keys(pagination))}
296310
end
297311

298-
defp parse_pagination(nil), do: {:ok, nil}
312+
defp parse_pagination(nil), do: {:ok, @default_pagination}
299313

300314
defp atomize_keys(map) when is_map(map) do
301315
Map.new(map, fn {key, value} ->
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
defmodule Plausible.Stats.DashboardQueryParser do
2+
@moduledoc """
3+
Parses a dashboard query string into `%ParsedQueryParams{}`. Note that
4+
`metrics` and `dimensions` do not exist at this step yet, and are expected
5+
to be filled in by each specific report.
6+
"""
7+
8+
alias Plausible.Stats.{ParsedQueryParams}
9+
10+
@default_include %{
11+
imports: true,
12+
# `include.imports_meta` can be true even when `include.imports`
13+
# is false. Even if we don't want to include imported data, we
14+
# might still want to know whether imported data can be toggled
15+
# on/off on the dashboard.
16+
imports_meta: true,
17+
time_labels: true,
18+
total_rows: false,
19+
trim_relative_date_range: true,
20+
comparisons: nil,
21+
legacy_time_on_page_cutoff: nil
22+
}
23+
24+
def default_include(), do: @default_include
25+
26+
@default_pagination nil
27+
28+
def default_pagination(), do: @default_pagination
29+
30+
def parse(query_string) when is_binary(query_string) do
31+
query_string = String.trim_leading(query_string, "?")
32+
params_map = URI.decode_query(query_string)
33+
34+
with {:ok, filters} <- parse_filters(query_string),
35+
{:ok, relative_date} <- parse_relative_date(params_map) do
36+
include_imports? = parse_include_imports(params_map)
37+
38+
{:ok,
39+
ParsedQueryParams.new!(%{
40+
input_date_range: parse_input_date_range(params_map),
41+
relative_date: relative_date,
42+
filters: filters,
43+
include: Map.merge(@default_include, %{imports: include_imports?})
44+
})}
45+
end
46+
end
47+
48+
defp parse_input_date_range(%{"period" => "realtime"}), do: :realtime
49+
defp parse_input_date_range(%{"period" => "day"}), do: :day
50+
defp parse_input_date_range(%{"period" => "month"}), do: :month
51+
defp parse_input_date_range(%{"period" => "year"}), do: :year
52+
defp parse_input_date_range(%{"period" => "all"}), do: :all
53+
defp parse_input_date_range(%{"period" => "7d"}), do: {:last_n_days, 7}
54+
defp parse_input_date_range(%{"period" => "28d"}), do: {:last_n_days, 28}
55+
defp parse_input_date_range(%{"period" => "30d"}), do: {:last_n_days, 30}
56+
defp parse_input_date_range(%{"period" => "91d"}), do: {:last_n_days, 91}
57+
defp parse_input_date_range(%{"period" => "6mo"}), do: {:last_n_months, 6}
58+
defp parse_input_date_range(%{"period" => "12mo"}), do: {:last_n_months, 12}
59+
60+
defp parse_input_date_range(%{"period" => "custom", "from" => from, "to" => to}) do
61+
from_date = Date.from_iso8601!(String.trim(from))
62+
to_date = Date.from_iso8601!(String.trim(to))
63+
{:date_range, from_date, to_date}
64+
end
65+
66+
defp parse_input_date_range(_), do: nil
67+
68+
defp parse_relative_date(%{"date" => date}) do
69+
case Date.from_iso8601(date) do
70+
{:ok, date} -> {:ok, date}
71+
_ -> {:error, :invalid_date}
72+
end
73+
end
74+
75+
defp parse_relative_date(_), do: {:ok, nil}
76+
77+
defp parse_include_imports(%{"with_imported" => "false"}), do: false
78+
defp parse_include_imports(_), do: true
79+
80+
defp parse_filters(query_string) do
81+
with {:ok, filters} <- decode_filters(query_string) do
82+
Plausible.Stats.ApiQueryParser.parse_filters(filters)
83+
end
84+
end
85+
86+
defp decode_filters(query_string) do
87+
query_string
88+
|> URI.query_decoder()
89+
|> Enum.filter(fn {key, _value} -> key == "f" end)
90+
|> Enum.reduce_while({:ok, []}, fn {_, filter_expression}, {:ok, acc} ->
91+
case decode_filter(filter_expression) do
92+
{:ok, filter} -> {:cont, {:ok, acc ++ [filter]}}
93+
{:error, _} -> {:halt, {:error, :invalid_filters}}
94+
end
95+
end)
96+
end
97+
98+
defp decode_filter(filter_expression) do
99+
case String.split(filter_expression, ",") do
100+
[operator, dimension | clauses] ->
101+
{:ok,
102+
[
103+
operator,
104+
with_prefix(dimension),
105+
Enum.map(clauses, &URI.decode_www_form/1)
106+
]}
107+
108+
_ ->
109+
{:error, :invalid_filter}
110+
end
111+
end
112+
113+
@event_prefix "event:"
114+
@visit_prefix "visit:"
115+
@no_prefix_dimensions ["segment"]
116+
defp with_prefix(dimension) do
117+
cond do
118+
dimension in @no_prefix_dimensions -> dimension
119+
event_dimension?(dimension) -> @event_prefix <> dimension
120+
true -> @visit_prefix <> dimension
121+
end
122+
end
123+
124+
@event_props_prefix "props:"
125+
@event_dimensions ["name", "page", "goal", "hostname"]
126+
defp event_dimension?(dimension) do
127+
dimension in @event_dimensions or String.starts_with?(dimension, @event_props_prefix)
128+
end
129+
end
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
defmodule Plausible.Stats.DashboardQuerySerializer do
2+
@moduledoc """
3+
Takes a `%ParsedQueryParams{}` struct and turns it into a query
4+
string.
5+
"""
6+
7+
alias Plausible.Stats.{ParsedQueryParams, DashboardQueryParser}
8+
9+
def serialize(%ParsedQueryParams{} = params) do
10+
params
11+
|> Map.to_list()
12+
|> Enum.flat_map(&get_serialized_fields/1)
13+
|> Enum.sort_by(&elem(&1, 0))
14+
|> Enum.map_join("&", fn {key, value} -> "#{key}=#{value}" end)
15+
end
16+
17+
defp get_serialized_fields({_, nil}), do: []
18+
defp get_serialized_fields({_, []}), do: []
19+
20+
defp get_serialized_fields({:input_date_range, {:date_range, from_date, to_date}}) do
21+
[
22+
{"period", "custom"},
23+
{"from", Date.to_iso8601(from_date)},
24+
{"to", Date.to_iso8601(to_date)}
25+
]
26+
end
27+
28+
defp get_serialized_fields({:input_date_range, input_date_range}) do
29+
period =
30+
case input_date_range do
31+
:realtime -> "realtime"
32+
:day -> "day"
33+
:month -> "month"
34+
:year -> "year"
35+
:all -> "all"
36+
{:last_n_days, 7} -> "7d"
37+
{:last_n_days, 28} -> "28d"
38+
{:last_n_days, 30} -> "30d"
39+
{:last_n_days, 91} -> "91d"
40+
{:last_n_months, 6} -> "6mo"
41+
{:last_n_months, 12} -> "12mo"
42+
end
43+
44+
[{"period", period}]
45+
end
46+
47+
defp get_serialized_fields({:relative_date, date}) do
48+
[{"date", Date.to_iso8601(date)}]
49+
end
50+
51+
defp get_serialized_fields({:filters, [_ | _] = filters}) do
52+
filters
53+
|> Enum.map(fn [operator, dimension, clauses] ->
54+
clauses = Enum.map_join(clauses, ",", &uri_encode_permissive/1)
55+
dimension = String.split(dimension, ":", parts: 2) |> List.last()
56+
{"f", "#{operator},#{dimension},#{clauses}"}
57+
end)
58+
end
59+
60+
defp get_serialized_fields({:include, include}) do
61+
if include.imports == DashboardQueryParser.default_include().imports do
62+
[]
63+
else
64+
[{"with_imported", to_string(include.imports)}]
65+
end
66+
end
67+
68+
defp get_serialized_fields(_) do
69+
[]
70+
end
71+
72+
# These characters are not URL encoded to have more readable URLs.
73+
# Browsers seem to handle this just fine. `?f=is,page,/my/page/:some_param`
74+
# vs `?f=is,page,%2Fmy%2Fpage%2F%3Asome_param`
75+
@do_not_url_encode [":", "/"]
76+
@do_not_url_encode_map Enum.into(@do_not_url_encode, %{}, fn char ->
77+
{URI.encode_www_form(char), char}
78+
end)
79+
80+
defp uri_encode_permissive(input) do
81+
input
82+
|> URI.encode_www_form()
83+
|> String.replace(Map.keys(@do_not_url_encode_map), &@do_not_url_encode_map[&1])
84+
end
85+
end

lib/plausible/stats/legacy/legacy_query_builder.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,10 +281,10 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do
281281
@doc """
282282
### Examples:
283283
iex> Plausible.Stats.Legacy.QueryBuilder.parse_include(nil)
284-
Plausible.Stats.ParsedQueryParams.default_include()
284+
Plausible.Stats.ApiQueryParser.default_include()
285285
286286
iex> Plausible.Stats.Legacy.QueryBuilder.parse_include(~s({"total_rows": true}))
287-
Map.merge(Plausible.Stats.ParsedQueryParams.default_include(), %{total_rows: true})
287+
Map.merge(Plausible.Stats.ApiQueryParser.default_include(), %{total_rows: true})
288288
"""
289289
def parse_include(include) do
290290
include =
@@ -296,7 +296,7 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do
296296
_ -> %{}
297297
end
298298

299-
Plausible.Stats.ParsedQueryParams.default_include()
299+
Plausible.Stats.ApiQueryParser.default_include()
300300
|> Map.merge(include)
301301
end
302302

lib/plausible/stats/parsed_query_params.ex

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

44
defstruct [
5-
:now,
65
:input_date_range,
6+
# `relative_date` is a convenience currently exclusive to the internal
7+
# dashboard API for constructing datetime ranges. It adds the ability
8+
# to use `day`, `month` and `year` periods relative to a specific date.
9+
# E.g.: `?period=month&date=2021-01-15` will query the entire month of
10+
# January 2021. In the public API it is always today in site.timezone.
11+
:relative_date,
712
:metrics,
813
:filters,
914
:dimensions,
@@ -12,41 +17,7 @@ defmodule Plausible.Stats.ParsedQueryParams do
1217
:include
1318
]
1419

15-
@default_include %{
16-
imports: false,
17-
# `include.imports_meta` can be true even when `include.imports`
18-
# is false. Even if we don't want to include imported data, we
19-
# might still want to know whether imported data can be toggled
20-
# on/off on the dashboard.
21-
imports_meta: false,
22-
time_labels: false,
23-
total_rows: false,
24-
trim_relative_date_range: false,
25-
comparisons: nil,
26-
legacy_time_on_page_cutoff: nil
27-
}
28-
29-
def default_include(), do: @default_include
30-
31-
@default_pagination %{
32-
limit: 10_000,
33-
offset: 0
34-
}
35-
36-
def default_pagination(), do: @default_pagination
37-
3820
def new!(params) when is_map(params) do
39-
[_ | _] = metrics = Map.fetch!(params, :metrics)
40-
41-
%__MODULE__{
42-
now: params[:now],
43-
input_date_range: Map.fetch!(params, :input_date_range),
44-
metrics: metrics,
45-
filters: params[:filters] || [],
46-
dimensions: params[:dimensions] || [],
47-
order_by: params[:order_by],
48-
pagination: Map.merge(@default_pagination, params[:pagination] || %{}),
49-
include: Map.merge(@default_include, params[:include] || %{})
50-
}
21+
struct!(__MODULE__, Map.to_list(params))
5122
end
5223
end

0 commit comments

Comments
 (0)