feat(cohorts): Allow adding property filters to events in cohorts (#21375)

This commit is contained in:
Neil Kakkar
2024-04-15 13:08:17 +01:00
committed by GitHub
parent 7d483a3a94
commit de511eeb89
29 changed files with 770 additions and 120 deletions

View File

@@ -78,6 +78,7 @@ module.exports = {
'error',
{
ignoreRestSiblings: true,
destructuredArrayIgnorePattern: '^_$',
},
],
'@typescript-eslint/prefer-ts-expect-error': 'error',

View File

@@ -84,7 +84,8 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 2 year
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_15_level_level_0_level_0_level_0_0
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_15_level_level_0_level_0_level_0_0
FROM events e
INNER JOIN
(SELECT distinct_id,
@@ -149,7 +150,8 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 2 year
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_17_level_level_0_level_0_level_0_0
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_17_level_level_0_level_0_level_0_0
FROM events e
INNER JOIN
(SELECT distinct_id,
@@ -238,7 +240,8 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 2 year
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_19_level_level_0_level_0_level_0_0,
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_19_level_level_0_level_0_level_0_0,
minIf(timestamp, event = 'signup') >= now() - INTERVAL 15 day
AND minIf(timestamp, event = 'signup') < now() as first_time_condition_19_level_level_0_level_1_level_0_level_0_level_0_0
FROM events e

View File

@@ -7,10 +7,12 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 1 day
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_None_level_level_0_level_0_level_0_0,
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_level_0_0,
countIf(timestamp > now() - INTERVAL 2 week
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_None_level_level_0_level_0_level_1_0,
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_level_1_0,
minIf(timestamp, ((replaceRegexpAll(JSONExtractRaw(properties, '$current_url'), '^"|"$', '') = 'https://posthog.com/feedback/123'
AND event = '$autocapture'))) >= now() - INTERVAL 2 week
AND minIf(timestamp, ((replaceRegexpAll(JSONExtractRaw(properties, '$current_url'), '^"|"$', '') = 'https://posthog.com/feedback/123'
@@ -126,7 +128,8 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 1 week
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_None_level_level_0_level_0_0
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_0
FROM events e
INNER JOIN
(SELECT distinct_id,
@@ -167,7 +170,8 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 1 week
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_None_level_level_0_level_1_level_0_0
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_1_level_0_0
FROM events e
INNER JOIN
(SELECT distinct_id,
@@ -250,7 +254,8 @@
AND event_0_latest_1 <= event_0_latest_0 + INTERVAL 3 day, 2, 1)) = 2 AS steps_0,
countIf(timestamp > now() - INTERVAL 1 week
AND timestamp < now()
AND event = '$new_view') >= 1 AS performed_event_multiple_condition_None_level_level_0_level_1_0
AND event = '$new_view'
AND 1=1) >= 1 AS performed_event_multiple_condition_None_level_level_0_level_1_0
FROM
(SELECT person_id,
event,
@@ -297,7 +302,8 @@
AND event_0_latest_1 <= event_0_latest_0 + INTERVAL 3 day, 2, 1)) = 2 AS steps_0,
countIf(timestamp > now() - INTERVAL 1 week
AND timestamp < now()
AND event = '$pageview') >= 1 AS performed_event_multiple_condition_None_level_level_0_level_1_0
AND event = '$pageview'
AND 1=1) >= 1 AS performed_event_multiple_condition_None_level_level_0_level_1_0
FROM
(SELECT person_id,
event,
@@ -348,6 +354,58 @@
AND (performed_event_multiple_condition_None_level_level_0_level_1_0)))
'''
# ---
# name: TestCohortQuery.test_performed_event_with_event_filters
'''
SELECT behavior_query.person_id AS id
FROM
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 1 week
AND timestamp < now()
AND event = '$pageview'
AND (has(['something'], replaceRegexpAll(JSONExtractRaw(properties, '$filter_prop'), '^"|"$', '')))) > 0 AS performed_event_condition_None_level_level_0_level_0_0
FROM events e
INNER JOIN
(SELECT distinct_id,
argMax(person_id, version) as person_id
FROM person_distinct_id2
WHERE team_id = 2
GROUP BY distinct_id
HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id
WHERE team_id = 2
AND event IN ['$pageview']
AND timestamp <= now()
AND timestamp >= now() - INTERVAL 1 week
GROUP BY person_id) behavior_query
WHERE 1 = 1
AND (((performed_event_condition_None_level_level_0_level_0_0)))
'''
# ---
# name: TestCohortQuery.test_performed_event_with_event_filters_and_explicit_date
'''
SELECT behavior_query.person_id AS id
FROM
(SELECT pdi.person_id AS person_id,
countIf(timestamp > '2024-04-02 13:01:01'
AND timestamp < now()
AND event = '$pageview'
AND (has(['something'], replaceRegexpAll(JSONExtractRaw(properties, '$filter_prop'), '^"|"$', '')))) > 0 AS performed_event_condition_None_level_level_0_level_0_0
FROM events e
INNER JOIN
(SELECT distinct_id,
argMax(person_id, version) as person_id
FROM person_distinct_id2
WHERE team_id = 2
GROUP BY distinct_id
HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id
WHERE team_id = 2
AND event IN ['$pageview']
GROUP BY person_id) behavior_query
WHERE 1 = 1
AND (((performed_event_condition_None_level_level_0_level_0_0)))
'''
# ---
# name: TestCohortQuery.test_person
'''
@@ -356,7 +414,8 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 1 week
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_None_level_level_0_level_0_0
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_0
FROM events e
INNER JOIN
(SELECT distinct_id,
@@ -393,7 +452,8 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 1 week
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_None_level_level_0_level_0_0
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_0
FROM events e
INNER JOIN
(SELECT distinct_id,
@@ -430,10 +490,12 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 1 day
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_None_level_level_0_level_0_level_0_0,
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_level_0_0,
countIf(timestamp > now() - INTERVAL 2 week
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_None_level_level_0_level_0_level_1_0,
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_level_1_0,
minIf(timestamp, ((replaceRegexpAll(JSONExtractRaw(properties, '$current_url'), '^"|"$', '') = 'https://posthog.com/feedback/123'
AND event = '$autocapture'))) >= now() - INTERVAL 2 week
AND minIf(timestamp, ((replaceRegexpAll(JSONExtractRaw(properties, '$current_url'), '^"|"$', '') = 'https://posthog.com/feedback/123'
@@ -579,7 +641,8 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 1 week
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_None_level_level_0_level_1_0
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_1_0
FROM events e
INNER JOIN
(SELECT distinct_id,
@@ -619,7 +682,8 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 1 week
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_None_level_level_0_level_1_level_0_0
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_1_level_0_0
FROM events e
INNER JOIN
(SELECT distinct_id,
@@ -668,10 +732,12 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 7 day
AND timestamp < now()
AND event = '$new_view') > 0 AS performed_event_condition_None_level_level_0_level_0_level_0_0,
AND event = '$new_view'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_level_0_0,
countIf(timestamp > now() - INTERVAL 1 week
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_None_level_level_0_level_1_0
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_1_0
FROM events e
INNER JOIN
(SELECT distinct_id,

View File

@@ -1,5 +1,7 @@
from datetime import datetime, timedelta
from freezegun import freeze_time
from ee.clickhouse.queries.enterprise_cohort_query import check_negation_clause
from posthog.client import sync_execute
from posthog.constants import PropertyOperatorType
@@ -216,10 +218,76 @@ class TestCohortQuery(ClickhouseTestMixin, BaseTest):
{
"key": "$pageview",
"event_type": "events",
"explicit_datetime": "-1w",
"value": "performed_event",
"type": "behavioral",
}
],
}
}
)
q, params = CohortQuery(filter=filter, team=self.team).get_query()
res = sync_execute(q, {**params, **filter.hogql_context.values})
self.assertEqual([p1.uuid], [r[0] for r in res])
@snapshot_clickhouse_queries
@freeze_time("2024-04-05 13:01:01")
def test_performed_event_with_event_filters_and_explicit_date(self):
p1 = _create_person(
team_id=self.team.pk,
distinct_ids=["p1"],
properties={"name": "test", "email": "test@posthog.com"},
)
_create_event(
team=self.team,
event="$pageview",
properties={"$filter_prop": "something"},
distinct_id="p1",
timestamp=datetime.now() - timedelta(days=2),
)
_create_person(
team_id=self.team.pk,
distinct_ids=["p2"],
properties={"name": "test", "email": "test@posthog.com"},
)
_create_event(
team=self.team,
event="$pageview",
properties={},
distinct_id="p2",
timestamp=datetime.now() - timedelta(days=2),
)
_create_event(
team=self.team,
event="$pageview",
properties={"$filter_prop": "something"},
distinct_id="p2",
# rejected because explicit datetime is set to 3 days ago
timestamp=datetime.now() - timedelta(days=5),
)
flush_persons_and_events()
filter = Filter(
data={
"properties": {
"type": "AND",
"values": [
{
"key": "$pageview",
"event_type": "events",
"explicit_datetime": str(
datetime.now() - timedelta(days=3)
), # overrides time_value and time_interval
"time_value": 1,
"time_interval": "week",
"value": "performed_event",
"type": "behavioral",
"event_filters": [
{"key": "$filter_prop", "value": "something", "operator": "exact", "type": "event"}
],
}
],
}
@@ -292,6 +360,78 @@ class TestCohortQuery(ClickhouseTestMixin, BaseTest):
self.assertEqual([p1.uuid], [r[0] for r in res])
def test_performed_event_multiple_with_event_filters(self):
p1 = _create_person(
team_id=self.team.pk,
distinct_ids=["p1"],
properties={"name": "test", "email": "test@posthog.com"},
)
_create_event(
team=self.team,
event="$pageview",
properties={"$filter_prop": "something"},
distinct_id="p1",
timestamp=datetime.now() - timedelta(days=2),
)
_create_event(
team=self.team,
event="$pageview",
properties={"$filter_prop": "something"},
distinct_id="p1",
timestamp=datetime.now() - timedelta(days=4),
)
_create_person(
team_id=self.team.pk,
distinct_ids=["p2"],
properties={"name": "test", "email": "test@posthog.com"},
)
_create_event(
team=self.team,
event="$pageview",
properties={},
distinct_id="p2",
timestamp=datetime.now() - timedelta(days=2),
)
_create_event(
team=self.team,
event="$pageview",
properties={},
distinct_id="p2",
timestamp=datetime.now() - timedelta(days=4),
)
flush_persons_and_events()
filter = Filter(
data={
"properties": {
"type": "AND",
"values": [
{
"key": "$pageview",
"event_type": "events",
"operator": "gte",
"operator_value": 1,
"time_value": 1,
"time_interval": "week",
"value": "performed_event_multiple",
"type": "behavioral",
"event_filters": [
{"key": "$filter_prop", "value": "something", "operator": "exact", "type": "event"},
{"key": "$filter_prop", "value": "some", "operator": "icontains", "type": "event"},
],
}
],
}
}
)
q, params = CohortQuery(filter=filter, team=self.team).get_query()
res = sync_execute(q, {**params, **filter.hogql_context.values})
self.assertEqual([p1.uuid], [r[0] for r in res])
def test_performed_event_lte_1_times(self):
_create_person(
team_id=self.team.pk,

View File

@@ -28,7 +28,7 @@ from posthog.models.experiment import Experiment
from posthog.models.filters.filter import Filter
from posthog.utils import generate_cache_key, get_safe_cache
EXPERIMENT_RESULTS_CACHE_DEFAULT_TTL = 60 * 30 # 30 minutes
EXPERIMENT_RESULTS_CACHE_DEFAULT_TTL = 60 * 60 # 1 hour
def _calculate_experiment_results(experiment: Experiment, refresh: bool = False):

View File

@@ -1,7 +1,7 @@
# serializer version: 1
# name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results
'''
/* user_id:107 celery:posthog.tasks.tasks.sync_insight_caching_state */
/* user_id:109 celery:posthog.tasks.tasks.sync_insight_caching_state */
SELECT team_id,
date_diff('second', max(timestamp), now()) AS age
FROM events

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -22,6 +22,7 @@ import { DateMappingOption, PropertyOperator } from '~/types'
import { PropertyFilterDatePicker } from '../PropertyFilters/components/PropertyFilterDatePicker'
import { dateFilterLogic } from './dateFilterLogic'
import { RollingDateRangeFilter } from './RollingDateRangeFilter'
import { DateOption } from './rollingDateRangeFilterLogic'
export interface DateFilterProps {
showCustom?: boolean
@@ -41,6 +42,7 @@ interface RawDateFilterProps extends DateFilterProps {
dateFrom?: string | null | dayjs.Dayjs
dateTo?: string | null | dayjs.Dayjs
max?: number | null
allowedRollingDateOptions?: DateOption[]
}
export function DateFilter({
@@ -58,6 +60,7 @@ export function DateFilter({
dropdownPlacement = 'bottom-start',
max,
isFixedDateMode = false,
allowedRollingDateOptions,
}: RawDateFilterProps): JSX.Element {
const key = useRef(uuid()).current
const logicProps: DateFilterLogicProps = {
@@ -183,7 +186,11 @@ export function DateFilter({
ref: rollingDateRangeRef,
}}
max={max}
allowedDateOptions={isFixedDateMode ? ['hours', 'days', 'weeks', 'months', 'years'] : undefined}
allowedDateOptions={
isFixedDateMode && !allowedRollingDateOptions
? ['hours', 'days', 'weeks', 'months', 'years']
: allowedRollingDateOptions
}
fullWidth
/>
)}

View File

@@ -409,7 +409,6 @@ export function HedgehogBuddy({
return actor.setupKeyboardListeners()
}, [])
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, setTimerLoop] = useState(0)
const [popoverVisible, setPopoverVisible] = useState(false)

View File

@@ -341,3 +341,11 @@ export function taxonomicFilterTypeToPropertyFilterType(
| PropertyFilterType
| undefined
}
export function isEmptyProperty(property: AnyPropertyFilter): boolean {
return (
property.value === null ||
property.value === undefined ||
(Array.isArray(property.value) && property.value.length === 0)
)
}

View File

@@ -5,6 +5,7 @@ import api from 'lib/api'
import { exportsLogic } from 'lib/components/ExportButton/exportsLogic'
import { deleteWithUndo } from 'lib/utils/deleteWithUndo'
import { permanentlyMount } from 'lib/utils/kea-logic-builders'
import { COHORT_EVENT_TYPES_WITH_EXPLICIT_DATETIME } from 'scenes/cohorts/CohortFilters/constants'
import { BehavioralFilterKey } from 'scenes/cohorts/CohortFilters/types'
import { personsLogic } from 'scenes/persons/personsLogic'
import { isAuthenticatedTeam, teamLogic } from 'scenes/teamLogic'
@@ -36,18 +37,7 @@ export function processCohort(cohort: CohortType): CohortType {
? {
...group,
values: (group.values as AnyCohortCriteriaType[]).map((c) =>
c.type &&
[BehavioralFilterKey.Cohort, BehavioralFilterKey.Person].includes(c.type) &&
!('value_property' in c)
? {
...c,
value_property: c.value,
value:
c.type === BehavioralFilterKey.Cohort
? BehavioralCohortType.InCohort
: BehavioralEventType.HaveProperty,
}
: c
processCohortCriteria(c)
),
}
: group
@@ -58,6 +48,45 @@ export function processCohort(cohort: CohortType): CohortType {
}
}
function convertTimeValueToRelativeTime(criteria: AnyCohortCriteriaType): string | undefined {
const timeValue = criteria?.time_value
const timeInterval = criteria?.time_interval
if (timeValue && timeInterval) {
return `-${timeValue}${timeInterval[0]}`
}
}
function processCohortCriteria(criteria: AnyCohortCriteriaType): AnyCohortCriteriaType {
if (!criteria.type) {
return criteria
}
const processedCriteria = { ...criteria }
if (
[BehavioralFilterKey.Cohort, BehavioralFilterKey.Person].includes(criteria.type) &&
!('value_property' in criteria)
) {
processedCriteria.value_property = criteria.value
processedCriteria.value =
criteria.type === BehavioralFilterKey.Cohort
? BehavioralCohortType.InCohort
: BehavioralEventType.HaveProperty
}
if (
[BehavioralFilterKey.Behavioral].includes(criteria.type) &&
!('explicit_datetime' in criteria) &&
criteria.value &&
COHORT_EVENT_TYPES_WITH_EXPLICIT_DATETIME.includes(criteria.value)
) {
processedCriteria.explicit_datetime = convertTimeValueToRelativeTime(criteria)
}
return processedCriteria
}
export const cohortsModel = kea<cohortsModelType>([
path(['models', 'cohortsModel']),
connect({

View File

@@ -47,6 +47,8 @@ export function CohortCriteriaRowBuilder({
...(_field.type === FilterType.Text ? { value: _field.defaultValue } : {}),
...(_field.groupTypeFieldKey ? { groupTypeFieldKey: _field.groupTypeFieldKey } : {}),
onChange: (newCriteria) => setCriteria(newCriteria, groupIndex, index),
groupIndex,
index,
} as CohortFieldProps)}
</div>
)
@@ -120,7 +122,7 @@ export function CohortCriteriaRowBuilder({
<span className="CohortCriteriaRow__Criteria__arrow">&#8627;</span>
</div>
<div>
<div className="flex items-center">
<div className="flex flex-wrap items-center">
{rowShape.fields.map((field, i) => {
return (
!field.hide &&

View File

@@ -2,24 +2,30 @@ import './CohortField.scss'
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { DateFilter } from 'lib/components/DateFilter/DateFilter'
import { PropertyValue } from 'lib/components/PropertyFilters/components/PropertyValue'
import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters'
import { TaxonomicFilterGroupType, TaxonomicFilterValue } from 'lib/components/TaxonomicFilter/types'
import { TaxonomicPopover } from 'lib/components/TaxonomicPopover/TaxonomicPopover'
import { dayjs } from 'lib/dayjs'
import { LemonButton, LemonButtonWithDropdown } from 'lib/lemon-ui/LemonButton'
import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput'
import { useMemo } from 'react'
import { formatDate } from 'lib/utils'
import { useEffect, useMemo, useRef } from 'react'
import { cohortFieldLogic } from 'scenes/cohorts/CohortFilters/cohortFieldLogic'
import {
CohortEventFiltersFieldProps,
CohortFieldBaseProps,
CohortNumberFieldProps,
CohortPersonPropertiesValuesFieldProps,
CohortRelativeAndExactTimeFieldProps,
CohortSelectorFieldProps,
CohortTaxonomicFieldProps,
CohortTextFieldProps,
} from 'scenes/cohorts/CohortFilters/types'
import { PropertyFilterType, PropertyFilterValue, PropertyOperator } from '~/types'
import { AnyPropertyFilter, PropertyFilterType, PropertyFilterValue, PropertyOperator } from '~/types'
let uniqueMemoizedIndex = 0
@@ -167,6 +173,118 @@ export function CohortPersonPropertiesValuesField({
)
}
export function CohortEventFiltersField({
fieldKey,
criteria,
cohortFilterLogicKey,
onChange: _onChange,
groupIndex,
index,
}: CohortEventFiltersFieldProps): JSX.Element {
const { logic } = useCohortFieldLogic({
fieldKey,
criteria,
cohortFilterLogicKey,
onChange: _onChange,
})
const { value } = useValues(logic)
const { onChange } = useActions(logic)
const componentRef = useRef<HTMLDivElement>(null)
const valueExists = ((value as AnyPropertyFilter[]) || []).length > 0
useEffect(() => {
// :TRICKY: We check paremt has CohortCriteriaRow__Criteria__Field class and add basis-full class if value exists
// We need to do this because of how this list is generated, and we need to add a line-break programatically
// when the PropertyFilters take up too much space.
// Since the list of children is declared in the parent component, we can't add a class to the parent directly, without
// adding a lot of annoying complexity to the parent component.
// This is a hacky solution, but it works 🙈.
// find parent with className CohortCriteriaRow__Criteria__Field and add basis-full class if value exists
const parent = componentRef.current?.closest('.CohortCriteriaRow__Criteria__Field')
if (parent) {
if (valueExists) {
parent.classList.add('basis-full')
} else {
parent.classList.remove('basis-full')
}
}
}, [componentRef, value])
return (
<div ref={componentRef}>
<PropertyFilters
propertyFilters={(value as AnyPropertyFilter[]) || []}
taxonomicGroupTypes={[
TaxonomicFilterGroupType.EventProperties,
TaxonomicFilterGroupType.EventFeatureFlags,
TaxonomicFilterGroupType.Elements,
TaxonomicFilterGroupType.HogQLExpression,
]}
onChange={(newValue: AnyPropertyFilter[]) => {
onChange({ [fieldKey]: newValue })
}}
pageKey={`${fieldKey}-${groupIndex}-${index}`}
eventNames={criteria?.key ? [criteria?.key] : []}
disablePopover
hasRowOperator={valueExists ? true : false}
sendAllKeyUpdates
/>
</div>
)
}
export function CohortRelativeAndExactTimeField({
fieldKey,
criteria,
cohortFilterLogicKey,
onChange: _onChange,
}: CohortRelativeAndExactTimeFieldProps): JSX.Element {
const { logic } = useCohortFieldLogic({
fieldKey,
criteria,
cohortFilterLogicKey,
onChange: _onChange,
})
// This replaces the old TimeUnit and TimeInterval filters
// and combines them with a relative+exact time option.
// This is more inline with rest of analytics filters and make things much nicer here.
const { value } = useValues(logic)
const { onChange } = useActions(logic)
return (
<DateFilter
dateFrom={String(value)}
onChange={(fromDate) => {
onChange({ [fieldKey]: fromDate })
}}
max={1000}
isFixedDateMode
allowedRollingDateOptions={['days', 'weeks', 'months', 'years']}
showCustom
dateOptions={[
{
key: 'Last 7 days',
values: ['-7d'],
getFormattedDate: (date: dayjs.Dayjs): string => formatDate(date.subtract(7, 'd')),
defaultInterval: 'day',
},
{
key: 'Last 30 days',
values: ['-30d'],
getFormattedDate: (date: dayjs.Dayjs): string => formatDate(date.subtract(14, 'd')),
defaultInterval: 'day',
},
]}
size="medium"
makeLabel={(_, startOfRange) => (
<span className="hide-when-small">Matches all values after {startOfRange} if evaluated today.</span>
)}
/>
)
}
export function CohortTextField({ value }: CohortTextFieldProps): JSX.Element {
return <span className={clsx('CohortField', 'CohortField__CohortTextField')}>{value}</span>
}

View File

@@ -27,7 +27,7 @@ const Template: StoryFn<typeof CohortTaxonomicField> = (props: CohortTaxonomicFi
props.taxonomicGroupTypes[0] === TaxonomicFilterGroupType.Events &&
props.taxonomicGroupTypes[1] === TaxonomicFilterGroupType.Actions
? FilterType.EventsAndActions
: FilterType.EventProperties
: FilterType.PersonProperties
return renderField[type]({
...props,
criteria: {

View File

@@ -2,8 +2,10 @@ import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { CohortTypeEnum, PROPERTY_MATCH_TYPE } from 'lib/constants'
import { LemonSelectOptions } from 'lib/lemon-ui/LemonSelect'
import {
CohortEventFiltersField,
CohortNumberField,
CohortPersonPropertiesValuesField,
CohortRelativeAndExactTimeField,
CohortSelectorField,
CohortTaxonomicField,
CohortTextField,
@@ -12,9 +14,11 @@ import {
BehavioralFilterKey,
BehavioralFilterType,
CohortClientErrors,
CohortEventFiltersFieldProps,
CohortFieldProps,
CohortNumberFieldProps,
CohortPersonPropertiesValuesFieldProps,
CohortRelativeAndExactTimeFieldProps,
CohortTaxonomicFieldProps,
CohortTextFieldProps,
FieldOptionsType,
@@ -341,19 +345,18 @@ export const ROWS: Record<BehavioralFilterType, Row> = {
defaultValue: TaxonomicFilterGroupType.Events,
hide: true,
},
{
fieldKey: 'event_filters',
type: FilterType.EventFilters,
},
{
type: FilterType.Text,
defaultValue: 'in the last',
defaultValue: 'after',
},
{
fieldKey: 'time_value',
type: FilterType.Number,
defaultValue: '30',
},
{
fieldKey: 'time_interval',
type: FilterType.TimeUnit,
defaultValue: TimeUnitType.Day,
fieldKey: 'explicit_datetime',
type: FilterType.RelativeAndExactTime,
defaultValue: '-30d',
},
],
},
@@ -373,19 +376,18 @@ export const ROWS: Record<BehavioralFilterType, Row> = {
defaultValue: TaxonomicFilterGroupType.Events,
hide: true,
},
{
fieldKey: 'event_filters',
type: FilterType.EventFilters,
},
{
type: FilterType.Text,
defaultValue: 'in the last',
defaultValue: 'after',
},
{
fieldKey: 'time_value',
type: FilterType.Number,
defaultValue: '30',
},
{
fieldKey: 'time_interval',
type: FilterType.TimeUnit,
defaultValue: TimeUnitType.Day,
fieldKey: 'explicit_datetime',
type: FilterType.RelativeAndExactTime,
defaultValue: '-30d',
},
],
},
@@ -405,6 +407,10 @@ export const ROWS: Record<BehavioralFilterType, Row> = {
defaultValue: TaxonomicFilterGroupType.Events,
hide: true,
},
{
fieldKey: 'event_filters',
type: FilterType.EventFilters,
},
{
fieldKey: 'operator',
type: FilterType.EventsAndActionsMathOperator,
@@ -417,17 +423,12 @@ export const ROWS: Record<BehavioralFilterType, Row> = {
},
{
type: FilterType.Text,
defaultValue: 'times in the last',
defaultValue: 'times after',
},
{
fieldKey: 'time_value',
type: FilterType.Number,
defaultValue: '30',
},
{
fieldKey: 'time_interval',
type: FilterType.TimeUnit,
defaultValue: TimeUnitType.Day,
fieldKey: 'explicit_datetime',
type: FilterType.RelativeAndExactTime,
defaultValue: '-30d',
},
],
},
@@ -568,7 +569,7 @@ export const ROWS: Record<BehavioralFilterType, Row> = {
fields: [
{
fieldKey: 'key',
type: FilterType.EventProperties,
type: FilterType.PersonProperties,
},
{
fieldKey: 'operator',
@@ -588,7 +589,7 @@ export const ROWS: Record<BehavioralFilterType, Row> = {
fields: [
{
fieldKey: 'key',
type: FilterType.EventProperties,
type: FilterType.PersonProperties,
},
{
fieldKey: 'operator',
@@ -823,6 +824,10 @@ export const ROWS: Record<BehavioralFilterType, Row> = {
},
}
export const COHORT_EVENT_TYPES_WITH_EXPLICIT_DATETIME = Object.entries(ROWS)
.filter(([_, row]) => row.fields.some((field) => field.type === FilterType.RelativeAndExactTime))
.map(([eventType, _]) => eventType)
// Building blocks of a row
export const renderField: Record<FilterType, (props: CohortFieldProps) => JSX.Element> = {
[FilterType.Behavioral]: function _renderField(p) {
@@ -877,7 +882,7 @@ export const renderField: Record<FilterType, (props: CohortFieldProps) => JSX.El
/>
)
},
[FilterType.EventProperties]: function _renderField(p) {
[FilterType.PersonProperties]: function _renderField(p) {
return (
<CohortTaxonomicField
{...(p as CohortTaxonomicFieldProps)}
@@ -886,6 +891,9 @@ export const renderField: Record<FilterType, (props: CohortFieldProps) => JSX.El
/>
)
},
[FilterType.EventFilters]: function _renderField(p) {
return <CohortEventFiltersField {...(p as CohortEventFiltersFieldProps)} />
},
[FilterType.PersonPropertyValues]: function _renderField(p) {
return p.criteria['operator'] &&
[PropertyOperator.IsSet, PropertyOperator.IsNotSet].includes(p.criteria['operator']) ? (
@@ -913,6 +921,9 @@ export const renderField: Record<FilterType, (props: CohortFieldProps) => JSX.El
/>
)
},
[FilterType.RelativeAndExactTime]: function _renderField(p) {
return <CohortRelativeAndExactTimeField {...(p as CohortRelativeAndExactTimeFieldProps)} />
},
[FilterType.EventType]: function _renderField() {
return <></>
},
@@ -926,7 +937,8 @@ export const CRITERIA_VALIDATIONS: Record<
(d: string | number | null | undefined) => CohortClientErrors | undefined
> = {
[FilterType.EventsAndActions]: () => CohortClientErrors.EmptyEventsAndActions,
[FilterType.EventProperties]: () => CohortClientErrors.EmptyEventProperties,
[FilterType.EventFilters]: () => CohortClientErrors.EmptyEventFilters,
[FilterType.PersonProperties]: () => CohortClientErrors.EmptyPersonProperties,
[FilterType.PersonPropertyValues]: () => CohortClientErrors.EmptyPersonPropertyValues,
[FilterType.EventType]: () => CohortClientErrors.EmptyEventType,
[FilterType.Number]: (d) => (Number(d) > 1 ? undefined : CohortClientErrors.EmptyNumber),
@@ -934,6 +946,7 @@ export const CRITERIA_VALIDATIONS: Record<
[FilterType.TimeUnit]: () => CohortClientErrors.EmptyTimeUnit,
[FilterType.MathOperator]: () => CohortClientErrors.EmptyMathOperator,
[FilterType.EventsAndActionsMathOperator]: () => CohortClientErrors.EmptyMathOperator,
[FilterType.RelativeAndExactTime]: () => CohortClientErrors.EmptyRelativeAndExactTime,
[FilterType.CohortId]: () => CohortClientErrors.EmptyCohortId,
[FilterType.CohortValues]: () => CohortClientErrors.EmptyCohortValues,
[FilterType.Value]: () => CohortClientErrors.EmptyValue,
@@ -952,8 +965,7 @@ export const NEW_CRITERIA = {
type: BehavioralFilterKey.Behavioral,
value: BehavioralEventType.PerformEvent,
event_type: TaxonomicFilterGroupType.Events,
time_value: '30',
time_interval: TimeUnitType.Day,
explicit_datetime: '-30d',
}
export const NEW_CRITERIA_GROUP: CohortCriteriaGroupFilter = {

View File

@@ -21,7 +21,9 @@ export enum FilterType {
Value = 'value',
Text = 'text',
EventsAndActions = 'eventsAndActions',
EventProperties = 'eventProperties',
RelativeAndExactTime = 'relativeAndExactTime',
EventFilters = 'eventFilters',
PersonProperties = 'personProperties',
PersonPropertyValues = 'personPropertyValues',
EventType = 'eventType',
Number = 'number',
@@ -86,6 +88,8 @@ export interface Row {
export interface CohortFieldBaseProps extends Omit<CohortFieldLogicProps, 'cohortFilterLogicKey'> {
cohortFilterLogicKey?: string
groupIndex?: number
index?: number
}
export interface CohortSelectorFieldProps extends CohortFieldBaseProps {
@@ -105,6 +109,14 @@ export interface CohortPersonPropertiesValuesFieldProps extends Omit<CohortField
operator?: PropertyOperator
}
export interface CohortEventFiltersFieldProps extends Omit<CohortFieldBaseProps, 'fieldOptionGroupTypes'> {
fieldOptionGroupTypes: never
}
export interface CohortRelativeAndExactTimeFieldProps extends Omit<CohortFieldBaseProps, 'fieldOptionGroupTypes'> {
fieldOptionGroupTypes: never
}
export interface CohortTextFieldProps extends CohortFieldBaseProps {
value: string
}
@@ -119,6 +131,8 @@ export type CohortFieldProps =
| CohortTaxonomicFieldProps
| CohortTextFieldProps
| CohortPersonPropertiesValuesFieldProps
| CohortEventFiltersFieldProps
| CohortRelativeAndExactTimeFieldProps
export enum CohortClientErrors {
NegationCriteriaMissingOther = 'Negation criteria can only be used when matching all criteria (AND), and must be accompanied by at least one positive matching criteria.',
@@ -126,12 +140,14 @@ export enum CohortClientErrors {
PeriodTimeMismatch = 'The lower bound period value must not be greater than the upper bound value.',
SequentialTimeMismatch = 'The lower bound period sequential time value must not be greater than the upper bound time value.',
EmptyEventsAndActions = 'Event or action cannot be empty.',
EmptyEventProperties = 'Event property cannot be empty.',
EmptyEventFilters = 'Event filters cannot be empty.',
EmptyPersonProperties = 'Person property name cannot be empty.',
EmptyPersonPropertyValues = 'Person property value cannot be empty',
EmptyEventType = 'Event type cannot be empty.',
EmptyNumber = 'Period values must be at least 1 day and cannot be empty.',
EmptyNumberTicker = 'Number cannot be empty.',
EmptyTimeUnit = 'Time interval cannot be empty.',
EmptyRelativeAndExactTime = 'Time value cannot be empty.',
EmptyMathOperator = 'Math operator cannot be empty.',
EmptyCohortId = 'Cohort id cannot be empty.',
EmptyCohortValues = 'Cohort value cannot be empty.',

View File

@@ -17,6 +17,7 @@ import {
BehavioralLifecycleType,
CohortCriteriaGroupFilter,
FilterLogicalOperator,
PropertyFilterType,
PropertyOperator,
TimeUnitType,
} from '~/types'
@@ -83,9 +84,6 @@ describe('cohortEditLogic', () => {
})
.toFinishAllListeners()
.toDispatchActions(['setCohort', 'deleteCohort', router.actionCreators.push(urls.cohorts())])
.toMatchValues({
cohort: mockCohort,
})
expect(api.update).toBeCalledTimes(1)
})
@@ -431,6 +429,86 @@ describe('cohortEditLogic', () => {
expect(api.update).toBeCalledTimes(0)
})
it('do not save on partial event filters', async () => {
await initCohortLogic({ id: 1 })
await expectLogic(logic, async () => {
logic.actions.setCohort({
...mockCohort,
filters: {
properties: {
id: '39777',
type: FilterLogicalOperator.Or,
values: [
{
id: '70427',
type: FilterLogicalOperator.And,
values: [
{
type: BehavioralFilterKey.Behavioral,
value: BehavioralEventType.PerformEvent,
event_type: TaxonomicFilterGroupType.Events,
explicit_datetime: '-14d',
key: 'dashboard date range changed',
event_filters: [
{
key: '$browser',
value: null,
type: PropertyFilterType.Event,
operator: PropertyOperator.Exact,
},
],
},
{
type: BehavioralFilterKey.Behavioral,
value: BehavioralEventType.PerformEvent,
event_type: TaxonomicFilterGroupType.Events,
time_value: '1',
time_interval: TimeUnitType.Day,
key: '$rageclick',
negation: true,
event_filters: [
{
key: '$browser',
value: null,
type: PropertyFilterType.Event,
operator: PropertyOperator.Exact,
},
],
},
],
},
],
},
},
})
logic.actions.submitCohort()
})
.toDispatchActions(['setCohort', 'submitCohort', 'submitCohortFailure'])
.toMatchValues({
cohortErrors: partial({
filters: {
properties: {
values: [
{
values: [
{
event_filters: 'Event filters cannot be empty.',
id: 'Event filters cannot be empty.',
},
{
event_filters: 'Event filters cannot be empty.',
id: 'Event filters cannot be empty.',
},
],
},
],
},
},
}),
})
expect(api.update).toBeCalledTimes(0)
})
describe('empty input errors', () => {
Object.entries(ROWS).forEach(([key, row]) => {
it(`${key} row missing all required fields`, async () => {
@@ -475,7 +553,10 @@ describe('cohortEditLogic', () => {
partial(
Object.fromEntries(
row.fields
.filter(({ fieldKey }) => !!fieldKey)
.filter(
({ fieldKey }) =>
!!fieldKey && fieldKey !== 'event_filters'
) // event_filters are optional
.map(({ fieldKey, type }) => [
fieldKey,
CRITERIA_VALIDATIONS[type](undefined),
@@ -530,6 +611,16 @@ describe('cohortEditLogic', () => {
})
it('duplicate group', async () => {
const expectedGroupValue = partial({
...mockCohort.filters.properties.values[0],
values: [
{
...(mockCohort.filters.properties.values[0] as CohortCriteriaGroupFilter).values[0],
explicit_datetime: '-30d',
},
],
}) // Backwards compatible processing adds explicit_datetime
await expectLogic(logic, () => {
logic.actions.duplicateFilter(0)
})
@@ -538,10 +629,7 @@ describe('cohortEditLogic', () => {
cohort: partial({
filters: {
properties: partial({
values: [
partial(mockCohort.filters.properties.values[0]),
partial(mockCohort.filters.properties.values[0]),
],
values: [expectedGroupValue, expectedGroupValue],
}),
},
}),
@@ -574,7 +662,17 @@ describe('cohortEditLogic', () => {
filters: {
properties: partial({
values: [
partial(mockCohort.filters.properties.values[0]),
partial({
...mockCohort.filters.properties.values[0],
values: [
{
...(
mockCohort.filters.properties.values[0] as CohortCriteriaGroupFilter
).values[0],
explicit_datetime: '-30d',
},
],
}), // Backwards compatible processing adds explicit_datetime
partial({
type: FilterLogicalOperator.Or,
values: [NEW_CRITERIA],

View File

@@ -1,5 +1,6 @@
import equal from 'fast-deep-equal'
import { DeepPartialMap, ValidationErrorType } from 'kea-forms'
import { isEmptyProperty } from 'lib/components/PropertyFilters/utils'
import { ENTITY_MATCH_TYPE, PROPERTY_MATCH_TYPE } from 'lib/constants'
import { areObjectValuesEmpty, calculateDays, isNumeric } from 'lib/utils'
import { BEHAVIORAL_TYPE_TO_LABEL, CRITERIA_VALIDATIONS, ROWS } from 'scenes/cohorts/CohortFilters/constants'
@@ -15,6 +16,7 @@ import {
ActionType,
AnyCohortCriteriaType,
AnyCohortGroupType,
AnyPropertyFilter,
BehavioralCohortType,
BehavioralEventType,
BehavioralLifecycleType,
@@ -22,6 +24,7 @@ import {
CohortCriteriaType,
CohortType,
FilterLogicalOperator,
PropertyFilterType,
PropertyOperator,
TimeUnitType,
} from '~/types'
@@ -272,6 +275,15 @@ export function validateGroup(
requiredFields = requiredFields.filter((f) => f.fieldKey !== 'value_property')
}
// Handle EventFilters separately, since these are optional
requiredFields = requiredFields.filter((f) => f.fieldKey !== 'event_filters')
const eventFilterError =
c?.event_filters &&
c.event_filters.length > 0 &&
c.event_filters.some((prop) => prop?.type !== PropertyFilterType.HogQL && isEmptyProperty(prop))
? CohortClientErrors.EmptyEventFilters
: undefined
const criteriaErrors = Object.fromEntries(
requiredFields.map(({ fieldKey, type }) => [
fieldKey,
@@ -284,13 +296,15 @@ export function validateGroup(
: CRITERIA_VALIDATIONS?.[type](c[fieldKey]),
])
)
const consolidatedErrors = Object.values(criteriaErrors)
const allErrors = { ...criteriaErrors, event_filters: eventFilterError }
const consolidatedErrors = Object.values(allErrors)
.filter((e) => !!e)
.join(' ')
return {
...(areObjectValuesEmpty(criteriaErrors) ? {} : { id: consolidatedErrors }),
...criteriaErrors,
...(areObjectValuesEmpty(allErrors) ? {} : { id: consolidatedErrors }),
...allErrors,
}
}),
}
@@ -361,7 +375,7 @@ export function determineFilterType(
export function resolveCohortFieldValue(
criteria: AnyCohortCriteriaType,
fieldKey: string
): string | number | boolean | null | undefined {
): string | number | boolean | null | undefined | AnyPropertyFilter[] {
// Resolve correct behavioral filter type
if (fieldKey === 'value') {
return criteriaToBehavioralFilterType(criteria)
@@ -452,6 +466,7 @@ export function criteriaToHumanSentence(
}
data.fields.forEach(({ type, fieldKey, defaultValue, hide }) => {
// TODO: This needs to be much nicer for all cohort criteria options
if (!hide) {
if (type === FilterType.Text) {
words.push(defaultValue)
@@ -461,6 +476,8 @@ export function criteriaToHumanSentence(
words.push(<pre>{cohortsById?.[value]?.name ?? `Cohort ${value}`}</pre>)
} else if (type === FilterType.EventsAndActions && typeof value === 'number') {
words.push(<pre>{actionsById?.[value]?.name ?? `Action ${value}`}</pre>)
} else if (type === FilterType.EventFilters && (criteria.event_filters?.length || 0) > 0) {
words.push(<pre>with filters</pre>)
} else {
words.push(<pre>{value}</pre>)
}

View File

@@ -1,6 +1,7 @@
import { actions, afterMount, connect, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea'
import { subscriptions } from 'kea-subscriptions'
import api from 'lib/api'
import { isEmptyProperty } from 'lib/components/PropertyFilters/utils'
import { TaxonomicFilterGroupType, TaxonomicFilterProps } from 'lib/components/TaxonomicFilter/types'
import { objectsEqual } from 'lib/utils'
@@ -310,11 +311,3 @@ export const featureFlagReleaseConditionsLogic = kea<featureFlagReleaseCondition
}
}),
])
function isEmptyProperty(property: AnyPropertyFilter): boolean {
return (
property.value === null ||
property.value === undefined ||
(Array.isArray(property.value) && property.value.length === 0)
)
}

View File

@@ -260,7 +260,6 @@ export const funnelPersonsModalLogic = kea<funnelPersonsModalLogicType>([
})
.filter(Boolean) as AnyPropertyFilter[]
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, propertyName, propertyValue] = correlation.event.event.split('::')
properties = [

View File

@@ -1076,6 +1076,7 @@ export interface CohortCriteriaType {
operator_value?: PropertyFilterValue
time_value?: number | string | null
time_interval?: TimeUnitType | null
explicit_datetime?: string | null
total_periods?: number | null
min_periods?: number | null
seq_event_type?: TaxonomicFilterGroupType | null
@@ -1084,6 +1085,7 @@ export interface CohortCriteriaType {
seq_time_interval?: TimeUnitType | null
negation?: boolean
value_property?: string | null // Transformed into 'value' for api calls
event_filters?: AnyPropertyFilter[] | null
}
export type EmptyCohortGroupType = Partial<CohortGroupType>

5
pnpm-lock.yaml generated
View File

@@ -1,4 +1,4 @@
lockfileVersion: '6.0'
lockfileVersion: '6.1'
settings:
autoInstallPeers: true
@@ -350,7 +350,7 @@ dependencies:
optionalDependencies:
fsevents:
specifier: ^2.3.2
version: 2.3.3
version: 2.3.2
devDependencies:
'@babel/core':
@@ -12825,7 +12825,6 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
dev: true
optional: true
/fsevents@2.3.3:

View File

@@ -24,7 +24,8 @@
(SELECT pdi.person_id AS person_id,
countIf(timestamp > now() - INTERVAL 1 day
AND timestamp < now()
AND event = '$pageview') > 0 AS performed_event_condition_1_level_level_0_level_1_level_0_0
AND event = '$pageview'
AND 1=1) > 0 AS performed_event_condition_1_level_level_0_level_1_level_0_0
FROM events e
INNER JOIN
(SELECT distinct_id,

View File

@@ -1739,7 +1739,7 @@
# ---
# name: TestFeatureFlag.test_creating_static_cohort.14
'''
/* user_id:198 celery:posthog.tasks.calculate_cohort.insert_cohort_from_feature_flag */
/* user_id:199 celery:posthog.tasks.calculate_cohort.insert_cohort_from_feature_flag */
SELECT count(DISTINCT person_id)
FROM person_static_cohort
WHERE team_id = 2

View File

@@ -157,7 +157,7 @@
# ---
# name: TestQuery.test_full_hogql_query_async
'''
/* user_id:464 celery:posthog.tasks.tasks.process_query_task */
/* user_id:465 celery:posthog.tasks.tasks.process_query_task */
SELECT events.uuid AS uuid,
events.event AS event,
events.properties AS properties,

View File

@@ -695,6 +695,84 @@ email@example.org,
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(2, len(response.json()["results"]))
@patch("posthog.api.cohort.report_user_action")
def test_calculating_with_new_cohort_event_filters(self, patch_capture):
_create_person(
distinct_ids=["p1"],
team_id=self.team.pk,
properties={"$some_prop": "something"},
)
_create_event(
team=self.team,
event="$pageview",
distinct_id="p1",
properties={"$filter_prop": "something"},
timestamp=datetime.now() - timedelta(hours=12),
)
_create_person(
distinct_ids=["p2"],
team_id=self.team.pk,
properties={"$some_prop": "not it"},
)
_create_event(
team=self.team,
event="$pageview",
distinct_id="p2",
properties={"$filter_prop": "something2"},
timestamp=datetime.now() - timedelta(hours=12),
)
_create_person(
distinct_ids=["p3"],
team_id=self.team.pk,
properties={"$some_prop": "something"},
)
_create_event(
team=self.team,
event="$pageview",
distinct_id="p3",
properties={"$filter_prop": "something2"},
timestamp=datetime.now() - timedelta(days=12),
)
flush_persons_and_events()
response = self.client.post(
f"/api/projects/{self.team.id}/cohorts",
data={
"name": "cohort A",
"filters": {
"properties": {
"type": "OR",
"values": [
{
"key": "$pageview",
"event_type": "events",
"time_value": 1,
"time_interval": "day",
"value": "performed_event",
"type": "behavioral",
"event_filters": [
{"key": "$filter_prop", "value": "something", "operator": "exact", "type": "event"}
],
},
],
}
},
},
)
self.assertEqual(response.status_code, 201, response.content)
cohort_id = response.json()["id"]
while response.json()["is_calculating"]:
response = self.client.get(f"/api/projects/{self.team.id}/cohorts/{cohort_id}")
response = self.client.get(f"/api/projects/{self.team.id}/cohorts/{cohort_id}/persons/?cohort={cohort_id}")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(1, len(response.json()["results"]))
@patch("posthog.api.cohort.report_user_action")
def test_creating_update_and_calculating_with_new_cohort_query(self, patch_capture):
_create_person(

View File

@@ -103,20 +103,28 @@ VALIDATE_PROP_TYPES = {
"hogql": ["key"],
}
VALIDATE_CONDITIONAL_BEHAVIORAL_PROP_TYPES = {
BehavioralPropertyType.PERFORMED_EVENT: [
{"time_value", "time_interval"},
{"explicit_datetime"},
],
BehavioralPropertyType.PERFORMED_EVENT_MULTIPLE: [
{"time_value", "time_interval"},
{"explicit_datetime"},
],
}
VALIDATE_BEHAVIORAL_PROP_TYPES = {
BehavioralPropertyType.PERFORMED_EVENT: [
"key",
"value",
"event_type",
"time_value",
"time_interval",
],
BehavioralPropertyType.PERFORMED_EVENT_MULTIPLE: [
"key",
"value",
"event_type",
"time_value",
"time_interval",
"operator_value",
],
BehavioralPropertyType.PERFORMED_EVENT_FIRST_TIME: [
@@ -175,8 +183,11 @@ class Property:
type: PropertyType
group_type_index: Optional[GroupTypeIndex]
# All these property keys are used in cohorts.
# Type of `key`
event_type: Optional[Literal["events", "actions"]]
# Any extra filters on the event
event_filters: Optional[List["Property"]]
# Query people who did event '$pageview' 20 times in the last 30 days
# translates into:
# key = '$pageview', value = 'performed_event_multiple'
@@ -185,6 +196,8 @@ class Property:
operator_value: Optional[int]
time_value: Optional[int]
time_interval: Optional[OperatorInterval]
# Alternative to time_value & time_interval, for explicit date bound rather than relative
explicit_datetime: Optional[str]
# Query people who did event '$pageview' in last week, but not in the previous 30 days
# translates into:
# key = '$pageview', value = 'restarted_performing_event'
@@ -218,6 +231,7 @@ class Property:
operator_value: Optional[int] = None,
time_value: Optional[int] = None,
time_interval: Optional[OperatorInterval] = None,
explicit_datetime: Optional[str] = None,
total_periods: Optional[int] = None,
min_periods: Optional[int] = None,
seq_event_type: Optional[str] = None,
@@ -225,6 +239,7 @@ class Property:
seq_time_value: Optional[int] = None,
seq_time_interval: Optional[OperatorInterval] = None,
negation: Optional[bool] = None,
event_filters: Optional[List["Property"]] = None,
**kwargs,
) -> None:
self.key = key
@@ -235,6 +250,7 @@ class Property:
self.operator_value = operator_value
self.time_value = time_value
self.time_interval = time_interval
self.explicit_datetime = explicit_datetime
self.total_periods = total_periods
self.min_periods = min_periods
self.seq_event_type = seq_event_type
@@ -242,6 +258,7 @@ class Property:
self.seq_time_value = seq_time_value
self.seq_time_interval = seq_time_interval
self.negation = None if negation is None else str_to_bool(negation)
self.event_filters = event_filters
if value is None and self.operator in ["is_set", "is_not_set"]:
self.value = self.operator
@@ -264,6 +281,19 @@ class Property:
if getattr(self, attr, None) is None:
raise ValueError(f"Missing required attr {attr} for property type {self.type}::{self.value}")
if cast(BehavioralPropertyType, self.value) in VALIDATE_CONDITIONAL_BEHAVIORAL_PROP_TYPES:
matches_attr_list = False
condition_list = VALIDATE_CONDITIONAL_BEHAVIORAL_PROP_TYPES[cast(BehavioralPropertyType, self.value)]
for attr_list in condition_list:
if all(getattr(self, attr, None) is not None for attr in attr_list):
matches_attr_list = True
break
if not matches_attr_list:
raise ValueError(
f"Missing required parameters, atleast one of values ({'), ('.join([' & '.join(condition) for condition in condition_list])}) for property type {self.type}::{self.value}"
)
def __repr__(self):
params_repr = ", ".join(f"{key}={repr(value)}" for key, value in self.to_dict().items())
return f"Property({params_repr})"

View File

@@ -18,10 +18,10 @@ from posthog.models.property import (
PropertyGroup,
PropertyName,
)
from posthog.models.property.util import prop_filter_json_extract
from posthog.models.property.util import prop_filter_json_extract, parse_prop_grouped_clauses
from posthog.queries.event_query import EventQuery
from posthog.queries.util import PersonPropertiesMode
from posthog.utils import PersonOnEventsMode
from posthog.utils import PersonOnEventsMode, relative_date_parse
Relative_Date = Tuple[int, OperatorInterval]
Event = Tuple[str, Union[str, int]]
@@ -458,24 +458,57 @@ class FOSSCohortQuery(EventQuery):
query, params = format_static_cohort_query(cohort, idx, prepend)
return f"id {'NOT' if prop.negation else ''} IN ({query})", params
def _get_entity_event_filters(self, prop: Property, prepend: str, idx: int) -> Tuple[str, Dict[str, Any]]:
params: Dict[str, Any] = {}
if prop.event_filters:
prop_query, prop_params = parse_prop_grouped_clauses(
team_id=self._team_id,
property_group=Filter(data={"properties": prop.event_filters}).property_groups,
prepend=f"{prepend}_{idx}_event_filters",
# should be no person properties in these filters, but if there are, use
# the inefficient person subquery default mode
person_properties_mode=PersonPropertiesMode.USING_SUBQUERY,
hogql_context=self._filter.hogql_context,
)
params.update(prop_params)
return prop_query, params
else:
return "AND 1=1", {}
def _get_entity_datetime_filters(self, prop: Property, prepend: str, idx: int) -> Tuple[str, Dict[str, Any]]:
if prop.explicit_datetime:
# Explicit datetime filter, can be a relative or absolute date, follows same convention
# as all analytics datetime filters
# TODO: Confirm if we need to validate the datetime
date_param = f"{prepend}_explicit_date_{idx}"
target_datetime = relative_date_parse(prop.explicit_datetime, self._team.timezone_info)
return f"timestamp > %({date_param})s", {f"{date_param}": target_datetime}
else:
date_value = parse_and_validate_positive_integer(prop.time_value, "time_value")
date_interval = validate_interval(prop.time_interval)
date_param = f"{prepend}_date_{idx}"
self._check_earliest_date((date_value, date_interval))
return f"timestamp > now() - INTERVAL %({date_param})s {date_interval}", {f"{date_param}": date_value}
def get_performed_event_condition(self, prop: Property, prepend: str, idx: int) -> Tuple[str, Dict[str, Any]]:
event = (prop.event_type, prop.key)
column_name = f"performed_event_condition_{prepend}_{idx}"
entity_query, entity_params = self._get_entity(event, prepend, idx)
date_value = parse_and_validate_positive_integer(prop.time_value, "time_value")
date_interval = validate_interval(prop.time_interval)
date_param = f"{prepend}_date_{idx}"
entity_filters, entity_filters_params = self._get_entity_event_filters(prop, prepend, idx)
date_filter, date_params = self._get_entity_datetime_filters(prop, prepend, idx)
self._check_earliest_date((date_value, date_interval))
field = f"countIf(timestamp > now() - INTERVAL %({date_param})s {date_interval} AND timestamp < now() AND {entity_query}) > 0 AS {column_name}"
field = f"countIf({date_filter} AND timestamp < now() AND {entity_query} {entity_filters}) > 0 AS {column_name}"
self._fields.append(field)
# Negation is handled in the where clause to ensure the right result if a full join occurs where the joined person did not perform the event
return f"{'NOT' if prop.negation else ''} {column_name}", {
f"{date_param}": date_value,
**date_params,
**entity_params,
**entity_filters_params,
}
def get_performed_event_multiple(self, prop: Property, prepend: str, idx: int) -> Tuple[str, Dict[str, Any]]:
@@ -483,15 +516,13 @@ class FOSSCohortQuery(EventQuery):
column_name = f"performed_event_multiple_condition_{prepend}_{idx}"
entity_query, entity_params = self._get_entity(event, prepend, idx)
entity_filters, entity_filters_params = self._get_entity_event_filters(prop, prepend, idx)
date_filter, date_params = self._get_entity_datetime_filters(prop, prepend, idx)
count = parse_and_validate_positive_integer(prop.operator_value, "operator_value")
date_value = parse_and_validate_positive_integer(prop.time_value, "time_value")
date_interval = validate_interval(prop.time_interval)
date_param = f"{prepend}_date_{idx}"
operator_value_param = f"{prepend}_operator_value_{idx}"
self._check_earliest_date((date_value, date_interval))
field = f"countIf(timestamp > now() - INTERVAL %({date_param})s {date_interval} AND timestamp < now() AND {entity_query}) {get_count_operator(prop.operator)} %({operator_value_param})s AS {column_name}"
field = f"countIf({date_filter} AND timestamp < now() AND {entity_query} {entity_filters}) {get_count_operator(prop.operator)} %({operator_value_param})s AS {column_name}"
self._fields.append(field)
# Negation is handled in the where clause to ensure the right result if a full join occurs where the joined person did not perform the event
@@ -499,8 +530,9 @@ class FOSSCohortQuery(EventQuery):
f"{'NOT' if prop.negation else ''} {column_name}",
{
f"{operator_value_param}": count,
f"{date_param}": date_value,
**date_params,
**entity_params,
**entity_filters_params,
},
)