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,