defmodule Plausible.Stats.Query.QueryOptimizerTest do use Plausible.DataCase, async: true alias Plausible.Stats.{Query, QueryOptimizer, DateTimeRange, ApiQueryParser} @default_params %{metrics: [:visitors]} def perform(params) do params = Map.merge(@default_params, params) |> Map.to_list() struct!(Query, params) |> QueryOptimizer.optimize() end describe "add_missing_order_by" do test "does nothing if order_by passed" do assert perform(%{order_by: [visitors: :desc]}).order_by == [{:visitors, :desc}] end test "adds first metric to order_by if order_by not specified" do assert perform(%{metrics: [:pageviews, :visitors]}).order_by == [{:pageviews, :desc}] assert perform(%{metrics: [:pageviews, :visitors], dimensions: ["event:page"]}).order_by == [{:pageviews, :desc}] end test "adds time and first metric to order_by if order_by not specified" do assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2022-01-31], "UTC"), metrics: [:pageviews, :visitors], dimensions: ["time", "event:page"] }).order_by == [{"time:day", :asc}, {:pageviews, :desc}] end end describe "update_group_by_time" do test "does nothing if `time` dimension not passed" do assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2022-01-04], "UTC"), dimensions: ["time:month"] }).dimensions == ["time:month"] end test "updating time dimension" do assert perform(%{ utc_time_range: DateTimeRange.new!( DateTime.new!(~D[2022-01-01], ~T[00:00:00], "UTC"), DateTime.new!(~D[2022-01-01], ~T[05:00:00], "UTC") ), dimensions: ["time"] }).dimensions == ["time:hour"] assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2022-01-01], "UTC"), dimensions: ["time"] }).dimensions == ["time:hour"] assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2022-01-02], "UTC"), dimensions: ["time"] }).dimensions == ["time:hour"] assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2022-01-03], "UTC"), dimensions: ["time"] }).dimensions == ["time:day"] assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2022-01-10], "UTC"), dimensions: ["time"] }).dimensions == ["time:day"] assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2022-01-16], "UTC"), dimensions: ["time"] }).dimensions == ["time:day"] assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2022-02-16], "UTC"), dimensions: ["time"] }).dimensions == ["time:week"] assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2022-03-16], "UTC"), dimensions: ["time"] }).dimensions == ["time:week"] assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2022-03-16], "UTC"), dimensions: ["time"] }).dimensions == ["time:week"] assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2023-11-16], "UTC"), dimensions: ["time"] }).dimensions == ["time:month"] assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2024-01-16], "UTC"), dimensions: ["time"] }).dimensions == ["time:month"] assert perform(%{ utc_time_range: DateTimeRange.new!(~D[2022-01-01], ~D[2026-01-01], "UTC"), dimensions: ["time"] }).dimensions == ["time:month"] end end describe "update_time_in_order_by" do test "updates explicit time dimension in order_by" do assert perform(%{ utc_time_range: DateTimeRange.new!( DateTime.new!(~D[2022-01-01], ~T[00:00:00], "UTC"), DateTime.new!(~D[2022-01-01], ~T[05:00:00], "UTC") ), dimensions: ["time:hour"], order_by: [{"time", :asc}] }).order_by == [{"time:hour", :asc}] end end describe "extend_hostname_filters_to_visit" do test "updates filters it filtering by event:hostname and visit:referrer and visit:exit_page dimensions" do assert perform(%{ utc_time_range: Date.range(~N[2022-01-01 00:00:00], ~N[2022-01-01 05:00:00]), filters: [ [:is, "event:hostname", ["example.com"]], [:matches_wildcard, "event:hostname", ["*.com"]] ], dimensions: ["visit:referrer", "visit:exit_page"] }).filters == [ [:is, "event:hostname", ["example.com"]], [:matches_wildcard, "event:hostname", ["*.com"]], [:is, "visit:entry_page_hostname", ["example.com"]], [:matches_wildcard, "visit:entry_page_hostname", ["*.com"]], [:is, "visit:exit_page_hostname", ["example.com"]], [:matches_wildcard, "visit:exit_page_hostname", ["*.com"]] ] end test "does not update filters if not needed" do assert perform(%{ utc_time_range: Date.range(~N[2022-01-01 00:00:00], ~N[2022-01-01 05:00:00]), filters: [ [:is, "event:hostname", ["example.com"]] ], dimensions: ["time", "event:hostname"] }).filters == [ [:is, "event:hostname", ["example.com"]] ] end end describe "trim_relative_date_range" do test "trims current month period when flag is set" do now = DateTime.new!(~D[2024-01-15], ~T[12:00:00], "UTC") result = perform(%{ utc_time_range: DateTimeRange.new!(~D[2024-01-01], ~D[2024-01-31], "UTC"), input_date_range: :month, now: now, timezone: "UTC", include: Map.put(ApiQueryParser.default_include(), :trim_relative_date_range, true) }) assert result.utc_time_range.first == ~U[2024-01-01 00:00:00Z] assert result.utc_time_range.last == ~U[2024-01-15 23:59:59Z] end test "trims current year period when flag is set" do now = DateTime.new!(~D[2024-03-15], ~T[12:00:00], "UTC") result = perform(%{ utc_time_range: DateTimeRange.new!(~D[2024-01-01], ~D[2024-12-31], "UTC"), input_date_range: :year, now: now, timezone: "UTC", include: Map.put(ApiQueryParser.default_include(), :trim_relative_date_range, true) }) assert result.utc_time_range.first == ~U[2024-01-01 00:00:00Z] assert result.utc_time_range.last == ~U[2024-03-15 23:59:59Z] end test "trims current day period to current hour when flag is set" do now = DateTime.new!(~D[2024-01-15], ~T[14:30:00], "UTC") result = perform(%{ utc_time_range: DateTimeRange.new!(~D[2024-01-15], ~D[2024-01-15], "UTC"), input_date_range: :day, now: now, timezone: "UTC", include: Map.put(ApiQueryParser.default_include(), :trim_relative_date_range, true) }) assert result.utc_time_range.first == ~U[2024-01-15 00:00:00Z] assert result.utc_time_range.last == ~U[2024-01-15 14:59:59Z] end test "does not trim historical month periods even when flag is set" do now = DateTime.new!(~D[2024-01-15], ~T[12:00:00], "UTC") original_range = DateTimeRange.new!(~D[2023-06-01], ~D[2023-06-30], "UTC") result = perform(%{ utc_time_range: original_range, input_date_range: :month, now: now, timezone: "UTC", include: Map.put(ApiQueryParser.default_include(), :trim_relative_date_range, true) }) assert result.utc_time_range == original_range end test "does not trim historical year periods even when flag is set" do now = DateTime.new!(~D[2024-03-15], ~T[12:00:00], "UTC") original_range = DateTimeRange.new!(~D[2023-01-01], ~D[2023-12-31], "UTC") result = perform(%{ utc_time_range: original_range, input_date_range: :year, now: now, timezone: "UTC", include: Map.put(ApiQueryParser.default_include(), :trim_relative_date_range, true) }) assert result.utc_time_range == original_range end test "does not trim historical day periods even when flag is set" do now = DateTime.new!(~D[2024-01-15], ~T[14:30:00], "UTC") original_range = DateTimeRange.new!(~D[2024-01-10], ~D[2024-01-10], "UTC") result = perform(%{ utc_time_range: original_range, input_date_range: :day, now: now, timezone: "UTC", include: Map.put(ApiQueryParser.default_include(), :trim_relative_date_range, true) }) assert result.utc_time_range == original_range end test "does not trim day when comparisons are set" do now = DateTime.new!(~D[2024-01-15], ~T[12:00:00], "UTC") original_range = DateTimeRange.new!(~D[2024-01-01], ~D[2024-01-31], "UTC") result = perform(%{ utc_time_range: original_range, input_date_range: :day, now: now, timezone: "UTC", include: Map.merge( ApiQueryParser.default_include(), %{comparisons: %{mode: "previous_period"}, trim_relative_date_range: true} ) }) assert result.utc_time_range == original_range end test "trims month when comparisons are set" do now = DateTime.new!(~D[2024-01-15], ~T[12:00:00], "UTC") result = perform(%{ utc_time_range: DateTimeRange.new!(~D[2024-01-01], ~D[2024-01-31], "UTC"), input_date_range: :month, now: now, timezone: "UTC", include: Map.merge( ApiQueryParser.default_include(), %{comparisons: %{mode: "previous_period"}, trim_relative_date_range: true} ) }) assert result.utc_time_range.first == ~U[2024-01-01 00:00:00Z] assert result.utc_time_range.last == ~U[2024-01-15 23:59:59Z] end test "does not trim when flag is false" do now = DateTime.new!(~D[2024-01-15], ~T[12:00:00], "UTC") original_range = DateTimeRange.new!(~D[2024-01-01], ~D[2024-01-31], "UTC") result = perform(%{ utc_time_range: original_range, input_date_range: :month, now: now, timezone: "UTC", include: Map.put(ApiQueryParser.default_include(), :trim_relative_date_range, false) }) assert result.utc_time_range == original_range end test "does not trim when flag is not set" do now = DateTime.new!(~D[2024-01-15], ~T[12:00:00], "UTC") original_range = DateTimeRange.new!(~D[2024-01-01], ~D[2024-01-31], "UTC") result = perform(%{ utc_time_range: original_range, input_date_range: :month, now: now, timezone: "UTC", include: ApiQueryParser.default_include() }) assert result.utc_time_range == original_range end test "does not trim non-current periods like custom date ranges" do now = DateTime.new!(~D[2024-01-15], ~T[12:00:00], "UTC") original_range = DateTimeRange.new!(~D[2024-01-10], ~D[2024-01-16], "UTC") result = perform(%{ utc_time_range: original_range, input_date_range: {:last_n_days, 7}, now: now, timezone: "UTC", include: Map.put(ApiQueryParser.default_include(), :trim_relative_date_range, true) }) assert result.utc_time_range == original_range end test "handles timezone correctly when trimming year periods" do now = DateTime.new!(~D[2024-03-15], ~T[12:00:00], "UTC") result = perform(%{ utc_time_range: DateTimeRange.new!(~D[2024-01-01], ~D[2024-12-31], "America/New_York") |> DateTimeRange.to_timezone("Etc/UTC"), input_date_range: :year, now: now, timezone: "America/New_York", include: Map.put(ApiQueryParser.default_include(), :trim_relative_date_range, true) }) nyc_mar_15_end = DateTimeRange.new!(~D[2024-03-15], ~D[2024-03-15], "America/New_York") |> DateTimeRange.to_timezone("Etc/UTC") |> Map.get(:last) assert result.utc_time_range.last == nyc_mar_15_end end end describe "set_sql_join_type" do test "updates sql_join_type to :full if time:minute or time:hour dimension is present" do assert perform(%{dimensions: ["time:minute"]}).sql_join_type == :full assert perform(%{dimensions: ["time:hour"]}).sql_join_type == :full end test "keeps default sql_join_type otherwise" do assert perform(%{dimensions: ["time:day"]}).sql_join_type == :left assert perform(%{dimensions: []}).sql_join_type == :left end end end