Skip to content

Commit

Permalink
feat(hogql): sparkline tags and title (#23273)
Browse files Browse the repository at this point in the history
  • Loading branch information
mariusandra authored Jun 28, 2024
1 parent 2735d98 commit 90b2ee3
Show file tree
Hide file tree
Showing 15 changed files with 387 additions and 34 deletions.
Binary file modified frontend/__snapshots__/exporter-exporter--dashboard--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 3 additions & 24 deletions frontend/src/queries/nodes/DataTable/renderColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { TZLabel } from 'lib/components/TZLabel'
import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag'
import { Link } from 'lib/lemon-ui/Link'
import { Sparkline } from 'lib/lemon-ui/Sparkline'
import { Spinner } from 'lib/lemon-ui/Spinner/Spinner'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { autoCaptureEventToDescription } from 'lib/utils'
Expand All @@ -16,6 +15,7 @@ import { PersonDisplay, PersonDisplayProps } from 'scenes/persons/PersonDisplay'
import { urls } from 'scenes/urls'

import { errorColumn, loadingColumn } from '~/queries/nodes/DataTable/dataTableLogic'
import { renderHogQLX } from '~/queries/nodes/HogQLX/render'
import { DeletePersonButton } from '~/queries/nodes/PersonsNode/DeletePersonButton'
import { DataTableNode, EventsQueryPersonColumn, HasPropertiesNode } from '~/queries/schema'
import { QueryContext } from '~/queries/types'
Expand Down Expand Up @@ -49,29 +49,8 @@ export function renderColumn(
</span>
</Tooltip>
)
} else if (
typeof value === 'object' &&
Array.isArray(value) &&
value[0] === '__hogql_chart_type' &&
value[1] === 'sparkline'
) {
const object: Record<string, any> = {}
for (let i = 0; i < value.length; i += 2) {
object[value[i]] = value[i + 1]
}
if ('results' in object && Array.isArray(object.results)) {
// TODO: If results aren't an array of numbers, show a helpful message on using sparkline()
return (
<Sparkline
data={[
{
name: key.includes('__hogql_chart_type') ? 'Data' : key,
values: object.results.map((v: any) => Number(v)),
},
]}
/>
)
}
} else if (typeof value === 'object' && Array.isArray(value) && value[0] === '__hx_tag') {
return renderHogQLX(value)
} else if (isHogQLQuery(query.source)) {
if (typeof value === 'string') {
try {
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/queries/nodes/DataTable/renderColumnMeta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export function renderColumnMeta(key: string, query: DataTableNode, context?: Qu
if (title.startsWith('`') && title.endsWith('`')) {
title = title.substring(1, title.length - 1)
}
if (title.startsWith("tuple('__hx_tag', '")) {
const tagName = title.substring(19, title.indexOf("'", 19))
title = tagName === '__hx_obj' ? 'Object' : '<' + tagName + ' />'
}
} else if (key === 'timestamp') {
title = 'Time'
} else if (key === 'created_at') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`HogQLX render should render Sparkline 1`] = `
<ErrorBoundary>
<Sparkline
data={
[
1,
2,
3,
]
}
type={
[
"line",
]
}
/>
</ErrorBoundary>
`;

exports[`HogQLX render should render array 1`] = `
<JSONViewer
collapsed={1}
name={null}
src={
[
1,
2,
3,
]
}
/>
`;

exports[`HogQLX render should render object 1`] = `
<JSONViewer
collapsed={1}
name={null}
src={
{
"a": 1,
"b": 2,
}
}
/>
`;

exports[`HogQLX render should render unknown tag 1`] = `
<div>
Unknown tag:
Unknown
</div>
`;
97 changes: 97 additions & 0 deletions frontend/src/queries/nodes/HogQLX/render.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { parseHogQLX, renderHogQLX } from '~/queries/nodes/HogQLX/render'

describe('HogQLX', () => {
describe('parse', () => {
it('should parse tags', () => {
const value = parseHogQLX(['__hx_tag', 'Sparkline', 'data', [1, 2, 3], 'type', ['line']])
expect(value).toEqual({
__hx_tag: 'Sparkline',
data: [1, 2, 3],
type: ['line'],
})
})

it('should parse empty tags', () => {
const value = parseHogQLX(['__hx_tag', 'Sparkline'])
expect(value).toEqual({
__hx_tag: 'Sparkline',
})
})

it('should parse objects', () => {
const value = parseHogQLX(['__hx_tag', '__hx_obj', 'a', 1, 'b', 2])
expect(value).toEqual({
a: 1,
b: 2,
})
})

it('should handle arrays', () => {
const value = parseHogQLX(['a', 'b', 'c'])
expect(value).toEqual(['a', 'b', 'c'])
})

it('should handle nested arrays', () => {
const value = parseHogQLX(['a', ['b', 'c']])
expect(value).toEqual(['a', ['b', 'c']])
})

it('should handle nested objects', () => {
const value = parseHogQLX(['__hx_tag', '__hx_obj', 'a', ['b', 'c']])
expect(value).toEqual({
a: ['b', 'c'],
})
})

it('should handle nested objects with tags', () => {
const value = parseHogQLX([
'__hx_tag',
'__hx_obj',
'a',
['__hx_tag', 'Sparkline', 'data', [1, 2, 3], 'type', ['line']],
])
expect(value).toEqual({
a: {
__hx_tag: 'Sparkline',
data: [1, 2, 3],
type: ['line'],
},
})
})
})

describe('render', () => {
it('should render Sparkline', () => {
const value = {
__hx_tag: 'Sparkline',
data: [1, 2, 3],
type: ['line'],
}
const element = renderHogQLX(value)
expect(element).toMatchSnapshot()
})

it('should render object', () => {
const value = {
a: 1,
b: 2,
}
const element = renderHogQLX(value)
expect(element).toMatchSnapshot()
})

it('should render unknown tag', () => {
const value = {
__hx_tag: 'Unknown',
}
const element = renderHogQLX(value)
expect(element).toMatchSnapshot()
})

it('should render array', () => {
const value = [1, 2, 3]
const element = renderHogQLX(value)
expect(element).toMatchSnapshot()
})
})
})
44 changes: 44 additions & 0 deletions frontend/src/queries/nodes/HogQLX/render.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { JSONViewer } from 'lib/components/JSONViewer'
import { Sparkline } from 'lib/lemon-ui/Sparkline'

import { ErrorBoundary } from '~/layout/ErrorBoundary'

export function parseHogQLX(value: any): any {
if (!Array.isArray(value)) {
return value
}
if (value[0] === '__hx_tag') {
const object: Record<string, any> = {}
const start = value[1] === '__hx_obj' ? 2 : 0
for (let i = start; i < value.length; i += 2) {
const key = parseHogQLX(value[i])
object[key] = parseHogQLX(value[i + 1])
}
return object
}
return value.map((v) => parseHogQLX(v))
}

export function renderHogQLX(value: any): JSX.Element {
const object = parseHogQLX(value)

if (typeof object === 'object') {
if (Array.isArray(object)) {
return <JSONViewer src={object} name={null} collapsed={object.length > 10 ? 0 : 1} />
}

const { __hx_tag: tag, ...rest } = object
if (!tag) {
return <JSONViewer src={rest} name={null} collapsed={Object.keys(rest).length > 10 ? 0 : 1} />
} else if (tag === 'Sparkline') {
return (
<ErrorBoundary>
<Sparkline {...rest} data={rest.data ?? []} type={rest.type ?? []} />
</ErrorBoundary>
)
}
return <div>Unknown tag: {String(tag)}</div>
}

return <>{String(value)}</>
}
1 change: 0 additions & 1 deletion mypy-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ posthog/hogql/resolver.py:0: error: Incompatible types in assignment (expression
posthog/hogql/resolver.py:0: error: Argument 1 to "append" of "list" has incompatible type "BaseTableType | SelectUnionQueryType | SelectQueryType | SelectQueryAliasType | SelectViewType | None"; expected "SelectQueryType | SelectUnionQueryType" [arg-type]
posthog/hogql/resolver.py:0: error: Incompatible types in assignment (expression has type "Expr", variable has type "JoinExpr | None") [assignment]
posthog/hogql/resolver.py:0: error: Incompatible types in assignment (expression has type "Expr", variable has type "SampleExpr | None") [assignment]
posthog/hogql/resolver.py:0: error: Argument 2 to "convert_hogqlx_tag" has incompatible type "int | None"; expected "int" [arg-type]
posthog/hogql/resolver.py:0: error: Statement is unreachable [unreachable]
posthog/hogql/resolver.py:0: error: Item "None" of "Type | None" has no attribute "resolve_constant_type" [union-attr]
posthog/hogql/resolver.py:0: error: Item "None" of "Type | None" has no attribute "resolve_constant_type" [union-attr]
Expand Down
6 changes: 3 additions & 3 deletions posthog/hogql/functions/sparkline.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
def sparkline(node: ast.Expr, args: list[ast.Expr]) -> ast.Expr:
return ast.Tuple(
exprs=[
ast.Constant(value="__hogql_chart_type"),
ast.Constant(value="sparkline"),
ast.Constant(value="results"),
ast.Constant(value="__hx_tag"),
ast.Constant(value="Sparkline"),
ast.Constant(value="data"),
args[0],
]
)
4 changes: 2 additions & 2 deletions posthog/hogql/functions/test/test_sparkline.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ def test_sparkline(self):
)
self.assertEqual(
response.hogql,
f"SELECT tuple('__hogql_chart_type', 'sparkline', 'results', [1, 2, 3]) LIMIT 100",
f"SELECT tuple('__hx_tag', 'Sparkline', 'data', [1, 2, 3]) LIMIT 100",
)
self.assertEqual(
response.results[0][0],
("__hogql_chart_type", "sparkline", "results", [1, 2, 3]),
("__hx_tag", "Sparkline", "data", [1, 2, 3]),
)

def test_sparkline_error(self):
Expand Down
38 changes: 38 additions & 0 deletions posthog/hogql/hogqlx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from typing import Any

from posthog.hogql import ast

HOGQLX_COMPONENTS = ["Sparkline"]


def convert_tag_to_hx(node: ast.HogQLXTag) -> ast.Tuple:
attrs: list[ast.Expr] = [
ast.Constant(value="__hx_tag"),
ast.Constant(value=node.kind),
]
for attribute in node.attributes:
attrs.append(convert_to_hx(attribute.name))
attrs.append(convert_to_hx(attribute.value))
return ast.Tuple(exprs=attrs)


def convert_dict_to_hx(node: ast.Dict) -> ast.Tuple:
attrs: list[ast.Expr] = [ast.Constant(value="__hx_tag"), ast.Constant(value="__hx_obj")]
for attribute in node.items:
attrs.append(convert_to_hx(attribute[0]))
attrs.append(convert_to_hx(attribute[1]))
return ast.Tuple(exprs=attrs)


def convert_to_hx(node: Any) -> ast.Expr:
if isinstance(node, ast.HogQLXTag):
return convert_tag_to_hx(node)
if isinstance(node, ast.Dict):
return convert_dict_to_hx(node)
if isinstance(node, ast.Array) or isinstance(node, ast.Tuple):
return ast.Tuple(exprs=[convert_to_hx(x) for x in node.exprs])
if isinstance(node, ast.Expr):
return node
if isinstance(node, list) or isinstance(node, tuple):
return ast.Tuple(exprs=[convert_to_hx(x) for x in node])
return ast.Constant(value=node)
6 changes: 6 additions & 0 deletions posthog/hogql/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,12 @@ def visit_array_access(self, node: ast.ArrayAccess):
def visit_array(self, node: ast.Array):
return f"[{', '.join([self.visit(expr) for expr in node.exprs])}]"

def visit_dict(self, node: ast.Dict):
str = "tuple('__hx_tag', '__hx_obj'"
for key, value in node.items:
str += f", {self.visit(key)}, {self.visit(value)}"
return str + ")"

def visit_lambda(self, node: ast.Lambda):
identifiers = [self._print_identifier(arg) for arg in node.args]
if len(identifiers) == 0:
Expand Down
12 changes: 9 additions & 3 deletions posthog/hogql/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
from posthog.hogql.functions.cohort import cohort_query_node
from posthog.hogql.functions.mapping import validate_function_args, HOGQL_CLICKHOUSE_FUNCTIONS, compare_types
from posthog.hogql.functions.sparkline import sparkline
from posthog.hogql.hogqlx import convert_to_hx, HOGQLX_COMPONENTS
from posthog.hogql.parser import parse_select
from posthog.hogql.resolver_utils import convert_hogqlx_tag, lookup_cte_by_name, lookup_field_by_name
from posthog.hogql.resolver_utils import expand_hogqlx_query, lookup_cte_by_name, lookup_field_by_name
from posthog.hogql.visitor import CloningVisitor, clone_expr, TraversingVisitor
from posthog.models.utils import UUIDT
from posthog.hogql.database.schema.events import EventsTable
Expand Down Expand Up @@ -277,7 +278,7 @@ def visit_join_expr(self, node: ast.JoinExpr):
scope = self.scopes[-1]

if isinstance(node.table, ast.HogQLXTag):
node.table = convert_hogqlx_tag(node.table, self.context.team_id)
node.table = expand_hogqlx_query(node.table, self.context.team_id)

# If selecting from a CTE, expand and visit the new node
if isinstance(node.table, ast.Field) and len(node.table.chain) == 1:
Expand Down Expand Up @@ -407,7 +408,9 @@ def visit_join_expr(self, node: ast.JoinExpr):
raise QueryError(f"A {type(node.table).__name__} cannot be used as a SELECT source")

def visit_hogqlx_tag(self, node: ast.HogQLXTag):
return self.visit(convert_hogqlx_tag(node, self.context.team_id))
if node.kind in HOGQLX_COMPONENTS:
return self.visit(convert_to_hx(node))
return self.visit(expand_hogqlx_query(node, self.context.team_id))

def visit_alias(self, node: ast.Alias):
"""Visit column aliases. SELECT 1, (select 3 as y) as x."""
Expand Down Expand Up @@ -703,6 +706,9 @@ def visit_tuple_access(self, node: ast.TupleAccess):

return node

def visit_dict(self, node: ast.Dict):
return self.visit(convert_to_hx(node))

def visit_constant(self, node: ast.Constant):
node = super().visit_constant(node)
node.type = resolve_constant_data_type(node.value)
Expand Down
5 changes: 4 additions & 1 deletion posthog/hogql/resolver_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,13 @@ def ast_to_query_node(expr: ast.Expr | ast.HogQLXTag):
raise SyntaxError(f'Expression of type "{type(expr).__name__}". Can\'t convert to constant.')


def convert_hogqlx_tag(node: ast.HogQLXTag, team_id: int):
def expand_hogqlx_query(node: ast.HogQLXTag, team_id: Optional[int]):
from posthog.hogql_queries.query_runner import get_query_runner
from posthog.models import Team

if team_id is None:
raise ResolutionError("team_id is required to convert a query tag to a query", start=node.start, end=node.end)

try:
query_node = ast_to_query_node(node)
runner = get_query_runner(query_node, Team.objects.get(pk=team_id))
Expand Down
Loading

0 comments on commit 90b2ee3

Please sign in to comment.