From 90b2ee3ad6f711aeca4694010aa1520e581a1621 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Fri, 28 Jun 2024 11:49:25 +0200 Subject: [PATCH] feat(hogql): sparkline tags and title (#23273) --- .../exporter-exporter--dashboard--light.png | Bin 27585 -> 23608 bytes .../queries/nodes/DataTable/renderColumn.tsx | 27 +---- .../nodes/DataTable/renderColumnMeta.tsx | 4 + .../HogQLX/__snapshots__/render.test.tsx.snap | 54 ++++++++++ .../src/queries/nodes/HogQLX/render.test.tsx | 97 ++++++++++++++++++ frontend/src/queries/nodes/HogQLX/render.tsx | 44 ++++++++ mypy-baseline.txt | 1 - posthog/hogql/functions/sparkline.py | 6 +- .../hogql/functions/test/test_sparkline.py | 4 +- posthog/hogql/hogqlx.py | 38 +++++++ posthog/hogql/printer.py | 6 ++ posthog/hogql/resolver.py | 12 ++- posthog/hogql/resolver_utils.py | 5 +- posthog/hogql/test/test_hogqlx.py | 68 ++++++++++++ posthog/hogql/test/test_resolver.py | 55 ++++++++++ 15 files changed, 387 insertions(+), 34 deletions(-) create mode 100644 frontend/src/queries/nodes/HogQLX/__snapshots__/render.test.tsx.snap create mode 100644 frontend/src/queries/nodes/HogQLX/render.test.tsx create mode 100644 frontend/src/queries/nodes/HogQLX/render.tsx create mode 100644 posthog/hogql/hogqlx.py create mode 100644 posthog/hogql/test/test_hogqlx.py diff --git a/frontend/__snapshots__/exporter-exporter--dashboard--light.png b/frontend/__snapshots__/exporter-exporter--dashboard--light.png index 12a9f90ef346ec67aedf18eaf322f6d241e2ebcf..60710037e09066db067702930a33695684c7a50d 100644 GIT binary patch literal 23608 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 literal 27585 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 M9V+FSw&O4V1qF8}>;M1& diff --git a/frontend/src/queries/nodes/DataTable/renderColumn.tsx b/frontend/src/queries/nodes/DataTable/renderColumn.tsx index 8de75f5a50abe..07d63687d1a5a 100644 --- a/frontend/src/queries/nodes/DataTable/renderColumn.tsx +++ b/frontend/src/queries/nodes/DataTable/renderColumn.tsx @@ -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' @@ -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' @@ -49,29 +49,8 @@ export function renderColumn( ) - } else if ( - typeof value === 'object' && - Array.isArray(value) && - value[0] === '__hogql_chart_type' && - value[1] === 'sparkline' - ) { - const object: Record = {} - 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 ( - 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 { diff --git a/frontend/src/queries/nodes/DataTable/renderColumnMeta.tsx b/frontend/src/queries/nodes/DataTable/renderColumnMeta.tsx index 1e461e2929c70..229743f7096b4 100644 --- a/frontend/src/queries/nodes/DataTable/renderColumnMeta.tsx +++ b/frontend/src/queries/nodes/DataTable/renderColumnMeta.tsx @@ -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') { diff --git a/frontend/src/queries/nodes/HogQLX/__snapshots__/render.test.tsx.snap b/frontend/src/queries/nodes/HogQLX/__snapshots__/render.test.tsx.snap new file mode 100644 index 0000000000000..cd2f995bd4b02 --- /dev/null +++ b/frontend/src/queries/nodes/HogQLX/__snapshots__/render.test.tsx.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HogQLX render should render Sparkline 1`] = ` + + + +`; + +exports[`HogQLX render should render array 1`] = ` + +`; + +exports[`HogQLX render should render object 1`] = ` + +`; + +exports[`HogQLX render should render unknown tag 1`] = ` +
+ Unknown tag: + Unknown +
+`; diff --git a/frontend/src/queries/nodes/HogQLX/render.test.tsx b/frontend/src/queries/nodes/HogQLX/render.test.tsx new file mode 100644 index 0000000000000..200ede0fa9cbd --- /dev/null +++ b/frontend/src/queries/nodes/HogQLX/render.test.tsx @@ -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() + }) + }) +}) diff --git a/frontend/src/queries/nodes/HogQLX/render.tsx b/frontend/src/queries/nodes/HogQLX/render.tsx new file mode 100644 index 0000000000000..f9b91fe3e2569 --- /dev/null +++ b/frontend/src/queries/nodes/HogQLX/render.tsx @@ -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 = {} + 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 10 ? 0 : 1} /> + } + + const { __hx_tag: tag, ...rest } = object + if (!tag) { + return 10 ? 0 : 1} /> + } else if (tag === 'Sparkline') { + return ( + + + + ) + } + return
Unknown tag: {String(tag)}
+ } + + return <>{String(value)} +} diff --git a/mypy-baseline.txt b/mypy-baseline.txt index 140381626f5c8..b9dd15136bea9 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -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] diff --git a/posthog/hogql/functions/sparkline.py b/posthog/hogql/functions/sparkline.py index 5bbf9004f4425..84f640572761a 100644 --- a/posthog/hogql/functions/sparkline.py +++ b/posthog/hogql/functions/sparkline.py @@ -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], ] ) diff --git a/posthog/hogql/functions/test/test_sparkline.py b/posthog/hogql/functions/test/test_sparkline.py index 45d043c232c06..9e5dfdb33042a 100644 --- a/posthog/hogql/functions/test/test_sparkline.py +++ b/posthog/hogql/functions/test/test_sparkline.py @@ -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): diff --git a/posthog/hogql/hogqlx.py b/posthog/hogql/hogqlx.py new file mode 100644 index 0000000000000..3971c19f74cfb --- /dev/null +++ b/posthog/hogql/hogqlx.py @@ -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) diff --git a/posthog/hogql/printer.py b/posthog/hogql/printer.py index bd1995923cd46..40e657a576341 100644 --- a/posthog/hogql/printer.py +++ b/posthog/hogql/printer.py @@ -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: diff --git a/posthog/hogql/resolver.py b/posthog/hogql/resolver.py index 55d48fe07a07c..70f2eb4b7d263 100644 --- a/posthog/hogql/resolver.py +++ b/posthog/hogql/resolver.py @@ -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 @@ -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: @@ -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.""" @@ -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) diff --git a/posthog/hogql/resolver_utils.py b/posthog/hogql/resolver_utils.py index bfede9538ab64..f67f683449b02 100644 --- a/posthog/hogql/resolver_utils.py +++ b/posthog/hogql/resolver_utils.py @@ -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)) diff --git a/posthog/hogql/test/test_hogqlx.py b/posthog/hogql/test/test_hogqlx.py new file mode 100644 index 0000000000000..12986cc093169 --- /dev/null +++ b/posthog/hogql/test/test_hogqlx.py @@ -0,0 +1,68 @@ +from posthog.hogql import ast +from posthog.hogql.hogqlx import convert_tag_to_hx, convert_dict_to_hx, convert_to_hx +from posthog.test.base import BaseTest + + +class TestHogQLX(BaseTest): + def test_convert_tag_to_hx(self): + tag = ast.HogQLXTag(kind="Sparkline", attributes=[]) + self.assertEqual( + convert_tag_to_hx(tag), ast.Tuple(exprs=[ast.Constant(value="__hx_tag"), ast.Constant(value="Sparkline")]) + ) + + tag = ast.HogQLXTag( + kind="Sparkline", attributes=[ast.HogQLXAttribute(name="color", value=ast.Constant(value="red"))] + ) + self.assertEqual( + convert_tag_to_hx(tag), + ast.Tuple( + exprs=[ + ast.Constant(value="__hx_tag"), + ast.Constant(value="Sparkline"), + ast.Constant(value="color"), + ast.Constant(value="red"), + ] + ), + ) + + def test_convert_dict_to_hx(self): + d = ast.Dict(items=[]) + self.assertEqual( + convert_dict_to_hx(d), ast.Tuple(exprs=[ast.Constant(value="__hx_tag"), ast.Constant(value="__hx_obj")]) + ) + + d = ast.Dict(items=[(ast.Constant(value="color"), ast.Constant(value="red"))]) + self.assertEqual( + convert_dict_to_hx(d), + ast.Tuple( + exprs=[ + ast.Constant(value="__hx_tag"), + ast.Constant(value="__hx_obj"), + ast.Constant(value="color"), + ast.Constant(value="red"), + ] + ), + ) + + def test_convert_to_hx(self): + self.assertEqual(convert_to_hx(1), ast.Constant(value=1)) + self.assertEqual(convert_to_hx(None), ast.Constant(value=None)) + self.assertEqual(convert_to_hx(False), ast.Constant(value=False)) + self.assertEqual(convert_to_hx("a"), ast.Constant(value="a")) + self.assertEqual(convert_to_hx(ast.Constant(value=1)), ast.Constant(value=1)) + self.assertEqual( + convert_to_hx(ast.HogQLXTag(kind="Sparkline", attributes=[])), + ast.Tuple(exprs=[ast.Constant(value="__hx_tag"), ast.Constant(value="Sparkline")]), + ) + self.assertEqual( + convert_to_hx(ast.Dict(items=[])), + ast.Tuple(exprs=[ast.Constant(value="__hx_tag"), ast.Constant(value="__hx_obj")]), + ) + self.assertEqual( + convert_to_hx(ast.Array(exprs=[ast.Constant(value=1), ast.Constant(value=2), ast.Constant(value=3)])), + ast.Tuple(exprs=[ast.Constant(value=1), ast.Constant(value=2), ast.Constant(value=3)]), + ) + self.assertEqual( + convert_to_hx([1, 2, 3]), + ast.Tuple(exprs=[ast.Constant(value=1), ast.Constant(value=2), ast.Constant(value=3)]), + ) diff --git a/posthog/hogql/test/test_resolver.py b/posthog/hogql/test/test_resolver.py index cc4cde4554a4f..3ccb740d889cd 100644 --- a/posthog/hogql/test/test_resolver.py +++ b/posthog/hogql/test/test_resolver.py @@ -426,6 +426,53 @@ def test_visit_hogqlx_tag_source(self): assert hogql == expected + def test_visit_hogqlx_sparkline(self): + node = self._select("select ") + node = cast(ast.SelectQuery, resolve_types(node, self.context, dialect="clickhouse")) + expected = ast.SelectQuery( + select=[ + ast.Tuple( + exprs=[ + ast.Constant(value="__hx_tag"), + ast.Constant(value="Sparkline"), + ast.Constant(value="data"), + ast.Tuple( + exprs=[ + ast.Constant(value=1), + ast.Constant(value=2), + ast.Constant(value=3), + ] + ), + ] + ) + ], + ) + assert clone_expr(node, clear_types=True) == expected + + def test_visit_hogqlx_object(self): + node = self._select("select {'key': {'key': 'value'}}") + node = cast(ast.SelectQuery, resolve_types(node, self.context, dialect="clickhouse")) + expected = ast.SelectQuery( + select=[ + ast.Tuple( + exprs=[ + ast.Constant(value="__hx_tag"), + ast.Constant(value="__hx_obj"), + ast.Constant(value="key"), + ast.Tuple( + exprs=[ + ast.Constant(value="__hx_tag"), + ast.Constant(value="__hx_obj"), + ast.Constant(value="key"), + ast.Constant(value="value"), + ] + ), + ] + ) + ], + ) + assert clone_expr(node, clear_types=True) == expected + def _assert_first_columm_is_type(self, node: ast.SelectQuery, type: ast.ConstantType): column_type = node.select[0].type assert column_type is not None @@ -502,3 +549,11 @@ def test_function_types(self): node = self._select("select plus(1, 2) from events") node = cast(ast.SelectQuery, resolve_types(node, self.context, dialect="clickhouse")) self._assert_first_columm_is_type(node, ast.IntegerType(nullable=False)) + + def test_sparkline_tag(self): + node: ast.SelectQuery = self._select("select ") + node = cast(ast.SelectQuery, resolve_types(node, self.context, dialect="clickhouse")) + + node2 = self._select("select sparkline((1,2,3))") + node2 = cast(ast.SelectQuery, resolve_types(node2, self.context, dialect="clickhouse")) + assert node == node2