Skip to content

Commit 981dc85

Browse files
authored
DashboardQueryParser: Labels and segments (#5964)
* parse segment filters * fix parsing visit:city filters + tests * serialize integer clauses too * serialize segment/location labels * default segments argument to emtpy list
1 parent 6d72c18 commit 981dc85

File tree

4 files changed

+190
-33
lines changed

4 files changed

+190
-33
lines changed

lib/plausible/stats/dashboard_query_parser.ex

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -116,17 +116,12 @@ defmodule Plausible.Stats.DashboardQueryParser do
116116
end
117117

118118
defp decode_filter(filter_expression) do
119-
case String.split(filter_expression, ",") do
120-
[operator, dimension | clauses] ->
121-
{:ok,
122-
[
123-
operator,
124-
with_prefix(dimension),
125-
Enum.map(clauses, &URI.decode_www_form/1)
126-
]}
127-
128-
_ ->
129-
{:error, :invalid_filter}
119+
with [operator, dimension | clauses] <- String.split(filter_expression, ","),
120+
dimension = with_prefix(dimension),
121+
{:ok, clauses} <- decode_clauses(clauses, dimension) do
122+
{:ok, [operator, dimension, clauses]}
123+
else
124+
_ -> {:error, :invalid_filter}
130125
end
131126
end
132127

@@ -141,6 +136,20 @@ defmodule Plausible.Stats.DashboardQueryParser do
141136
end
142137
end
143138

139+
@dimensions_with_integer_clauses ["segment", "visit:city"]
140+
defp decode_clauses(clauses, dimension) when dimension in @dimensions_with_integer_clauses do
141+
Enum.reduce_while(clauses, {:ok, []}, fn clause, {:ok, acc} ->
142+
case Integer.parse(clause) do
143+
{int, ""} -> {:cont, {:ok, acc ++ [int]}}
144+
_ -> {:halt, {:error, :invalid_filter}}
145+
end
146+
end)
147+
end
148+
149+
defp decode_clauses(clauses, _dimension) do
150+
{:ok, Enum.map(clauses, &URI.decode_www_form/1)}
151+
end
152+
144153
@event_props_prefix "props:"
145154
@event_dimensions ["name", "page", "goal", "hostname"]
146155
defp event_dimension?(dimension) do

lib/plausible/stats/dashboard_query_serializer.ex

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,26 @@ defmodule Plausible.Stats.DashboardQuerySerializer do
66

77
alias Plausible.Stats.{ParsedQueryParams, DashboardQueryParser, QueryInclude}
88

9-
def serialize(%ParsedQueryParams{} = params) do
9+
def serialize(%ParsedQueryParams{} = params, segments \\ []) do
1010
params
1111
|> Map.to_list()
12-
|> Enum.flat_map(&get_serialized_fields/1)
12+
|> Enum.flat_map(fn pair -> get_serialized_fields(pair, segments) end)
1313
|> Enum.sort_by(&elem(&1, 0))
1414
|> Enum.map_join("&", fn {key, value} -> "#{key}=#{value}" end)
1515
end
1616

17-
defp get_serialized_fields({_, nil}), do: []
18-
defp get_serialized_fields({_, []}), do: []
17+
defp get_serialized_fields({_, nil}, _segments), do: []
18+
defp get_serialized_fields({_, []}, _segments), do: []
1919

20-
defp get_serialized_fields({:input_date_range, {:date_range, from_date, to_date}}) do
20+
defp get_serialized_fields({:input_date_range, {:date_range, from_date, to_date}}, _segments) do
2121
[
2222
{"period", "custom"},
2323
{"from", Date.to_iso8601(from_date)},
2424
{"to", Date.to_iso8601(to_date)}
2525
]
2626
end
2727

28-
defp get_serialized_fields({:input_date_range, input_date_range}) do
28+
defp get_serialized_fields({:input_date_range, input_date_range}, _segments) do
2929
period =
3030
case input_date_range do
3131
:realtime -> "realtime"
@@ -44,29 +44,22 @@ defmodule Plausible.Stats.DashboardQuerySerializer do
4444
[{"period", period}]
4545
end
4646

47-
defp get_serialized_fields({:relative_date, date}) do
47+
defp get_serialized_fields({:relative_date, date}, _segments) do
4848
[{"date", Date.to_iso8601(date)}]
4949
end
5050

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)
51+
defp get_serialized_fields({:filters, [_ | _] = filters}, segments) do
52+
serialize_filters(filters) ++ serialize_labels(filters, segments)
5853
end
5954

60-
defp get_serialized_fields({:include, %QueryInclude{} = include}) do
55+
defp get_serialized_fields({:include, %QueryInclude{} = include}, _segments) do
6156
[:imports, :compare, :compare_match_day_of_week]
6257
|> Enum.flat_map(fn include_key ->
6358
get_serialized_fields_from_include(include_key, include)
6459
end)
6560
end
6661

67-
defp get_serialized_fields(_) do
68-
[]
69-
end
62+
defp get_serialized_fields(_, _), do: []
7063

7164
defp get_serialized_fields_from_include(:imports, %QueryInclude{} = include) do
7265
if include.imports == DashboardQueryParser.default_include().imports do
@@ -102,15 +95,64 @@ defmodule Plausible.Stats.DashboardQuerySerializer do
10295
end
10396
end
10497

98+
defp serialize_filters(filters) do
99+
filters
100+
|> Enum.map(fn [operator, dimension, clauses] ->
101+
clauses = Enum.map_join(clauses, ",", &custom_uri_encode/1)
102+
dimension = String.split(dimension, ":", parts: 2) |> List.last()
103+
{"f", "#{operator},#{dimension},#{clauses}"}
104+
end)
105+
end
106+
107+
# This is needed by the React dashboard during the migration phase.
108+
# In Elixir, we can do cheap location/segment lookups but React needs
109+
# URL labels to be able translate filters into a human-readable form.
110+
# E.g. `f=is,city,2950159&l=2950159,Berlin`.
111+
defp serialize_labels(filters, segments) do
112+
Enum.flat_map(filters, fn filter -> labels_for(filter, segments) end)
113+
end
114+
115+
defp labels_for([_operator, dimension, clauses], segments) do
116+
name_lookup_fn =
117+
case dimension do
118+
"visit:country" ->
119+
&Location.get_country/1
120+
121+
"visit:region" ->
122+
&Location.get_subdivision/1
123+
124+
"visit:city" ->
125+
&Location.get_city/1
126+
127+
"segment" ->
128+
fn segment_id ->
129+
Enum.find(segments, &(&1.id == segment_id))
130+
end
131+
132+
_ ->
133+
fn _ -> nil end
134+
end
135+
136+
Enum.reduce(clauses, [], fn code, acc ->
137+
case name_lookup_fn.(code) do
138+
%{name: name} -> acc ++ [{"l", "#{code},#{name}"}]
139+
nil -> acc
140+
end
141+
end)
142+
end
143+
105144
# These characters are not URL encoded to have more readable URLs.
106145
# Browsers seem to handle this just fine. `?f=is,page,/my/page/:some_param`
107146
# vs `?f=is,page,%2Fmy%2Fpage%2F%3Asome_param`
108147
@do_not_url_encode [":", "/"]
109148
@do_not_url_encode_map Enum.into(@do_not_url_encode, %{}, fn char ->
110149
{URI.encode_www_form(char), char}
111150
end)
151+
defp custom_uri_encode(input) when is_integer(input) do
152+
to_string(input)
153+
end
112154

113-
defp uri_encode_permissive(input) do
155+
defp custom_uri_encode(input) do
114156
input
115157
|> URI.encode_www_form()
116158
|> String.replace(Map.keys(@do_not_url_encode_map), &@do_not_url_encode_map[&1])

test/plausible/stats/query/dashboard_query_parser_test.exs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,35 @@ defmodule Plausible.Stats.DashboardQueryParserTest do
162162
} = parsed
163163
end
164164

165-
test "errors when filter decoding fails" do
165+
test "parses city filter with multiple clauses" do
166+
{:ok, parsed} =
167+
parse("?f=is,city,2988507,2950159")
168+
169+
assert %ParsedQueryParams{
170+
filters: [[:is, "visit:city", [2_988_507, 2_950_159]]],
171+
include: @default_include
172+
} = parsed
173+
end
174+
175+
test "parses a segment filter" do
176+
{:ok, parsed} = parse("?f=is,segment,123")
177+
178+
assert %ParsedQueryParams{
179+
filters: [[:is, "segment", [123]]],
180+
include: @default_include
181+
} = parsed
182+
end
183+
184+
test "errors when filter structure is wrong" do
166185
assert {:error, :invalid_filters} = parse("?f=is,page,/&f=what")
167186
end
187+
188+
test "errors when city filter cannot be parsed to integer" do
189+
assert {:error, :invalid_filters} = parse("?f=is,city,Berlin")
190+
end
191+
192+
test "errors when segment filter cannot be parsed to integer" do
193+
assert {:error, :invalid_filters} = parse("?f=is,segment,MySegment")
194+
end
168195
end
169196
end

test/plausible/stats/query/dashboard_query_serializer_test.exs

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ defmodule Plausible.Stats.DashboardQuerySerializerTest do
113113
end
114114

115115
describe "filters" do
116-
test "serializes multiple filters" do
116+
test "serializes multiple is filters" do
117117
serialized =
118118
serialize(%ParsedQueryParams{
119119
filters: [
@@ -127,9 +127,88 @@ defmodule Plausible.Stats.DashboardQuerySerializerTest do
127127
assert serialized == "f=is,exit_page,/:dashboard&f=is,source,Bing&f=is,props:theme,system"
128128
end
129129

130+
test "serializes filters with integer clauses" do
131+
serialized =
132+
serialize(%ParsedQueryParams{
133+
filters: [
134+
[:is, "segment", [123, 456, 789]],
135+
[:is, "visit:city", [2_950_159]]
136+
],
137+
include: @default_include
138+
})
139+
140+
assert serialized == "f=is,segment,123,456,789&f=is,city,2950159&l=2950159,Berlin"
141+
end
142+
130143
test "serializes empty filters" do
131-
serialized = serialize(%ParsedQueryParams{filters: [], include: @default_include})
144+
serialized = serialize(%ParsedQueryParams{filters: [], include: @default_include}, [])
132145
assert serialized == ""
133146
end
134147
end
148+
149+
describe "labels" do
150+
test "adds location labels" do
151+
serialized =
152+
serialize(%ParsedQueryParams{
153+
filters: [
154+
[:is, "visit:country", ["EE"]],
155+
[:is, "visit:region", ["EE-79"]],
156+
[:is, "visit:city", [588_335]]
157+
],
158+
include: @default_include
159+
})
160+
161+
assert serialized ==
162+
"f=is,country,EE&f=is,region,EE-79&f=is,city,588335&l=EE,Estonia&l=EE-79,Tartumaa&l=588335,Tartu"
163+
end
164+
165+
test "adds segment label" do
166+
user = new_user()
167+
site = new_site(owner: user)
168+
169+
segment =
170+
insert(:segment,
171+
type: :personal,
172+
owner: user,
173+
site: site,
174+
name: "personal segment"
175+
)
176+
177+
serialized =
178+
serialize(
179+
%ParsedQueryParams{
180+
filters: [[:is, "segment", [segment.id]]],
181+
include: @default_include
182+
},
183+
[segment]
184+
)
185+
186+
assert serialized ==
187+
"f=is,segment,#{segment.id}&l=#{segment.id},#{segment.name}"
188+
end
189+
190+
test "skips location labels when not found" do
191+
serialized =
192+
serialize(%ParsedQueryParams{
193+
filters: [
194+
[:is, "visit:country", ["XX"]],
195+
[:is, "visit:region", ["XX-00"]],
196+
[:is, "visit:city", [999_999_999]]
197+
],
198+
include: @default_include
199+
})
200+
201+
assert serialized == "f=is,country,XX&f=is,region,XX-00&f=is,city,999999999"
202+
end
203+
204+
test "skips segment label when not found" do
205+
serialized =
206+
serialize(%ParsedQueryParams{
207+
filters: [[:is, "segment", [1]]],
208+
include: @default_include
209+
})
210+
211+
assert serialized == "f=is,segment,1"
212+
end
213+
end
135214
end

0 commit comments

Comments
 (0)