Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

APIv2: Case insensitive search #4863

Merged
merged 15 commits into from
Dec 3, 2024
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ All notable changes to this project will be documented in this file.
### Added
- Dashboard shows comparisons for all reports
- UTM Medium report and API shows (gclid) and (msclkid) for paid searches when no explicit utm medium present.
- Support for `case_sensitive: false` modifiers in Stats API V2 filters for case-insensitive searches.

### Removed
### Changed

- Details modal search inputs are now case-insensitive.

### Fixed

- Fix returning filter suggestions for multiple custom property values in the dashboard Filter modal
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/modals/conversions.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function ConversionsModal() {
}, [reportInfo.dimension])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])

function chooseMetrics() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function BrowserVersionsModal() {
}, [reportInfo.dimension])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])

const renderIcon = useCallback((listItem) => browserIconFor(listItem.browser), [])
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/modals/devices/browsers-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function BrowsersModal() {
}, [reportInfo.dimension])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])

const renderIcon = useCallback((listItem) => browserIconFor(listItem.name), [])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function OperatingSystemVersionsModal() {
}, [reportInfo.dimension])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])

const renderIcon = useCallback((listItem) => osIconFor(listItem.os), [])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function OperatingSystemsModal() {
}, [reportInfo.dimension])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])

const renderIcon = useCallback((listItem) => osIconFor(listItem.name), [])
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/modals/entry-pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function EntryPagesModal() {
}, [reportInfo.dimension])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])

function chooseMetrics() {
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/modals/exit-pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function ExitPagesModal() {
}, [reportInfo.dimension])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])

function chooseMetrics() {
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/modals/locations-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function LocationsModal({ currentView }) {
}, [reportInfo.dimension])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', `${reportInfo.dimension}_name`, [searchString]])
return addFilter(query, ['contains', `${reportInfo.dimension}_name`, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])

function chooseMetrics() {
Expand Down
6 changes: 3 additions & 3 deletions assets/js/dashboard/stats/modals/pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function PagesModal() {
}, [reportInfo.dimension])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])

function chooseMetrics() {
Expand All @@ -46,14 +46,14 @@ function PagesModal() {
metrics.createVisitors({renderLabel: (_query) => 'Current visitors', width: 'w-36'})
]
}

const defaultMetrics = [
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
metrics.createPageviews(),
metrics.createBounceRate(),
metrics.createTimeOnPage()
]

return site.flags.scroll_depth ? [...defaultMetrics, metrics.createScrollDepth()] : defaultMetrics
}

Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/modals/props.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function PropsModal() {
}, [propKey])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', `${EVENT_PROPS_PREFIX}${propKey}`, [searchString]])
return addFilter(query, ['contains', `${EVENT_PROPS_PREFIX}${propKey}`, [searchString], { case_sensitive: false }])
}, [propKey])

function chooseMetrics() {
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/modals/referrer-drilldown.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function ReferrerDrilldownModal() {
}, [reportInfo.dimension])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])

function chooseMetrics() {
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/modals/sources.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function SourcesModal({ currentView }) {
}, [reportInfo.dimension])

const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString]])
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])

function chooseMetrics() {
Expand Down
20 changes: 11 additions & 9 deletions assets/js/dashboard/util/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,16 +229,18 @@ export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
const EVENT_FILTER_KEYS = new Set(['name', 'page', 'goal', 'hostname'])

export function serializeApiFilters(filters) {
const apiFilters = filters.map(([operation, filterKey, clauses]) => {
let apiFilterKey = `visit:${filterKey}`
if (
filterKey.startsWith(EVENT_PROPS_PREFIX) ||
EVENT_FILTER_KEYS.has(filterKey)
) {
apiFilterKey = `event:${filterKey}`
const apiFilters = filters.map(
([operation, filterKey, clauses, ...modifiers]) => {
let apiFilterKey = `visit:${filterKey}`
if (
filterKey.startsWith(EVENT_PROPS_PREFIX) ||
EVENT_FILTER_KEYS.has(filterKey)
) {
apiFilterKey = `event:${filterKey}`
}
return [operation, apiFilterKey, clauses, ...modifiers]
}
return [operation, apiFilterKey, clauses]
})
)

return JSON.stringify(apiFilters)
}
Expand Down
47 changes: 35 additions & 12 deletions assets/js/types/query-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,34 +64,57 @@ export type CustomPropertyFilterDimensions = string;
export type GoalDimension = "event:goal";
export type TimeDimensions = "time" | "time:month" | "time:week" | "time:day" | "time:hour";
export type FilterTree = FilterEntry | FilterAndOr | FilterNot;
export type FilterEntry = FilterWithoutGoals | FilterWithGoals;
export type FilterEntry = FilterWithoutGoals | FilterWithGoals | FilterWithPattern;
/**
* @minItems 3
* @maxItems 3
* @maxItems 4
*/
export type FilterWithoutGoals = [
FilterOperationWithoutGoals | ("matches_wildcard" | "matches_wildcard_not"),
SimpleFilterDimensions | CustomPropertyFilterDimensions,
Clauses
];
export type FilterWithoutGoals =
| [FilterOperationWithoutGoals, SimpleFilterDimensions | CustomPropertyFilterDimensions, Clauses]
| [
FilterOperationWithoutGoals,
SimpleFilterDimensions | CustomPropertyFilterDimensions,
Clauses,
{
case_sensitive?: boolean;
}
];
/**
* filter operation
*/
export type FilterOperationWithoutGoals = "is_not" | "contains_not" | "matches" | "matches_not";
export type FilterOperationWithoutGoals = "is_not" | "contains_not";
export type Clauses = (string | number)[];
/**
* @minItems 3
* @maxItems 4
*/
export type FilterWithGoals =
| [FilterOperationContains, GoalDimension | SimpleFilterDimensions | CustomPropertyFilterDimensions, Clauses]
| [
FilterOperationContains,
GoalDimension | SimpleFilterDimensions | CustomPropertyFilterDimensions,
Clauses,
{
case_sensitive?: boolean;
}
];
/**
* filter operation
*/
export type FilterOperationContains = "is" | "contains";
/**
* @minItems 3
* @maxItems 3
*/
export type FilterWithGoals = [
FilterOperationWithGoals,
GoalDimension | SimpleFilterDimensions | CustomPropertyFilterDimensions,
export type FilterWithPattern = [
FilterOperationRegex | ("matches_wildcard" | "matches_wildcard_not"),
SimpleFilterDimensions | CustomPropertyFilterDimensions,
Clauses
];
/**
* filter operation
*/
export type FilterOperationWithGoals = "is" | "contains";
export type FilterOperationRegex = "matches" | "matches_not";
/**
* @minItems 2
* @maxItems 2
Expand Down
36 changes: 25 additions & 11 deletions lib/plausible/goals/filters.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ defmodule Plausible.Goals.Filters do
* `imported?` - when `true`, builds conditions on the `page` db field rather than
`pathname`, and also skips the `e.name == "pageview"` check.
"""
def add_filter(query, [operation, "event:goal", clauses], opts \\ [])
def add_filter(query, [operation, "event:goal", clauses | _] = filter, opts \\ [])
when operation in [:is, :contains] do
imported? = Keyword.get(opts, :imported?, false)

Enum.reduce(clauses, false, fn clause, dynamic_statement ->
condition =
query.preloaded_goals
|> filter_preloaded(operation, clause)
|> filter_preloaded(filter, clause)
|> build_condition(imported?)

dynamic([e], ^condition or ^dynamic_statement)
Expand All @@ -38,32 +38,46 @@ defmodule Plausible.Goals.Filters do
goals = Plausible.Goals.for_site(site)

Enum.reduce(filters, goals, fn
[operation, "event:goal", clauses], goals ->
goals_matching_any_clause(goals, operation, clauses)
[_, "event:goal" | _] = filter, goals ->
goals_matching_any_clause(goals, filter)

_filter, goals ->
goals
end)
end

def filter_preloaded(preloaded_goals, operation, clause) when operation in [:is, :contains] do
Enum.filter(preloaded_goals, fn goal -> matches?(goal, operation, clause) end)
defp filter_preloaded(preloaded_goals, filter, clause) do
Enum.filter(preloaded_goals, fn goal -> matches?(goal, filter, clause) end)
end

defp goals_matching_any_clause(goals, operation, clauses) do
defp goals_matching_any_clause(goals, [_, _, clauses | _] = filter) do
goals
|> Enum.filter(fn goal ->
Enum.any?(clauses, fn clause -> matches?(goal, operation, clause) end)
Enum.any?(clauses, fn clause -> matches?(goal, filter, clause) end)
end)
end

defp matches?(goal, operation, clause) do
defp matches?(goal, [operation | _rest] = filter, clause) do
goal_name =
goal
|> Plausible.Goal.display_name()
|> mod(filter)

clause = mod(clause, filter)

case operation do
:is ->
Plausible.Goal.display_name(goal) == clause
goal_name == clause

:contains ->
String.contains?(Plausible.Goal.display_name(goal), clause)
String.contains?(goal_name, clause)
end
end

defp mod(str, filter) do
case filter do
[_, _, _, %{case_sensitive: false}] -> String.downcase(str)
_ -> str
end
end

Expand Down
9 changes: 5 additions & 4 deletions lib/plausible/google/search_console/filters.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,28 @@ defmodule Plausible.Google.SearchConsole.Filters do
transform_filter(property, [op, "visit:entry_page" | rest])
end

defp transform_filter(property, [:is, "visit:entry_page", pages]) when is_list(pages) do
# :TODO: Should also work case-insensitive, if not, blacklist.
defp transform_filter(property, [:is, "visit:entry_page", pages | _]) when is_list(pages) do
expression =
Enum.map_join(pages, "|", fn page -> property_url(property, Regex.escape(page)) end)

%{dimension: "page", operator: "includingRegex", expression: expression}
end

defp transform_filter(property, [:matches_wildcard, "visit:entry_page", pages])
defp transform_filter(property, [:matches_wildcard, "visit:entry_page", pages | _])
when is_list(pages) do
expression =
Enum.map_join(pages, "|", fn page -> page_regex(property_url(property, page)) end)

%{dimension: "page", operator: "includingRegex", expression: expression}
end

defp transform_filter(_property, [:is, "visit:screen", devices]) when is_list(devices) do
defp transform_filter(_property, [:is, "visit:screen", devices | _]) when is_list(devices) do
expression = Enum.map_join(devices, "|", &search_console_device/1)
%{dimension: "device", operator: "includingRegex", expression: expression}
end

defp transform_filter(_property, [:is, "visit:country", countries])
defp transform_filter(_property, [:is, "visit:country", countries | _])
when is_list(countries) do
expression = Enum.map_join(countries, "|", &search_console_country/1)
%{dimension: "country", operator: "includingRegex", expression: expression}
Expand Down
4 changes: 2 additions & 2 deletions lib/plausible/stats/filters/filters.ex
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ defmodule Plausible.Stats.Filters do

def rename_dimensions_used_in_filter(filters, renames) do
transform_filters(filters, fn
[operation, dimension, clauses] ->
[[operation, Map.get(renames, dimension, dimension), clauses]]
[operation, dimension | rest] ->
[[operation, Map.get(renames, dimension, dimension) | rest]]

_subtree ->
nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ defmodule Plausible.Stats.Filters.LegacyDashboardFilterParser do
is_negated && is_wildcard && is_list ->
[:matches_wildcard_not, key, val]

# TODO
is_negated && is_contains && is_list ->
[:matches_wildcard_not, key, Enum.map(val, &"**#{&1}**")]

Expand Down
Loading
Loading