diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx
index b750e3558b2fc..ba97efe09883c 100644
--- a/frontend/src/lib/constants.tsx
+++ b/frontend/src/lib/constants.tsx
@@ -170,6 +170,7 @@ export const FEATURE_FLAGS = {
SURVEYS_SITE_APP_DEPRECATION: 'surveys-site-app-deprecation', // owner: @neilkakkar
SURVEYS_MULTIPLE_QUESTIONS: 'surveys-multiple-questions', // owner: @liyiy
CONSOLE_RECORDING_SEARCH: 'console-recording-search', // owner: #team-monitoring
+ PERSONS_HOGQL_QUERY: 'persons-hogql-query', // owner: @mariusandra
} as const
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]
diff --git a/frontend/src/queries/nodes/DataNode/LoadNext.tsx b/frontend/src/queries/nodes/DataNode/LoadNext.tsx
index 72c9f73c9c372..d81fea67ff685 100644
--- a/frontend/src/queries/nodes/DataNode/LoadNext.tsx
+++ b/frontend/src/queries/nodes/DataNode/LoadNext.tsx
@@ -2,7 +2,7 @@ import { useActions, useValues } from 'kea'
import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { DataNode } from '~/queries/schema'
-import { isPersonsNode } from '~/queries/utils'
+import { isPersonsNode, isPersonsQuery } from '~/queries/utils'
interface LoadNextProps {
query: DataNode
@@ -14,7 +14,7 @@ export function LoadNext({ query }: LoadNextProps): JSX.Element {
return (
- Load more {isPersonsNode(query) ? 'people' : 'events'}
+ Load more {isPersonsNode(query) || isPersonsQuery(query) ? 'people' : 'events'}
)
diff --git a/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts b/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts
index e9c8aa4d70842..3feb758e5cf80 100644
--- a/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts
+++ b/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts
@@ -15,9 +15,25 @@ import {
} from 'kea'
import { loaders } from 'kea-loaders'
import type { dataNodeLogicType } from './dataNodeLogicType'
-import { AnyResponseType, DataNode, EventsQuery, EventsQueryResponse, PersonsNode, QueryTiming } from '~/queries/schema'
+import {
+ AnyResponseType,
+ DataNode,
+ EventsQuery,
+ EventsQueryResponse,
+ PersonsNode,
+ PersonsQuery,
+ PersonsQueryResponse,
+ QueryResponse,
+ QueryTiming,
+} from '~/queries/schema'
import { query } from '~/queries/query'
-import { isInsightQueryNode, isEventsQuery, isPersonsNode, isQueryWithHogQLSupport } from '~/queries/utils'
+import {
+ isInsightQueryNode,
+ isEventsQuery,
+ isPersonsNode,
+ isQueryWithHogQLSupport,
+ isPersonsQuery,
+} from '~/queries/utils'
import { subscriptions } from 'kea-subscriptions'
import { objectsEqual, shouldCancelQuery, uuid } from 'lib/utils'
import clsx from 'clsx'
@@ -182,13 +198,13 @@ export const dataNodeLogic = kea([
}
// TODO: unify when we use the same backend endpoint for both
const now = performance.now()
- if (isEventsQuery(props.query)) {
+ if (isEventsQuery(props.query) || isPersonsQuery(props.query)) {
const newResponse = (await query(values.nextQuery)) ?? null
actions.setElapsedTime(performance.now() - now)
- const eventQueryResponse = values.response as EventsQueryResponse
+ const queryResponse = values.response as QueryResponse
return {
- ...eventQueryResponse,
- results: [...(eventQueryResponse?.results ?? []), ...(newResponse?.results ?? [])],
+ ...queryResponse,
+ results: [...(queryResponse?.results ?? []), ...(newResponse?.results ?? [])],
hasMore: newResponse?.hasMore,
}
} else if (isPersonsNode(props.query)) {
@@ -358,11 +374,11 @@ export const dataNodeLogic = kea([
return null
}
- if (isEventsQuery(query) && !responseError && !dataLoading) {
- if ((response as EventsQuery['response'])?.hasMore) {
+ if ((isEventsQuery(query) || isPersonsQuery(query)) && !responseError && !dataLoading) {
+ if ((response as EventsQueryResponse | PersonsQueryResponse)?.hasMore) {
const sortKey = query.orderBy?.[0] ?? 'timestamp DESC'
- const typedResults = (response as EventsQuery['response'])?.results
- if (sortKey === 'timestamp DESC') {
+ const typedResults = (response as QueryResponse)?.results
+ if (isEventsQuery(query) && sortKey === 'timestamp DESC') {
const sortColumnIndex = query.select
.map((hql) => removeExpressionComment(hql))
.indexOf('timestamp')
@@ -374,11 +390,10 @@ export const dataNodeLogic = kea([
}
}
} else {
- const newQuery: EventsQuery = {
+ return {
...query,
offset: typedResults?.length || 0,
- }
- return newQuery
+ } as EventsQuery | PersonsQuery
}
}
}
diff --git a/frontend/src/queries/nodes/DataTable/DataTable.tsx b/frontend/src/queries/nodes/DataTable/DataTable.tsx
index 848a00cc47177..002c9b47df87c 100644
--- a/frontend/src/queries/nodes/DataTable/DataTable.tsx
+++ b/frontend/src/queries/nodes/DataTable/DataTable.tsx
@@ -6,6 +6,7 @@ import {
EventsQuery,
HogQLQuery,
PersonsNode,
+ PersonsQuery,
QueryContext,
} from '~/queries/schema'
import { useCallback, useState } from 'react'
@@ -28,7 +29,14 @@ import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
import clsx from 'clsx'
import { SessionPlayerModal } from 'scenes/session-recordings/player/modal/SessionPlayerModal'
import { OpenEditorButton } from '~/queries/nodes/Node/OpenEditorButton'
-import { isEventsQuery, isHogQlAggregation, isHogQLQuery, taxonomicEventFilterToHogQL } from '~/queries/utils'
+import {
+ isEventsQuery,
+ isHogQlAggregation,
+ isHogQLQuery,
+ isPersonsQuery,
+ taxonomicEventFilterToHogQL,
+ taxonomicPersonFilterToHogQL,
+} from '~/queries/utils'
import { PersonPropertyFilters } from '~/queries/nodes/PersonsNode/PersonPropertyFilters'
import { PersonsSearch } from '~/queries/nodes/PersonsNode/PersonsSearch'
import { PersonDeleteModal } from 'scenes/persons/PersonDeleteModal'
@@ -37,7 +45,11 @@ import { DateRange } from '~/queries/nodes/DataNode/DateRange'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { TaxonomicPopover } from 'lib/components/TaxonomicPopover/TaxonomicPopover'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
-import { extractExpressionComment, removeExpressionComment } from '~/queries/nodes/DataTable/utils'
+import {
+ extractExpressionComment,
+ getDataNodeDefaultColumns,
+ removeExpressionComment,
+} from '~/queries/nodes/DataTable/utils'
import { InsightEmptyState, InsightErrorState } from 'scenes/insights/EmptyStates'
import { EventType } from '~/types'
import { SavedQueries } from '~/queries/nodes/DataTable/SavedQueries'
@@ -61,6 +73,7 @@ const eventGroupTypes = [
TaxonomicFilterGroupType.PersonProperties,
TaxonomicFilterGroupType.EventFeatureFlags,
]
+const personGroupTypes = [TaxonomicFilterGroupType.HogQLExpression, TaxonomicFilterGroupType.PersonProperties]
let uniqueNode = 0
@@ -121,8 +134,8 @@ export function DataTable({ uniqueKey, query, setQuery, context, cachedResults }
? columnsInResponse ?? columnsInQuery
: columnsInQuery
- const groupTypes = eventGroupTypes
- const hogQLTable = 'events'
+ const groupTypes = isPersonsQuery(query.source) ? personGroupTypes : eventGroupTypes
+ const hogQLTable = isPersonsQuery(query.source) ? 'persons' : 'events'
const lemonColumns: LemonTableColumn[] = [
...columnsInLemonTable.map((key, index) => ({
@@ -165,11 +178,14 @@ export function DataTable({ uniqueKey, query, setQuery, context, cachedResults }
type="tertiary"
fullWidth
onChange={(v, g) => {
- const hogQl = taxonomicEventFilterToHogQL(g, v)
+ const hogQl = isPersonsQuery(query.source)
+ ? taxonomicPersonFilterToHogQL(g, v)
+ : taxonomicEventFilterToHogQL(g, v)
if (setQuery && hogQl && sourceFeatures.has(QueryFeature.selectAndOrderByColumns)) {
// Typecasting to a query type with select and order_by fields.
// The actual query may or may not be an events query.
const source = query.source as EventsQuery
+ const columns = getDataNodeDefaultColumns(source)
const isAggregation = isHogQlAggregation(hogQl)
const isOrderBy = source.orderBy?.[0] === key
const isDescOrderBy = source.orderBy?.[0] === `${key} DESC`
@@ -177,9 +193,11 @@ export function DataTable({ uniqueKey, query, setQuery, context, cachedResults }
...query,
source: {
...source,
- select: source.select
+ select: columns
.map((s, i) => (i === index ? hogQl : s))
- .filter((c) => (isAggregation ? c !== '*' : true)),
+ .filter((c) =>
+ isAggregation ? c !== '*' && c !== 'person.$delete' : true
+ ),
orderBy:
isOrderBy || isDescOrderBy
? [isDescOrderBy ? `${hogQl} DESC` : hogQl]
@@ -190,7 +208,7 @@ export function DataTable({ uniqueKey, query, setQuery, context, cachedResults }
}}
/>
- {canSort ? (
+ {canSort && key !== 'person.$delete' ? (
<>
{
- const hogQl = taxonomicEventFilterToHogQL(g, v)
+ const hogQl = isPersonsQuery(query.source)
+ ? taxonomicPersonFilterToHogQL(g, v)
+ : taxonomicEventFilterToHogQL(g, v)
if (setQuery && hogQl && sourceFeatures.has(QueryFeature.selectAndOrderByColumns)) {
const isAggregation = isHogQlAggregation(hogQl)
const source = query.source as EventsQuery
+ const columns = getDataNodeDefaultColumns(source)
setQuery({
...query,
source: {
...source,
- select: [
- ...(source.select || []).slice(0, index),
- hogQl,
- ...(source.select || []).slice(index),
- ].filter((c) => (isAggregation ? c !== '*' : true)),
- } as EventsQuery,
+ select: [...columns.slice(0, index), hogQl, ...columns.slice(index)].filter(
+ (c) => (isAggregation ? c !== '*' && c !== 'person.$delete' : true)
+ ),
+ } as EventsQuery | PersonsQuery,
})
}
}}
@@ -269,20 +288,25 @@ export function DataTable({ uniqueKey, query, setQuery, context, cachedResults }
type="tertiary"
fullWidth
onChange={(v, g) => {
- const hogQl = taxonomicEventFilterToHogQL(g, v)
+ const hogQl = isPersonsQuery(query.source)
+ ? taxonomicPersonFilterToHogQL(g, v)
+ : taxonomicEventFilterToHogQL(g, v)
if (setQuery && hogQl && sourceFeatures.has(QueryFeature.selectAndOrderByColumns)) {
const isAggregation = isHogQlAggregation(hogQl)
const source = query.source as EventsQuery
+ const columns = getDataNodeDefaultColumns(source)
setQuery?.({
...query,
source: {
...source,
select: [
- ...(source.select || []).slice(0, index + 1),
+ ...columns.slice(0, index + 1),
hogQl,
- ...(source.select || []).slice(index + 1),
- ].filter((c) => (isAggregation ? c !== '*' : true)),
- } as EventsQuery,
+ ...columns.slice(index + 1),
+ ].filter((c) =>
+ isAggregation ? c !== '*' && c !== 'person.$delete' : true
+ ),
+ } as EventsQuery | PersonsQuery,
})
}
}}
@@ -342,7 +366,8 @@ export function DataTable({ uniqueKey, query, setQuery, context, cachedResults }
].filter((column) => !query.hiddenColumns?.includes(column.dataIndex) && column.dataIndex !== '*')
const setQuerySource = useCallback(
- (source: EventsNode | EventsQuery | PersonsNode | HogQLQuery) => setQuery?.({ ...query, source }),
+ (source: EventsNode | EventsQuery | PersonsNode | PersonsQuery | HogQLQuery) =>
+ setQuery?.({ ...query, source }),
[setQuery]
)
@@ -388,6 +413,14 @@ export function DataTable({ uniqueKey, query, setQuery, context, cachedResults }
const showSecondRow = !isReadOnly && (secondRowLeft.length > 0 || secondRowRight.length > 0)
const inlineEditorButtonOnRow = showFirstRow ? 1 : showSecondRow ? 2 : 0
+ if (showOpenEditorButton && !isReadOnly) {
+ if (inlineEditorButtonOnRow === 1) {
+ firstRowRight.push()
+ } else if (inlineEditorButtonOnRow === 2) {
+ secondRowRight.push()
+ }
+ }
+
return (
@@ -400,9 +433,6 @@ export function DataTable({ uniqueKey, query, setQuery, context, cachedResults }
{firstRowLeft}
{firstRowLeft.length > 0 && firstRowRight.length > 0 ? : null}
{firstRowRight}
- {showOpenEditorButton && inlineEditorButtonOnRow === 1 && !isReadOnly ? (
-
- ) : null}
)}
{showFirstRow && showSecondRow && }
@@ -411,9 +441,6 @@ export function DataTable({ uniqueKey, query, setQuery, context, cachedResults }
{secondRowLeft}
{secondRowLeft.length > 0 && secondRowRight.length > 0 ? : null}
{secondRowRight}
- {showOpenEditorButton && inlineEditorButtonOnRow === 2 && !isReadOnly ? (
-
- ) : null}
)}
{showOpenEditorButton && inlineEditorButtonOnRow === 0 && !isReadOnly ? (
diff --git a/frontend/src/queries/nodes/DataTable/queryFeatures.ts b/frontend/src/queries/nodes/DataTable/queryFeatures.ts
index 7416b323d0418..4813def3438bd 100644
--- a/frontend/src/queries/nodes/DataTable/queryFeatures.ts
+++ b/frontend/src/queries/nodes/DataTable/queryFeatures.ts
@@ -2,6 +2,7 @@ import {
isEventsQuery,
isHogQLQuery,
isPersonsNode,
+ isPersonsQuery,
isWebOverviewStatsQuery,
isWebTopClicksQuery,
isWebTopPagesQuery,
@@ -43,9 +44,15 @@ export function getQueryFeatures(query: Node): Set {
features.add(QueryFeature.selectAndOrderByColumns)
}
- if (isPersonsNode(query)) {
+ if (isPersonsNode(query) || isPersonsQuery(query)) {
features.add(QueryFeature.personPropertyFilters)
features.add(QueryFeature.personsSearch)
+
+ if (isPersonsQuery(query)) {
+ features.add(QueryFeature.selectAndOrderByColumns)
+ features.add(QueryFeature.columnsInResponse)
+ features.add(QueryFeature.resultIsArrayOfArrays)
+ }
}
if (
diff --git a/frontend/src/queries/nodes/DataTable/renderColumn.tsx b/frontend/src/queries/nodes/DataTable/renderColumn.tsx
index 88b749007301e..f502ec138cd74 100644
--- a/frontend/src/queries/nodes/DataTable/renderColumn.tsx
+++ b/frontend/src/queries/nodes/DataTable/renderColumn.tsx
@@ -7,7 +7,14 @@ import { Property } from 'lib/components/Property'
import { urls } from 'scenes/urls'
import { PersonDisplay } from 'scenes/persons/PersonDisplay'
import { DataTableNode, EventsQueryPersonColumn, HasPropertiesNode, QueryContext } from '~/queries/schema'
-import { isEventsQuery, isHogQLQuery, isPersonsNode, isTimeToSeeDataSessionsQuery, trimQuotes } from '~/queries/utils'
+import {
+ isEventsQuery,
+ isHogQLQuery,
+ isPersonsNode,
+ isPersonsQuery,
+ isTimeToSeeDataSessionsQuery,
+ trimQuotes,
+} from '~/queries/utils'
import { combineUrl, router } from 'kea-router'
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
import { DeletePersonButton } from '~/queries/nodes/PersonsNode/DeletePersonButton'
@@ -209,18 +216,25 @@ export function renderColumn(
)
- } else if (key === 'person.$delete' && isPersonsNode(query.source)) {
+ } else if (key === 'person' && isPersonsQuery(query.source)) {
+ const personRecord = value as PersonType
+ return (
+
+
+
+ )
+ } else if (key === 'person.$delete' && (isPersonsNode(query.source) || isPersonsQuery(query.source))) {
const personRecord = record as PersonType
return
} else if (key.startsWith('context.columns.')) {
const Component = context?.columns?.[trimQuotes(key.substring(16))]?.render
return Component ? : ''
- } else if (key === 'id' && isPersonsNode(query.source)) {
+ } else if (key === 'id' && (isPersonsNode(query.source) || isPersonsQuery(query.source))) {
return (
{String(value)}
diff --git a/frontend/src/queries/nodes/DataTable/renderColumnMeta.tsx b/frontend/src/queries/nodes/DataTable/renderColumnMeta.tsx
index fbc905249c3b3..9b0625d82fd17 100644
--- a/frontend/src/queries/nodes/DataTable/renderColumnMeta.tsx
+++ b/frontend/src/queries/nodes/DataTable/renderColumnMeta.tsx
@@ -1,7 +1,7 @@
import { PropertyFilterType } from '~/types'
import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo'
-import { QueryContext, DataTableNode } from '~/queries/schema'
-import { isEventsQuery, isHogQLQuery, trimQuotes } from '~/queries/utils'
+import { QueryContext, DataTableNode, EventsQuery } from '~/queries/schema'
+import { isHogQLQuery, trimQuotes } from '~/queries/utils'
import { extractExpressionComment } from '~/queries/nodes/DataTable/utils'
import { SortingIndicator } from 'lib/lemon-ui/LemonTable/sorting'
import { getQueryFeatures, QueryFeature } from '~/queries/nodes/DataTable/queryFeatures'
@@ -30,6 +30,7 @@ export function renderColumnMeta(key: string, query: DataTableNode, context?: Qu
} else if (key === 'person') {
title = 'Person'
} else if (key.startsWith('properties.')) {
+ // NOTE: Sometimes these are event, sometimes person properties. We use PropertyFilterType.Event for both.
title =
} else if (key.startsWith('context.columns.')) {
const column = trimQuotes(key.substring(16))
@@ -44,8 +45,10 @@ export function renderColumnMeta(key: string, query: DataTableNode, context?: Qu
title = queryFeatures.has(QueryFeature.selectAndOrderByColumns) ? extractExpressionComment(key) : key
}
- if (isEventsQuery(query.source) && !query.allowSorting) {
- const sortKey = isEventsQuery(query.source) ? query.source?.orderBy?.[0] : null
+ if (queryFeatures.has(QueryFeature.selectAndOrderByColumns) && !query.allowSorting) {
+ const sortKey = queryFeatures.has(QueryFeature.selectAndOrderByColumns)
+ ? (query.source as EventsQuery)?.orderBy?.[0]
+ : null
const sortOrder = key === sortKey ? 1 : `-${key}` === sortKey ? -1 : undefined
if (sortOrder) {
title = (
diff --git a/frontend/src/queries/nodes/DataTable/utils.ts b/frontend/src/queries/nodes/DataTable/utils.ts
index 3464cbc2e1e71..94c196c2afe07 100644
--- a/frontend/src/queries/nodes/DataTable/utils.ts
+++ b/frontend/src/queries/nodes/DataTable/utils.ts
@@ -1,5 +1,5 @@
-import { DataNode, DataTableNode, HogQLExpression, NodeKind } from '~/queries/schema'
-import { isEventsQuery } from '~/queries/utils'
+import { DataNode, DataTableNode, EventsQuery, HogQLExpression, NodeKind } from '~/queries/schema'
+import { getQueryFeatures, QueryFeature } from '~/queries/nodes/DataTable/queryFeatures'
export const defaultDataTableEventColumns: HogQLExpression[] = [
'*',
@@ -13,7 +13,7 @@ export const defaultDataTableEventColumns: HogQLExpression[] = [
export const defaultDataTablePersonColumns: HogQLExpression[] = ['person', 'id', 'created_at', 'person.$delete']
export function defaultDataTableColumns(kind: NodeKind): HogQLExpression[] {
- return kind === NodeKind.PersonsNode
+ return kind === NodeKind.PersonsNode || kind === NodeKind.PersonsQuery
? defaultDataTablePersonColumns
: kind === NodeKind.EventsQuery
? defaultDataTableEventColumns
@@ -23,10 +23,14 @@ export function defaultDataTableColumns(kind: NodeKind): HogQLExpression[] {
}
export function getDataNodeDefaultColumns(source: DataNode): HogQLExpression[] {
- return (
- (isEventsQuery(source) && Array.isArray(source.select) && source.select.length > 0 ? source.select : null) ??
- defaultDataTableColumns(source.kind)
- )
+ if (
+ getQueryFeatures(source).has(QueryFeature.selectAndOrderByColumns) &&
+ Array.isArray((source as EventsQuery).select) &&
+ (source as EventsQuery).select.length > 0
+ ) {
+ return (source as EventsQuery).select
+ }
+ return defaultDataTableColumns(source.kind)
}
export function getColumnsForQuery(query: DataTableNode): HogQLExpression[] {
diff --git a/frontend/src/queries/nodes/PersonsNode/PersonPropertyFilters.tsx b/frontend/src/queries/nodes/PersonsNode/PersonPropertyFilters.tsx
index d0dc26dce6a86..e99c2c68b7ffd 100644
--- a/frontend/src/queries/nodes/PersonsNode/PersonPropertyFilters.tsx
+++ b/frontend/src/queries/nodes/PersonsNode/PersonPropertyFilters.tsx
@@ -1,12 +1,13 @@
-import { PersonsNode } from '~/queries/schema'
+import { PersonsNode, PersonsQuery } from '~/queries/schema'
import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters'
-import { AnyPropertyFilter } from '~/types'
+import { PersonPropertyFilter } from '~/types'
import { useState } from 'react'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
+import { isPersonsQuery } from '~/queries/utils'
interface PersonPropertyFiltersProps {
- query: PersonsNode
- setQuery?: (query: PersonsNode) => void
+ query: PersonsNode | PersonsQuery
+ setQuery?: (query: PersonsNode | PersonsQuery) => void
}
let uniqueNode = 0
@@ -15,9 +16,22 @@ export function PersonPropertyFilters({ query, setQuery }: PersonPropertyFilters
return !query.properties || Array.isArray(query.properties) ? (
setQuery?.({ ...query, properties: value })}
+ onChange={(value) => {
+ setQuery?.({
+ ...query,
+ properties: value as PersonPropertyFilter[],
+ })
+ }}
pageKey={`PersonPropertyFilters.${id}`}
- taxonomicGroupTypes={[TaxonomicFilterGroupType.PersonProperties]}
+ taxonomicGroupTypes={
+ isPersonsQuery(query)
+ ? [
+ TaxonomicFilterGroupType.PersonProperties,
+ TaxonomicFilterGroupType.Cohorts,
+ TaxonomicFilterGroupType.HogQLExpression,
+ ]
+ : [TaxonomicFilterGroupType.PersonProperties]
+ }
hogQLTable="persons"
style={{ marginBottom: 0, marginTop: 0 }}
/>
diff --git a/frontend/src/queries/nodes/PersonsNode/PersonsSearch.tsx b/frontend/src/queries/nodes/PersonsNode/PersonsSearch.tsx
index 30a5fc36f4b9c..a190d1b4434b6 100644
--- a/frontend/src/queries/nodes/PersonsNode/PersonsSearch.tsx
+++ b/frontend/src/queries/nodes/PersonsNode/PersonsSearch.tsx
@@ -1,16 +1,16 @@
-import { PersonsNode } from '~/queries/schema'
+import { PersonsNode, PersonsQuery } from '~/queries/schema'
import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput'
import { IconInfo } from 'lib/lemon-ui/icons'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { useDebouncedQuery } from '~/queries/hooks/useDebouncedQuery'
interface PersonSearchProps {
- query: PersonsNode
- setQuery?: (query: PersonsNode) => void
+ query: PersonsNode | PersonsQuery
+ setQuery?: (query: PersonsNode | PersonsQuery) => void
}
export function PersonsSearch({ query, setQuery }: PersonSearchProps): JSX.Element {
- const { value, onChange } = useDebouncedQuery(
+ const { value, onChange } = useDebouncedQuery(
query,
setQuery,
(query) => query.search || '',
diff --git a/frontend/src/queries/query.ts b/frontend/src/queries/query.ts
index bd2e67b573186..ea3ccd5476dfa 100644
--- a/frontend/src/queries/query.ts
+++ b/frontend/src/queries/query.ts
@@ -1,5 +1,5 @@
import posthog from 'posthog-js'
-import { DataNode, HogQLQueryResponse, PersonsNode } from './schema'
+import { DataNode, HogQLQuery, HogQLQueryResponse, NodeKind, PersonsNode } from './schema'
import {
isInsightQueryNode,
isEventsQuery,
@@ -11,6 +11,7 @@ import {
isHogQLQuery,
isInsightVizNode,
isQueryWithHogQLSupport,
+ isPersonsQuery,
} from './utils'
import api, { ApiMethodOptions } from 'lib/api'
import { getCurrentTeamId } from 'lib/utils/logics'
@@ -43,7 +44,7 @@ export function queryExportContext(
return queryExportContext(query.source, methodOptions, refresh)
} else if (isDataTableNode(query)) {
return queryExportContext(query.source, methodOptions, refresh)
- } else if (isEventsQuery(query)) {
+ } else if (isEventsQuery(query) || isPersonsQuery(query)) {
return {
source: query,
max_limit: EXPORT_MAX_LIMIT,
@@ -98,12 +99,12 @@ export async function query(
methodOptions?: ApiMethodOptions,
refresh?: boolean,
queryId?: string
-): Promise {
+): Promise> {
if (isTimeToSeeDataSessionsNode(queryNode)) {
return query(queryNode.source)
}
- let response: N['response']
+ let response: NonNullable
const logParams: Record = {}
const startTime = performance.now()
@@ -246,3 +247,11 @@ export async function legacyInsightQuery({
}
return [fetchResponse, apiUrl]
}
+
+export async function hogqlQuery(queryString: string, values?: Record): Promise {
+ return await query({
+ kind: NodeKind.HogQLQuery,
+ query: queryString,
+ values,
+ })
+}
diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json
index c3fec7bc69a98..95c2ac6a8c35d 100644
--- a/frontend/src/queries/schema.json
+++ b/frontend/src/queries/schema.json
@@ -78,9 +78,6 @@
{
"$ref": "#/definitions/EventsNode"
},
- {
- "$ref": "#/definitions/EventsQuery"
- },
{
"$ref": "#/definitions/ActionsNode"
},
@@ -88,13 +85,19 @@
"$ref": "#/definitions/PersonsNode"
},
{
- "$ref": "#/definitions/HogQLQuery"
+ "$ref": "#/definitions/TimeToSeeDataSessionsQuery"
},
{
- "$ref": "#/definitions/HogQLMetadata"
+ "$ref": "#/definitions/EventsQuery"
},
{
- "$ref": "#/definitions/TimeToSeeDataSessionsQuery"
+ "$ref": "#/definitions/PersonsQuery"
+ },
+ {
+ "$ref": "#/definitions/HogQLQuery"
+ },
+ {
+ "$ref": "#/definitions/HogQLMetadata"
},
{
"$ref": "#/definitions/WebOverviewStatsQuery"
@@ -396,6 +399,9 @@
{
"$ref": "#/definitions/PersonsNode"
},
+ {
+ "$ref": "#/definitions/PersonsQuery"
+ },
{
"$ref": "#/definitions/HogQLQuery"
},
@@ -1415,6 +1421,9 @@
"LifecycleQueryResponse": {
"additionalProperties": false,
"properties": {
+ "hogql": {
+ "type": "string"
+ },
"is_cached": {
"type": "boolean"
},
@@ -1638,6 +1647,90 @@
"required": ["kind"],
"type": "object"
},
+ "PersonsQuery": {
+ "additionalProperties": false,
+ "properties": {
+ "fixedProperties": {
+ "items": {
+ "$ref": "#/definitions/AnyPropertyFilter"
+ },
+ "type": "array"
+ },
+ "kind": {
+ "const": "PersonsQuery",
+ "type": "string"
+ },
+ "limit": {
+ "type": "number"
+ },
+ "offset": {
+ "type": "number"
+ },
+ "orderBy": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "properties": {
+ "items": {
+ "$ref": "#/definitions/AnyPropertyFilter"
+ },
+ "type": "array"
+ },
+ "response": {
+ "$ref": "#/definitions/PersonsQueryResponse",
+ "description": "Cached query response"
+ },
+ "search": {
+ "type": "string"
+ },
+ "select": {
+ "items": {
+ "$ref": "#/definitions/HogQLExpression"
+ },
+ "type": "array"
+ }
+ },
+ "required": ["kind"],
+ "type": "object"
+ },
+ "PersonsQueryResponse": {
+ "additionalProperties": false,
+ "properties": {
+ "columns": {
+ "items": {},
+ "type": "array"
+ },
+ "hasMore": {
+ "type": "boolean"
+ },
+ "hogql": {
+ "type": "string"
+ },
+ "results": {
+ "items": {
+ "items": {},
+ "type": "array"
+ },
+ "type": "array"
+ },
+ "timings": {
+ "items": {
+ "$ref": "#/definitions/QueryTiming"
+ },
+ "type": "array"
+ },
+ "types": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": ["results", "columns", "types", "hogql"],
+ "type": "object"
+ },
"PropertyFilterValue": {
"anyOf": [
{
@@ -2279,6 +2372,9 @@
"TrendsQueryResponse": {
"additionalProperties": false,
"properties": {
+ "hogql": {
+ "type": "string"
+ },
"is_cached": {
"type": "boolean"
},
@@ -2332,6 +2428,9 @@
"items": {},
"type": "array"
},
+ "hogql": {
+ "type": "string"
+ },
"is_cached": {
"type": "boolean"
},
@@ -2386,6 +2485,9 @@
"items": {},
"type": "array"
},
+ "hogql": {
+ "type": "string"
+ },
"is_cached": {
"type": "boolean"
},
@@ -2440,6 +2542,9 @@
"items": {},
"type": "array"
},
+ "hogql": {
+ "type": "string"
+ },
"is_cached": {
"type": "boolean"
},
@@ -2494,6 +2599,9 @@
"items": {},
"type": "array"
},
+ "hogql": {
+ "type": "string"
+ },
"is_cached": {
"type": "boolean"
},
diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts
index c671ff8183a4b..16a820600cbdb 100644
--- a/frontend/src/queries/schema.ts
+++ b/frontend/src/queries/schema.ts
@@ -43,6 +43,7 @@ export enum NodeKind {
PersonsNode = 'PersonsNode',
HogQLQuery = 'HogQLQuery',
HogQLMetadata = 'HogQLMetadata',
+ PersonsQuery = 'PersonsQuery',
// Interface nodes
DataTableNode = 'DataTableNode',
@@ -74,13 +75,14 @@ export enum NodeKind {
}
export type AnyDataNode =
- | EventsNode
+ | EventsNode // never queried directly
+ | ActionsNode // old actions API endpoint
+ | PersonsNode // old persons API endpoint
+ | TimeToSeeDataSessionsQuery // old API
| EventsQuery
- | ActionsNode
- | PersonsNode
+ | PersonsQuery
| HogQLQuery
| HogQLMetadata
- | TimeToSeeDataSessionsQuery
| WebOverviewStatsQuery
| WebTopSourcesQuery
| WebTopClicksQuery
@@ -297,6 +299,7 @@ export interface DataTableNode extends Node, DataTableNodeViewProps {
| EventsNode
| EventsQuery
| PersonsNode
+ | PersonsQuery
| HogQLQuery
| TimeToSeeDataSessionsQuery
| WebOverviewStatsQuery
@@ -486,8 +489,9 @@ export type LifecycleFilter = Omit & {
} // using everything except what it inherits from FilterType
export interface QueryResponse {
- results: unknown
+ results: unknown[]
timings?: QueryTiming[]
+ hogql?: string
is_cached?: boolean
last_refresh?: string
next_allowed_client_refresh?: string
@@ -508,6 +512,27 @@ export interface LifecycleQuery extends InsightsQueryBase {
response?: LifecycleQueryResponse
}
+export interface PersonsQueryResponse {
+ results: any[][]
+ columns: any[]
+ types: string[]
+ hogql: string
+ timings?: QueryTiming[]
+ hasMore?: boolean
+}
+
+export interface PersonsQuery extends DataNode {
+ kind: NodeKind.PersonsQuery
+ select?: HogQLExpression[]
+ search?: string
+ properties?: AnyPropertyFilter[]
+ fixedProperties?: AnyPropertyFilter[]
+ orderBy?: string[]
+ limit?: number
+ offset?: number
+ response?: PersonsQueryResponse
+}
+
export type WebAnalyticsFilters = any
export interface WebAnalyticsQueryBase {
diff --git a/frontend/src/queries/utils.ts b/frontend/src/queries/utils.ts
index 80148f5d0f08b..fa673e53085a7 100644
--- a/frontend/src/queries/utils.ts
+++ b/frontend/src/queries/utils.ts
@@ -30,6 +30,7 @@ import {
WebTopClicksQuery,
WebTopPagesQuery,
WebOverviewStatsQuery,
+ PersonsQuery,
HogQLMetadata,
} from '~/queries/schema'
import { TaxonomicFilterGroupType, TaxonomicFilterValue } from 'lib/components/TaxonomicFilter/types'
@@ -43,6 +44,7 @@ export function isDataNode(node?: Node | null): node is EventsQuery | PersonsNod
isPersonsNode(node) ||
isTimeToSeeDataSessionsQuery(node) ||
isEventsQuery(node) ||
+ isPersonsQuery(node) ||
isHogQLQuery(node) ||
isHogQLMetadata(node)
)
@@ -87,6 +89,10 @@ export function isPersonsNode(node?: Node | null): node is PersonsNode {
return node?.kind === NodeKind.PersonsNode
}
+export function isPersonsQuery(node?: Node | null): node is PersonsQuery {
+ return node?.kind === NodeKind.PersonsQuery
+}
+
export function isDataTableNode(node?: Node | null): node is DataTableNode {
return node?.kind === NodeKind.DataTableNode
}
@@ -294,6 +300,19 @@ export function taxonomicEventFilterToHogQL(
return null
}
+export function taxonomicPersonFilterToHogQL(
+ groupType: TaxonomicFilterGroupType,
+ value: TaxonomicFilterValue
+): string | null {
+ if (groupType === TaxonomicFilterGroupType.PersonProperties) {
+ return `properties.${escapePropertyAsHogQlIdentifier(String(value))}`
+ }
+ if (groupType === TaxonomicFilterGroupType.HogQLExpression && value) {
+ return String(value)
+ }
+ return null
+}
+
export function isHogQlAggregation(hogQl: string): boolean {
return (
hogQl.includes('count(') ||
diff --git a/frontend/src/scenes/cohorts/cohortEditLogic.ts b/frontend/src/scenes/cohorts/cohortEditLogic.ts
index f5afc26926c86..f9dc745bc6c3e 100644
--- a/frontend/src/scenes/cohorts/cohortEditLogic.ts
+++ b/frontend/src/scenes/cohorts/cohortEditLogic.ts
@@ -1,7 +1,7 @@
-import { actions, afterMount, beforeUnmount, kea, key, listeners, path, props, reducers } from 'kea'
+import { actions, afterMount, beforeUnmount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import api from 'lib/api'
import { cohortsModel } from '~/models/cohortsModel'
-import { ENTITY_MATCH_TYPE } from 'lib/constants'
+import { ENTITY_MATCH_TYPE, FEATURE_FLAGS } from 'lib/constants'
import {
AnyCohortCriteriaType,
AnyCohortGroupType,
@@ -9,12 +9,12 @@ import {
CohortGroupType,
CohortType,
FilterLogicalOperator,
+ PropertyFilterType,
} from '~/types'
import { personsLogic } from 'scenes/persons/personsLogic'
import { lemonToast } from 'lib/lemon-ui/lemonToast'
import { urls } from 'scenes/urls'
-import { router } from 'kea-router'
-import { actionToUrl } from 'kea-router'
+import { actionToUrl, router } from 'kea-router'
import { loaders } from 'kea-loaders'
import { forms } from 'kea-forms'
import {
@@ -29,13 +29,17 @@ import { NEW_COHORT, NEW_CRITERIA, NEW_CRITERIA_GROUP } from 'scenes/cohorts/Coh
import type { cohortEditLogicType } from './cohortEditLogicType'
import { CohortLogicProps } from 'scenes/cohorts/cohortLogic'
import { processCohort } from 'lib/utils'
-import { DataTableNode, NodeKind, Node } from '~/queries/schema'
+import { DataTableNode, Node, NodeKind } from '~/queries/schema'
import { isDataTableNode } from '~/queries/utils'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
export const cohortEditLogic = kea([
props({} as CohortLogicProps),
key((props) => props.id || 'new'),
path(['scenes', 'cohorts', 'cohortLogicEdit']),
+ connect(() => ({
+ values: [featureFlagLogic, ['featureFlags']],
+ })),
actions({
saveCohort: (cohortParams = {}) => ({ cohortParams }),
@@ -61,7 +65,11 @@ export const cohortEditLogic = kea([
duplicateToStaticCohort: true,
}),
- reducers(({ props }) => ({
+ selectors({
+ usePersonsQuery: [(s) => [s.featureFlags], (featureFlags) => featureFlags[FEATURE_FLAGS.PERSONS_HOGQL_QUERY]],
+ }),
+
+ reducers(({ props, selectors }) => ({
cohort: [
NEW_COHORT as CohortType,
{
@@ -154,15 +162,27 @@ export const cohortEditLogic = kea([
},
],
query: [
- {
- kind: NodeKind.DataTableNode,
- source: {
- kind: NodeKind.PersonsNode,
- cohort: props.id,
- },
- columns: undefined,
- full: true,
- } as DataTableNode,
+ ((state: Record) =>
+ selectors.usePersonsQuery(state)
+ ? {
+ kind: NodeKind.DataTableNode,
+ source: {
+ kind: NodeKind.PersonsQuery,
+ fixedProperties: [
+ { type: PropertyFilterType.Cohort, key: 'id', value: parseInt(String(props.id)) },
+ ],
+ },
+ full: true,
+ }
+ : {
+ kind: NodeKind.DataTableNode,
+ source: {
+ kind: NodeKind.PersonsNode,
+ cohort: props.id,
+ },
+ columns: undefined,
+ full: true,
+ }) as any as DataTableNode,
{
setQuery: (state, { query }) => (isDataTableNode(query) ? query : state),
},
diff --git a/frontend/src/scenes/events/EventDetails.tsx b/frontend/src/scenes/events/EventDetails.tsx
index 2d6dd7a85a4ec..9bf9efcf96648 100644
--- a/frontend/src/scenes/events/EventDetails.tsx
+++ b/frontend/src/scenes/events/EventDetails.tsx
@@ -50,7 +50,7 @@ export function EventDetails({ event, tableProps, useReactJsonView }: EventDetai
([
props({} as InsightLogicProps),
key(keyForInsightLogicProps('new')),
path((key) => ['scenes', 'insights', 'insightLogic', key]),
- connect({
+ connect(() => ({
values: [
teamLogic,
['currentTeamId', 'currentTeam'],
@@ -93,7 +93,7 @@ export const insightLogic = kea([
],
actions: [tagsModel, ['loadTags']],
logic: [eventUsageLogic, dashboardsModel, promptLogic({ key: `save-as-insight` })],
- }),
+ })),
actions({
setFilters: (filters: Partial, insightMode?: ItemMode, clearInsightQuery?: boolean) => ({
diff --git a/frontend/src/scenes/persons/personsLogic.tsx b/frontend/src/scenes/persons/personsLogic.tsx
index 23a8bd9fd5e4c..c660dd15aabea 100644
--- a/frontend/src/scenes/persons/personsLogic.tsx
+++ b/frontend/src/scenes/persons/personsLogic.tsx
@@ -22,6 +22,7 @@ import { TriggerExportProps } from 'lib/components/ExportButton/exporter'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { FEATURE_FLAGS } from 'lib/constants'
import { asDisplay } from './person-utils'
+import { hogqlQuery } from '~/queries/query'
export interface PersonsLogicProps {
cohort?: number | 'new'
@@ -40,14 +41,15 @@ export const personsLogic = kea({
return props.cohort ? `cohort_${props.cohort}` : 'scene'
},
path: (key) => ['scenes', 'persons', 'personsLogic', key],
- connect: {
+ connect: () => ({
actions: [eventUsageLogic, ['reportPersonDetailViewed']],
values: [teamLogic, ['currentTeam'], featureFlagLogic, ['featureFlags']],
- },
+ }),
actions: {
setPerson: (person: PersonType | null) => ({ person }),
setPersons: (persons: PersonType[]) => ({ persons }),
loadPerson: (id: string) => ({ id }),
+ loadPersonUUID: (uuid: string) => ({ uuid }),
loadPersons: (url: string | null = '') => ({ url }),
setListFilters: (payload: PersonListParams) => ({ payload }),
setHiddenListProperties: (payload: AnyPropertyFilter[]) => ({ payload }),
@@ -281,6 +283,26 @@ export const personsLogic = kea({
null as PersonType | null,
{
loadPerson: async ({ id }): Promise => {
+ if (values.featureFlags[FEATURE_FLAGS.PERSONS_HOGQL_QUERY]) {
+ const response = await hogqlQuery(
+ 'select id, groupArray(pdi.distinct_id) as distinct_ids, properties, is_identified, created_at from persons where pdi.distinct_id={distinct_id} group by id, properties, is_identified, created_at',
+ { distinct_id: id }
+ )
+ const row = response?.results?.[0]
+ if (row) {
+ const person: PersonType = {
+ id: row[0],
+ uuid: row[0],
+ distinct_ids: row[1],
+ properties: JSON.parse(row[2] || '{}'),
+ is_identified: !!row[3],
+ created_at: row[4],
+ }
+ actions.reportPersonDetailViewed(person)
+ return person
+ }
+ }
+
const response = await api.persons.list({ distinct_id: id })
const person = response.results[0]
if (person) {
@@ -288,6 +310,26 @@ export const personsLogic = kea({
}
return person
},
+ loadPersonUUID: async ({ uuid }): Promise => {
+ const response = await hogqlQuery(
+ 'select id, groupArray(pdi.distinct_id) as distinct_ids, properties, is_identified, created_at from persons where id={id} group by id, properties, is_identified, created_at',
+ { id: uuid }
+ )
+ const row = response?.results?.[0]
+ if (row) {
+ const person: PersonType = {
+ id: row[0],
+ uuid: row[0],
+ distinct_ids: row[1],
+ properties: JSON.parse(row[2] || '{}'),
+ is_identified: !!row[3],
+ created_at: row[4],
+ }
+ actions.reportPersonDetailViewed(person)
+ return person
+ }
+ return null
+ },
},
],
cohorts: [
@@ -347,6 +389,26 @@ export const personsLogic = kea({
}
}
},
+ '/persons/*': ({ _: rawPersonUUID }, { sessionRecordingId }, { activeTab }) => {
+ if (props.syncWithUrl) {
+ if (sessionRecordingId && values.activeTab !== PersonsTabType.SESSION_RECORDINGS) {
+ actions.navigateToTab(PersonsTabType.SESSION_RECORDINGS)
+ } else if (activeTab && values.activeTab !== activeTab) {
+ actions.navigateToTab(activeTab as PersonsTabType)
+ }
+
+ if (!activeTab) {
+ actions.setActiveTab(PersonsTabType.PROPERTIES)
+ }
+
+ if (rawPersonUUID) {
+ const decodedPersonUUID = decodeURIComponent(rawPersonUUID)
+ if (!values.person || values.person.id != decodedPersonUUID) {
+ actions.loadPersonUUID(decodedPersonUUID)
+ }
+ }
+ }
+ },
}),
events: ({ props, actions }) => ({
afterMount: () => {
diff --git a/frontend/src/scenes/persons/personsSceneLogic.ts b/frontend/src/scenes/persons/personsSceneLogic.ts
index 336c562b8a89b..080f644d47a64 100644
--- a/frontend/src/scenes/persons/personsSceneLogic.ts
+++ b/frontend/src/scenes/persons/personsSceneLogic.ts
@@ -1,4 +1,4 @@
-import { actions, kea, path, reducers } from 'kea'
+import { actions, connect, kea, path, reducers, selectors } from 'kea'
import { actionToUrl, urlToAction } from 'kea-router'
import equal from 'fast-deep-equal'
@@ -8,25 +8,42 @@ import { objectsEqual } from 'lib/utils'
import { lemonToast } from 'lib/lemon-ui/lemonToast'
import type { personsSceneLogicType } from './personsSceneLogicType'
+import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
+import { FEATURE_FLAGS } from 'lib/constants'
-const getDefaultQuery = (): DataTableNode => ({
+const getDefaultQuery = (usePersonsQuery = false): DataTableNode => ({
kind: NodeKind.DataTableNode,
- source: { kind: NodeKind.PersonsNode },
+ source: usePersonsQuery
+ ? { kind: NodeKind.PersonsQuery, select: defaultDataTableColumns(NodeKind.PersonsQuery) }
+ : { kind: NodeKind.PersonsNode },
full: true,
propertiesViaUrl: true,
})
export const personsSceneLogic = kea([
path(['scenes', 'persons', 'personsSceneLogic']),
+ connect({ values: [featureFlagLogic, ['featureFlags']] }),
+ selectors({
+ queryFlagEnabled: [
+ (s) => [s.featureFlags],
+ (featureFlags) => !!featureFlags?.[FEATURE_FLAGS.PERSONS_HOGQL_QUERY],
+ ],
+ }),
actions({ setQuery: (query: Node) => ({ query }) }),
- reducers({ query: [getDefaultQuery() as Node, { setQuery: (_, { query }) => query }] }),
+ reducers(({ selectors }) => ({
+ query: [
+ ((state: Record) => getDefaultQuery(selectors.queryFlagEnabled(state))) as any as Node,
+ { setQuery: (_, { query }) => query },
+ ],
+ })),
actionToUrl(({ values }) => ({
setQuery: () => [
urls.persons(),
{},
- objectsEqual(values.query, getDefaultQuery()) ? {} : { q: values.query },
+ objectsEqual(values.query, getDefaultQuery(values.queryFlagEnabled)) ? {} : { q: values.query },
{ replace: true },
],
})),
@@ -36,9 +53,10 @@ export const personsSceneLogic = kea([
if (!equal(queryParam, values.query)) {
// nothing in the URL
if (!queryParam) {
+ const defaultQuery = getDefaultQuery(values.queryFlagEnabled)
// set the default unless it's already there
- if (!objectsEqual(values.query, getDefaultQuery())) {
- actions.setQuery(getDefaultQuery())
+ if (!objectsEqual(values.query, defaultQuery)) {
+ actions.setQuery(defaultQuery)
}
} else {
if (typeof queryParam === 'object') {
diff --git a/frontend/src/scenes/saved-insights/SavedInsights.tsx b/frontend/src/scenes/saved-insights/SavedInsights.tsx
index b38b2df5c0790..c868d7906b410 100644
--- a/frontend/src/scenes/saved-insights/SavedInsights.tsx
+++ b/frontend/src/scenes/saved-insights/SavedInsights.tsx
@@ -182,6 +182,12 @@ export const QUERY_TYPES_METADATA: Record = {
icon: IconPerson,
inMenu: true,
},
+ [NodeKind.PersonsQuery]: {
+ name: 'Persons',
+ description: 'List of persons matching specified conditions',
+ icon: IconPerson,
+ inMenu: false,
+ },
[NodeKind.DataTableNode]: {
name: 'Data table',
description: 'Slice and dice your data in a table',
diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts
index de3cf70b00df7..647b369ff8798 100644
--- a/frontend/src/scenes/scenes.ts
+++ b/frontend/src/scenes/scenes.ts
@@ -416,6 +416,7 @@ export const routes: Record = {
[urls.replaySingle(':id')]: Scene.ReplaySingle,
[urls.replayPlaylist(':id')]: Scene.ReplayPlaylist,
[urls.personByDistinctId('*', false)]: Scene.Person,
+ [urls.personByUUID('*', false)]: Scene.Person,
[urls.persons()]: Scene.Persons,
[urls.groups(':groupTypeIndex')]: Scene.Groups,
[urls.group(':groupTypeIndex', ':groupKey', false)]: Scene.Group,
diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts
index 1625a1cebc715..dfccee2129218 100644
--- a/frontend/src/scenes/urls.ts
+++ b/frontend/src/scenes/urls.ts
@@ -90,6 +90,8 @@ export const urls = {
combineUrl(`/replay/${id}`, filters ? { filters } : {}).url,
personByDistinctId: (id: string, encode: boolean = true): string =>
encode ? `/person/${encodeURIComponent(id)}` : `/person/${id}`,
+ personByUUID: (uuid: string, encode: boolean = true): string =>
+ encode ? `/persons/${encodeURIComponent(uuid)}` : `/persons/${uuid}`,
persons: (): string => '/persons',
groups: (groupTypeIndex: string | number): string => `/groups/${groupTypeIndex}`,
// :TRICKY: Note that groupKey is provided by user. We need to override urlPatternOptions for kea-router.
diff --git a/posthog/api/query.py b/posthog/api/query.py
index 078bf8cd3eaee..375db61b5bbb0 100644
--- a/posthog/api/query.py
+++ b/posthog/api/query.py
@@ -46,6 +46,9 @@
"WebTopClicksQuery",
"WebTopPagesQuery",
]
+QUERY_WITH_RUNNER_NO_CACHE = [
+ "PersonsQuery",
+]
class QueryThrottle(TeamRateThrottle):
@@ -218,6 +221,9 @@ def process_query(
refresh_requested = refresh_requested_by_client(request) if request else False
query_runner = get_query_runner(query_json, team)
return _unwrap_pydantic_dict(query_runner.run(refresh_requested=refresh_requested))
+ elif query_kind in QUERY_WITH_RUNNER_NO_CACHE:
+ query_runner = get_query_runner(query_json, team)
+ return _unwrap_pydantic_dict(query_runner.calculate())
elif query_kind == "EventsQuery":
events_query = EventsQuery.model_validate(query_json)
events_response = run_events_query(query=events_query, team=team, default_limit=default_limit)
diff --git a/posthog/hogql/property.py b/posthog/hogql/property.py
index 81efafc225a1f..b97cf37eb31a4 100644
--- a/posthog/hogql/property.py
+++ b/posthog/hogql/property.py
@@ -1,5 +1,5 @@
import re
-from typing import Any, List, Optional, Union, cast
+from typing import List, Optional, Union, cast, Literal
from pydantic import BaseModel
@@ -47,11 +47,15 @@ def visit_call(self, node: ast.Call):
self.visit(arg)
-def property_to_expr(property: Union[BaseModel, PropertyGroup, Property, dict, list], team: Team) -> ast.Expr:
+def property_to_expr(
+ property: Union[BaseModel, PropertyGroup, Property, dict, list],
+ team: Team,
+ scope: Literal["event", "person"] = "event",
+) -> ast.Expr:
if isinstance(property, dict):
property = Property(**property)
elif isinstance(property, list):
- properties = [property_to_expr(p, team) for p in property]
+ properties = [property_to_expr(p, team, scope) for p in property]
if len(properties) == 0:
return ast.Constant(value=True)
if len(properties) == 1:
@@ -80,12 +84,12 @@ def property_to_expr(property: Union[BaseModel, PropertyGroup, Property, dict, l
if len(property.values) == 0:
return ast.Constant(value=True)
if len(property.values) == 1:
- return property_to_expr(property.values[0], team)
+ return property_to_expr(property.values[0], team, scope)
if property.type == PropertyOperatorType.AND or property.type == FilterLogicalOperator.AND:
- return ast.And(exprs=[property_to_expr(p, team) for p in property.values])
+ return ast.And(exprs=[property_to_expr(p, team, scope) for p in property.values])
else:
- return ast.Or(exprs=[property_to_expr(p, team) for p in property.values])
+ return ast.Or(exprs=[property_to_expr(p, team, scope) for p in property.values])
elif isinstance(property, BaseModel):
property = Property(**property.dict())
else:
@@ -95,7 +99,11 @@ def property_to_expr(property: Union[BaseModel, PropertyGroup, Property, dict, l
if property.type == "hogql":
return parse_expr(property.key)
- elif property.type == "event" or cast(Any, property.type) == "feature" or property.type == "person":
+ elif property.type == "event" or property.type == "feature" or property.type == "person":
+ if scope == "person" and property.type != "person":
+ raise NotImplementedException(
+ f"The '{property.type}' property filter only works in 'event' scope, not in '{scope}' scope"
+ )
operator = cast(Optional[PropertyOperator], property.operator) or PropertyOperator.exact
value = property.value
if isinstance(value, list):
@@ -106,7 +114,7 @@ def property_to_expr(property: Union[BaseModel, PropertyGroup, Property, dict, l
else:
exprs = [
property_to_expr(
- Property(type=property.type, key=property.key, operator=property.operator, value=v), team
+ Property(type=property.type, key=property.key, operator=property.operator, value=v), team, scope
)
for v in value
]
@@ -118,7 +126,7 @@ def property_to_expr(property: Union[BaseModel, PropertyGroup, Property, dict, l
return ast.And(exprs=exprs)
return ast.Or(exprs=exprs)
- chain = ["person", "properties"] if property.type == "person" else ["properties"]
+ chain = ["person", "properties"] if property.type == "person" and scope != "person" else ["properties"]
field = ast.Field(chain=chain + [property.key])
if operator == PropertyOperator.is_set:
@@ -179,6 +187,10 @@ def property_to_expr(property: Union[BaseModel, PropertyGroup, Property, dict, l
return ast.CompareOperation(op=op, left=field, right=ast.Constant(value=value))
elif property.type == "element":
+ if scope == "person":
+ raise NotImplementedException(
+ f"property_to_expr for scope {scope} not implemented for type '{property.type}'"
+ )
value = property.value
operator = cast(Optional[PropertyOperator], property.operator) or PropertyOperator.exact
if isinstance(value, list):
@@ -187,7 +199,7 @@ def property_to_expr(property: Union[BaseModel, PropertyGroup, Property, dict, l
else:
exprs = [
property_to_expr(
- Property(type=property.type, key=property.key, operator=property.operator, value=v), team
+ Property(type=property.type, key=property.key, operator=property.operator, value=v), team, scope
)
for v in value
]
@@ -219,10 +231,9 @@ def property_to_expr(property: Union[BaseModel, PropertyGroup, Property, dict, l
elif property.type == "cohort" or property.type == "static-cohort" or property.type == "precalculated-cohort":
if not team:
raise Exception("Can not convert cohort property to expression without team")
-
cohort = Cohort.objects.get(team=team, id=property.value)
return ast.CompareOperation(
- left=ast.Field(chain=["person_id"]),
+ left=ast.Field(chain=["id" if scope == "person" else "person_id"]),
op=ast.CompareOperationOp.InCohort,
right=ast.Constant(value=cohort.pk),
)
diff --git a/posthog/hogql/test/test_property.py b/posthog/hogql/test/test_property.py
index b050b674c2f04..d56a74b59ba70 100644
--- a/posthog/hogql/test/test_property.py
+++ b/posthog/hogql/test/test_property.py
@@ -1,4 +1,4 @@
-from typing import List, Union, cast, Optional, Dict, Any
+from typing import List, Union, cast, Optional, Dict, Any, Literal
from posthog.constants import PropertyOperatorType
from posthog.hogql import ast
@@ -26,8 +26,13 @@
class TestProperty(BaseTest):
maxDiff = None
- def _property_to_expr(self, property: Union[PropertyGroup, Property, dict, list], team: Optional[Team] = None):
- return clear_locations(property_to_expr(property, team=team or self.team))
+ def _property_to_expr(
+ self,
+ property: Union[PropertyGroup, Property, dict, list],
+ team: Optional[Team] = None,
+ scope: Optional[Literal["event", "person"]] = None,
+ ):
+ return clear_locations(property_to_expr(property, team=team or self.team, scope=scope or "event"))
def _selector_to_expr(self, selector: str):
return clear_locations(selector_to_expr(selector))
@@ -436,3 +441,18 @@ def test_cohort_filter_dynamic(self):
self._property_to_expr({"type": "cohort", "key": "id", "value": cohort.pk}, self.team),
self._parse_expr(f"person_id IN COHORT {cohort.pk}"),
)
+
+ def test_person_scope(self):
+ self.assertEqual(
+ self._property_to_expr({"type": "person", "key": "a", "value": "b", "operator": "exact"}, scope="event"),
+ self._parse_expr("person.properties.a = 'b'"),
+ )
+ self.assertEqual(
+ self._property_to_expr({"type": "person", "key": "a", "value": "b", "operator": "exact"}, scope="person"),
+ self._parse_expr("properties.a = 'b'"),
+ )
+ with self.assertRaises(Exception) as e:
+ self._property_to_expr({"type": "event", "key": "a", "value": "b", "operator": "exact"}, scope="person")
+ self.assertEqual(
+ str(e.exception), "The 'event' property filter only works in 'event' scope, not in 'person' scope"
+ )
diff --git a/posthog/hogql_queries/insights/test/test_lifecycle_hogql_query.py b/posthog/hogql_queries/insights/test/test_lifecycle_query_runner.py
similarity index 99%
rename from posthog/hogql_queries/insights/test/test_lifecycle_hogql_query.py
rename to posthog/hogql_queries/insights/test/test_lifecycle_query_runner.py
index cb46d0f24b388..75637d5216ebd 100644
--- a/posthog/hogql_queries/insights/test/test_lifecycle_hogql_query.py
+++ b/posthog/hogql_queries/insights/test/test_lifecycle_query_runner.py
@@ -9,7 +9,7 @@
from posthog.test.base import APIBaseTest, ClickhouseTestMixin, _create_event, _create_person, flush_persons_and_events
-class TestLifecycleHogQLQuery(ClickhouseTestMixin, APIBaseTest):
+class TestLifecycleQueryRunner(ClickhouseTestMixin, APIBaseTest):
maxDiff = None
def _create_random_events(self) -> str:
diff --git a/posthog/hogql_queries/persons_query_runner.py b/posthog/hogql_queries/persons_query_runner.py
new file mode 100644
index 0000000000000..e5a0c512441f3
--- /dev/null
+++ b/posthog/hogql_queries/persons_query_runner.py
@@ -0,0 +1,205 @@
+import json
+from datetime import timedelta
+from typing import Optional, Any, Dict, List, cast, Literal
+
+from posthog.hogql import ast
+from posthog.hogql.constants import DEFAULT_RETURNED_ROWS, MAX_SELECT_RETURNED_ROWS
+from posthog.hogql.parser import parse_expr, parse_order_expr
+from posthog.hogql.property import property_to_expr, has_aggregation
+from posthog.hogql.query import execute_hogql_query
+from posthog.hogql.timings import HogQLTimings
+from posthog.hogql_queries.query_runner import QueryRunner
+from posthog.models import Team
+from posthog.schema import PersonsQuery, PersonsQueryResponse
+
+PERSON_FULL_TUPLE = ["id", "properties", "created_at", "is_identified"]
+
+
+class PersonsQueryRunner(QueryRunner):
+ query: PersonsQuery
+ query_type = PersonsQuery
+
+ def __init__(self, query: PersonsQuery | Dict[str, Any], team: Team, timings: Optional[HogQLTimings] = None):
+ super().__init__(query, team, timings)
+ if isinstance(query, PersonsQuery):
+ self.query = query
+ else:
+ self.query = PersonsQuery.model_validate(query)
+
+ def calculate(self) -> PersonsQueryResponse:
+ response = execute_hogql_query(
+ query_type="PersonsQuery",
+ query=self.to_query(),
+ team=self.team,
+ timings=self.timings,
+ )
+ input_columns = self.input_columns()
+ if "person" in input_columns:
+ person_column_index = input_columns.index("person")
+ for index, result in enumerate(response.results):
+ response.results[index] = list(result)
+ select = result[person_column_index]
+ new_result = dict(zip(PERSON_FULL_TUPLE, select))
+ new_result["properties"] = json.loads(new_result["properties"])
+ response.results[index][person_column_index] = new_result
+
+ has_more = len(response.results) > self.query_limit()
+ return PersonsQueryResponse(
+ # we added +1 before for pagination, remove the last element if there's more
+ results=response.results[:-1] if has_more else response.results,
+ timings=response.timings,
+ types=[type for _, type in response.types],
+ columns=self.input_columns(),
+ hogql=response.hogql,
+ hasMore=has_more,
+ )
+
+ def filter_conditions(self) -> List[ast.Expr]:
+ where_exprs: List[ast.Expr] = []
+
+ if self.query.properties:
+ where_exprs.append(property_to_expr(self.query.properties, self.team, scope="person"))
+
+ if self.query.fixedProperties:
+ where_exprs.append(property_to_expr(self.query.fixedProperties, self.team, scope="person"))
+
+ if self.query.search is not None and self.query.search != "":
+ where_exprs.append(
+ ast.Or(
+ exprs=[
+ ast.CompareOperation(
+ op=ast.CompareOperationOp.ILike,
+ left=ast.Field(chain=["properties", "email"]),
+ right=ast.Constant(value=f"%{self.query.search}%"),
+ ),
+ ast.CompareOperation(
+ op=ast.CompareOperationOp.ILike,
+ left=ast.Field(chain=["properties", "name"]),
+ right=ast.Constant(value=f"%{self.query.search}%"),
+ ),
+ ast.CompareOperation(
+ op=ast.CompareOperationOp.ILike,
+ left=ast.Call(name="toString", args=[ast.Field(chain=["id"])]),
+ right=ast.Constant(value=f"%{self.query.search}%"),
+ ),
+ ast.CompareOperation(
+ op=ast.CompareOperationOp.ILike,
+ left=ast.Field(chain=["pdi", "distinct_id"]),
+ right=ast.Constant(value=f"%{self.query.search}%"),
+ ),
+ ]
+ )
+ )
+ return where_exprs
+
+ def input_columns(self) -> List[str]:
+ return self.query.select or ["person", "id", "created_at", "person.$delete"]
+
+ def query_limit(self) -> int:
+ return min(MAX_SELECT_RETURNED_ROWS, DEFAULT_RETURNED_ROWS if self.query.limit is None else self.query.limit)
+
+ def to_query(self) -> ast.SelectQuery:
+
+ with self.timings.measure("columns"):
+ columns = []
+ group_by = []
+ aggregations = []
+ for expr in self.input_columns():
+ if expr == "person.$delete":
+ columns.append(ast.Constant(value=1))
+ elif expr == "person":
+ tuple_exprs = []
+ for field in PERSON_FULL_TUPLE:
+ if field == "distinct_ids":
+ column = parse_expr("'id'")
+ else:
+ column = ast.Field(chain=[field])
+ tuple_exprs.append(column)
+ if has_aggregation(column):
+ aggregations.append(column)
+ elif not isinstance(column, ast.Constant):
+ group_by.append(column)
+ columns.append(ast.Tuple(exprs=tuple_exprs))
+ else:
+ column = parse_expr(expr)
+ columns.append(parse_expr(expr))
+ if has_aggregation(column):
+ aggregations.append(column)
+ elif not isinstance(column, ast.Constant):
+ group_by.append(column)
+ has_any_aggregation = len(aggregations) > 0
+
+ with self.timings.measure("filters"):
+ filter_conditions = self.filter_conditions()
+ where_list = [expr for expr in filter_conditions if not has_aggregation(expr)]
+ if len(where_list) == 0:
+ where = None
+ elif len(where_list) == 1:
+ where = where_list[0]
+ else:
+ where = ast.And(exprs=where_list)
+
+ having_list = [expr for expr in filter_conditions if has_aggregation(expr)]
+ if len(having_list) == 0:
+ having = None
+ elif len(having_list) == 1:
+ having = having_list[0]
+ else:
+ having = ast.And(exprs=having_list)
+
+ with self.timings.measure("order"):
+ if self.query.orderBy is not None:
+ if self.query.orderBy in [["person"], ["person DESC"], ["person ASC"]]:
+ order_property = (
+ "email"
+ if self.team.person_display_name_properties is None
+ else self.team.person_display_name_properties[0]
+ )
+ order_by = [
+ ast.OrderExpr(
+ expr=ast.Field(chain=["properties", order_property]),
+ order=cast(
+ Literal["ASC", "DESC"], "DESC" if self.query.orderBy[0] == "person DESC" else "ASC"
+ ),
+ )
+ ]
+ else:
+ order_by = [parse_order_expr(column, timings=self.timings) for column in self.query.orderBy]
+ elif "count()" in self.input_columns():
+ order_by = [ast.OrderExpr(expr=parse_expr("count()"), order="DESC")]
+ elif len(aggregations) > 0:
+ order_by = [ast.OrderExpr(expr=aggregations[0], order="DESC")]
+ elif "created_at" in self.input_columns():
+ order_by = [ast.OrderExpr(expr=ast.Field(chain=["created_at"]), order="DESC")]
+ elif len(columns) > 0:
+ order_by = [ast.OrderExpr(expr=columns[0], order="ASC")]
+ else:
+ order_by = []
+
+ with self.timings.measure("limit"):
+ # adding +1 to the limit to check if there's a "next page" after the requested results
+ limit = self.query_limit() + 1
+ offset = 0 if self.query.offset is None else self.query.offset
+
+ with self.timings.measure("select"):
+ stmt = ast.SelectQuery(
+ select=columns,
+ select_from=ast.JoinExpr(table=ast.Field(chain=["persons"])),
+ where=where,
+ having=having,
+ group_by=group_by if has_any_aggregation else None,
+ order_by=order_by,
+ limit=ast.Constant(value=limit),
+ offset=ast.Constant(value=offset),
+ )
+
+ return stmt
+
+ def to_persons_query(self) -> ast.SelectQuery:
+ return self.to_query()
+
+ def _is_stale(self, cached_result_package):
+ return True
+
+ def _refresh_frequency(self):
+ return timedelta(minutes=1)
diff --git a/posthog/hogql_queries/query_runner.py b/posthog/hogql_queries/query_runner.py
index cb6e341a3d597..3f42fb2f734bd 100644
--- a/posthog/hogql_queries/query_runner.py
+++ b/posthog/hogql_queries/query_runner.py
@@ -22,6 +22,7 @@
WebTopClicksQuery,
WebTopPagesQuery,
WebOverviewStatsQuery,
+ PersonsQuery,
)
from posthog.utils import generate_cache_key, get_safe_cache
@@ -48,6 +49,8 @@ class QueryResponse(BaseModel, Generic[DataT]):
timings: Optional[List[QueryTiming]] = None
types: Optional[List[Tuple[str, str]]] = None
columns: Optional[List[str]] = None
+ hogql: Optional[str] = None
+ hasMore: Optional[bool] = None
class CachedQueryResponse(QueryResponse):
@@ -61,6 +64,7 @@ class CachedQueryResponse(QueryResponse):
RunnableQueryNode = Union[
TrendsQuery,
+ PersonsQuery,
LifecycleQuery,
WebOverviewStatsQuery,
WebTopSourcesQuery,
@@ -86,6 +90,10 @@ def get_query_runner(
from .insights.trends_query_runner import TrendsQueryRunner
return TrendsQueryRunner(query=cast(TrendsQuery | Dict[str, Any], query), team=team, timings=timings)
+ if kind == "PersonsQuery":
+ from .persons_query_runner import PersonsQueryRunner
+
+ return PersonsQueryRunner(query=cast(PersonsQuery | Dict[str, Any], query), team=team, timings=timings)
if kind == "WebOverviewStatsQuery":
from .web_analytics.overview_stats import WebOverviewStatsQueryRunner
@@ -121,7 +129,9 @@ def __init__(self, query: RunnableQueryNode | Dict[str, Any], team: Team, timing
self.query = self.query_type.model_validate(query)
@abstractmethod
- def calculate(self) -> QueryResponse:
+ def calculate(self) -> BaseModel:
+ # The returned model should have a structure similar to QueryResponse.
+ # Due to the way schema.py is generated, we don't have a good inheritance story here.
raise NotImplementedError()
def run(self, refresh_requested: bool) -> CachedQueryResponse:
@@ -140,7 +150,7 @@ def run(self, refresh_requested: bool) -> CachedQueryResponse:
else:
QUERY_CACHE_HIT_COUNTER.labels(team_id=self.team.pk, cache_hit="miss").inc()
- fresh_response_dict = self.calculate().model_dump()
+ fresh_response_dict = cast(QueryResponse, self.calculate()).model_dump()
fresh_response_dict["is_cached"] = False
fresh_response_dict["last_refresh"] = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
fresh_response_dict["next_allowed_client_refresh"] = (datetime.now() + self._refresh_frequency()).strftime(
diff --git a/posthog/hogql_queries/test/test_persons_query_runner.py b/posthog/hogql_queries/test/test_persons_query_runner.py
new file mode 100644
index 0000000000000..4febfcb107e8f
--- /dev/null
+++ b/posthog/hogql_queries/test/test_persons_query_runner.py
@@ -0,0 +1,130 @@
+from posthog.hogql import ast
+from posthog.hogql.visitor import clear_locations
+from posthog.hogql_queries.persons_query_runner import PersonsQueryRunner
+from posthog.models.utils import UUIDT
+from posthog.schema import PersonsQuery, PersonPropertyFilter, HogQLPropertyFilter, PropertyOperator
+from posthog.test.base import APIBaseTest, ClickhouseTestMixin, _create_person, flush_persons_and_events
+
+
+class TestPersonsQueryRunner(ClickhouseTestMixin, APIBaseTest):
+ maxDiff = None
+ random_uuid: str
+
+ def _create_random_persons(self) -> str:
+ random_uuid = str(UUIDT())
+ for index in range(10):
+ _create_person(
+ properties={
+ "email": f"jacob{index}@{random_uuid}.posthog.com",
+ "name": f"Mr Jacob {random_uuid}",
+ "random_uuid": random_uuid,
+ "index": index,
+ },
+ team=self.team,
+ distinct_ids=[f"id-{random_uuid}-{index}"],
+ is_identified=True,
+ )
+ flush_persons_and_events()
+ return random_uuid
+
+ def _create_runner(self, query: PersonsQuery) -> PersonsQueryRunner:
+ return PersonsQueryRunner(team=self.team, query=query)
+
+ def setUp(self):
+ super().setUp()
+ self.random_uuid = self._create_random_persons()
+
+ def test_default_persons_query(self):
+ runner = self._create_runner(PersonsQuery())
+
+ query = runner.to_query()
+ query = clear_locations(query)
+ expected = ast.SelectQuery(
+ select=[
+ ast.Tuple(
+ exprs=[
+ ast.Field(chain=["id"]),
+ ast.Field(chain=["properties"]),
+ ast.Field(chain=["created_at"]),
+ ast.Field(chain=["is_identified"]),
+ ]
+ ),
+ ast.Field(chain=["id"]),
+ ast.Field(chain=["created_at"]),
+ ast.Constant(value=1),
+ ],
+ select_from=ast.JoinExpr(table=ast.Field(chain=["persons"])),
+ where=None,
+ limit=ast.Constant(value=101),
+ offset=ast.Constant(value=0),
+ order_by=[ast.OrderExpr(expr=ast.Field(chain=["created_at"]), order="DESC")],
+ )
+ self.assertEqual(clear_locations(query), expected)
+ response = runner.calculate()
+ self.assertEqual(len(response.results), 10)
+
+ def test_persons_query_properties(self):
+ runner = self._create_runner(
+ PersonsQuery(
+ properties=[
+ PersonPropertyFilter(key="random_uuid", value=self.random_uuid, operator=PropertyOperator.exact),
+ HogQLPropertyFilter(key="toInt(properties.index) > 5"),
+ ]
+ )
+ )
+ self.assertEqual(len(runner.calculate().results), 4)
+
+ def test_persons_query_fixed_properties(self):
+ runner = self._create_runner(
+ PersonsQuery(
+ fixedProperties=[
+ PersonPropertyFilter(key="random_uuid", value=self.random_uuid, operator=PropertyOperator.exact),
+ HogQLPropertyFilter(key="toInt(properties.index) < 2"),
+ ]
+ )
+ )
+ self.assertEqual(len(runner.calculate().results), 2)
+
+ def test_persons_query_search_email(self):
+ self._create_random_persons()
+ runner = self._create_runner(PersonsQuery(search=f"jacob4@{self.random_uuid}.posthog"))
+ self.assertEqual(len(runner.calculate().results), 1)
+ runner = self._create_runner(PersonsQuery(search=f"JACOB4@{self.random_uuid}.posthog"))
+ self.assertEqual(len(runner.calculate().results), 1)
+
+ def test_persons_query_search_name(self):
+ runner = self._create_runner(PersonsQuery(search=f"Mr Jacob {self.random_uuid}"))
+ self.assertEqual(len(runner.calculate().results), 10)
+ runner = self._create_runner(PersonsQuery(search=f"MR JACOB {self.random_uuid}"))
+ self.assertEqual(len(runner.calculate().results), 10)
+
+ def test_persons_query_search_distinct_id(self):
+ runner = self._create_runner(PersonsQuery(search=f"id-{self.random_uuid}-9"))
+ self.assertEqual(len(runner.calculate().results), 1)
+ runner = self._create_runner(PersonsQuery(search=f"id-{self.random_uuid}-9"))
+ self.assertEqual(len(runner.calculate().results), 1)
+
+ def test_persons_query_aggregation_select_having(self):
+ runner = self._create_runner(PersonsQuery(select=["properties.name", "count()"]))
+ results = runner.calculate().results
+ self.assertEqual(results, [[f"Mr Jacob {self.random_uuid}", 10]])
+
+ def test_persons_query_order_by(self):
+ runner = self._create_runner(PersonsQuery(select=["properties.email"], orderBy=["properties.email DESC"]))
+ results = runner.calculate().results
+ self.assertEqual(results[0], [f"jacob9@{self.random_uuid}.posthog.com"])
+
+ def test_persons_query_limit(self):
+ runner = self._create_runner(
+ PersonsQuery(select=["properties.email"], orderBy=["properties.email DESC"], limit=1)
+ )
+ response = runner.calculate()
+ self.assertEqual(response.results, [[f"jacob9@{self.random_uuid}.posthog.com"]])
+ self.assertEqual(response.hasMore, True)
+
+ runner = self._create_runner(
+ PersonsQuery(select=["properties.email"], orderBy=["properties.email DESC"], limit=1, offset=2)
+ )
+ response = runner.calculate()
+ self.assertEqual(response.results, [[f"jacob7@{self.random_uuid}.posthog.com"]])
+ self.assertEqual(response.hasMore, True)
diff --git a/posthog/hogql_queries/test/test_query_runner.py b/posthog/hogql_queries/test/test_query_runner.py
index 17a38727bb407..9ac9cb5956df2 100644
--- a/posthog/hogql_queries/test/test_query_runner.py
+++ b/posthog/hogql_queries/test/test_query_runner.py
@@ -17,7 +17,7 @@ class TestQuery(BaseModel):
other_attr: Optional[List[Any]] = []
-class QueryRunnerTest(BaseTest):
+class TestQueryRunner(BaseTest):
def setup_test_query_runner_class(self, query_class: Type[RunnableQueryNode] = TestQuery): # type: ignore
"""Setup required methods and attributes of the abstract base class."""
diff --git a/posthog/schema.py b/posthog/schema.py
index 705267fdf9cdc..9290259c7cc91 100644
--- a/posthog/schema.py
+++ b/posthog/schema.py
@@ -461,6 +461,7 @@ class TrendsQueryResponse(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
+ hogql: Optional[str] = None
is_cached: Optional[bool] = None
last_refresh: Optional[str] = None
next_allowed_client_refresh: Optional[str] = None
@@ -473,6 +474,7 @@ class WebOverviewStatsQueryResponse(BaseModel):
extra="forbid",
)
columns: Optional[List] = None
+ hogql: Optional[str] = None
is_cached: Optional[bool] = None
last_refresh: Optional[str] = None
next_allowed_client_refresh: Optional[str] = None
@@ -486,6 +488,7 @@ class WebTopClicksQueryResponse(BaseModel):
extra="forbid",
)
columns: Optional[List] = None
+ hogql: Optional[str] = None
is_cached: Optional[bool] = None
last_refresh: Optional[str] = None
next_allowed_client_refresh: Optional[str] = None
@@ -499,6 +502,7 @@ class WebTopPagesQueryResponse(BaseModel):
extra="forbid",
)
columns: Optional[List] = None
+ hogql: Optional[str] = None
is_cached: Optional[bool] = None
last_refresh: Optional[str] = None
next_allowed_client_refresh: Optional[str] = None
@@ -512,6 +516,7 @@ class WebTopSourcesQueryResponse(BaseModel):
extra="forbid",
)
columns: Optional[List] = None
+ hogql: Optional[str] = None
is_cached: Optional[bool] = None
last_refresh: Optional[str] = None
next_allowed_client_refresh: Optional[str] = None
@@ -666,6 +671,7 @@ class LifecycleQueryResponse(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
+ hogql: Optional[str] = None
is_cached: Optional[bool] = None
last_refresh: Optional[str] = None
next_allowed_client_refresh: Optional[str] = None
@@ -684,6 +690,18 @@ class PersonPropertyFilter(BaseModel):
value: Optional[Union[str, float, List[Union[str, float]]]] = None
+class PersonsQueryResponse(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ columns: List
+ hasMore: Optional[bool] = None
+ hogql: str
+ results: List[List]
+ timings: Optional[List[QueryTiming]] = None
+ types: List[str]
+
+
class RetentionFilter(BaseModel):
model_config = ConfigDict(
extra="forbid",
@@ -963,6 +981,51 @@ class PersonsNode(BaseModel):
search: Optional[str] = None
+class PersonsQuery(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ fixedProperties: Optional[
+ List[
+ Union[
+ EventPropertyFilter,
+ PersonPropertyFilter,
+ ElementPropertyFilter,
+ SessionPropertyFilter,
+ CohortPropertyFilter,
+ RecordingDurationFilter,
+ GroupPropertyFilter,
+ FeaturePropertyFilter,
+ HogQLPropertyFilter,
+ EmptyPropertyFilter,
+ ]
+ ]
+ ] = None
+ kind: Literal["PersonsQuery"] = "PersonsQuery"
+ limit: Optional[float] = None
+ offset: Optional[float] = None
+ orderBy: Optional[List[str]] = None
+ properties: Optional[
+ List[
+ Union[
+ EventPropertyFilter,
+ PersonPropertyFilter,
+ ElementPropertyFilter,
+ SessionPropertyFilter,
+ CohortPropertyFilter,
+ RecordingDurationFilter,
+ GroupPropertyFilter,
+ FeaturePropertyFilter,
+ HogQLPropertyFilter,
+ EmptyPropertyFilter,
+ ]
+ ]
+ ] = None
+ response: Optional[PersonsQueryResponse] = Field(default=None, description="Cached query response")
+ search: Optional[str] = None
+ select: Optional[List[str]] = None
+
+
class PropertyGroupFilterValue(BaseModel):
model_config = ConfigDict(
extra="forbid",
@@ -1086,6 +1149,7 @@ class DataTableNode(BaseModel):
EventsNode,
EventsQuery,
PersonsNode,
+ PersonsQuery,
HogQLQuery,
TimeToSeeDataSessionsQuery,
WebOverviewStatsQuery,
@@ -1363,12 +1427,13 @@ class Model(RootModel):
DatabaseSchemaQuery,
Union[
EventsNode,
- EventsQuery,
ActionsNode,
PersonsNode,
+ TimeToSeeDataSessionsQuery,
+ EventsQuery,
+ PersonsQuery,
HogQLQuery,
HogQLMetadata,
- TimeToSeeDataSessionsQuery,
WebOverviewStatsQuery,
WebTopSourcesQuery,
WebTopClicksQuery,