Incomplete port of PostHog Python code

This commit is contained in:
Matt Aitken
2025-12-10 17:23:29 +00:00
parent b50c6321da
commit d6a3845f6a
16 changed files with 4347 additions and 581 deletions
+22
View File
@@ -0,0 +1,22 @@
Portions of this package are derived from PostHog (MIT License).
Copyright (c) 2020-2025 PostHog Inc.
The original license is reproduced below:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -3
View File
@@ -1,5 +1,4 @@
import { Logger } from "@trigger.dev/core/logger";
export { TQuery } from "./query/TQuery.js";
export type { TQueryOptions } from "./query/TQuery.js";
export type { QueryConfig } from "./query/QueryConfig.js";
export { TQuery } from "./query/query.js";
export type { TQueryOptions } from "./query/query.js";
@@ -1,536 +0,0 @@
import { ParserRuleContext } from "antlr4ts/ParserRuleContext";
import { ErrorNode } from "antlr4ts/tree/ErrorNode";
import { ParseTree } from "antlr4ts/tree/ParseTree";
import { TerminalNode } from "antlr4ts/tree/TerminalNode";
import {
ArrayJoinClauseContext,
ColumnExprContext,
ColumnExprListContext,
FromClauseContext,
GroupByClauseContext,
HavingClauseContext,
JoinExprContext,
LimitAndOffsetClauseContext,
LimitByClauseContext,
OffsetOnlyClauseContext,
OrderByClauseContext,
PlaceholderContext,
PrewhereClauseContext,
SelectContext,
SelectSetStmtContext,
SelectStmtContext,
SelectStmtWithParensContext,
SettingsClauseContext,
TopClauseContext,
WhereClauseContext,
WindowClauseContext,
WithClauseContext,
} from "../grammar/TSQLParser.js";
import { TSQLParserVisitor } from "../grammar/TSQLParserVisitor.js";
import { QueryConfig } from "./QueryConfig.js";
/**
* Visitor that converts TSQL AST to a QueryConfig
* The QueryConfig can then be used to build a ClickhouseQueryBuilder
*/
export class ClickHouseQueryVisitor implements TSQLParserVisitor<QueryConfig> {
visitSelect(ctx: SelectContext): QueryConfig {
const selectSetStmt = ctx.selectSetStmt();
if (selectSetStmt) {
return this.visitSelectSetStmt(selectSetStmt);
}
const selectStmt = ctx.selectStmt();
if (selectStmt) {
return this.visitSelectStmt(selectStmt);
}
// Handle tSQLxTagElement if needed - return empty config
return {
baseQuery: "",
whereClauses: [],
};
}
visitSelectSetStmt(ctx: SelectSetStmtContext): QueryConfig {
const selectStmtWithParens = ctx.selectStmtWithParens();
let config = this.visitSelectStmtWithParens(selectStmtWithParens);
// Handle subsequent select set clauses (UNION, EXCEPT, INTERSECT)
// For now, we'll convert the config back to SQL for UNION operations
// This is a limitation - UNION queries can't use the query builder pattern easily
const subsequentClauses = ctx.subsequentSelectSetClause();
if (subsequentClauses.length > 0) {
// Convert config to SQL for UNION operations
let query = this.configToSql(config);
for (const clause of subsequentClauses) {
const op = clause.EXCEPT()
? "EXCEPT"
: clause.UNION()
? clause.ALL()
? "UNION ALL"
: clause.DISTINCT()
? "UNION DISTINCT"
: "UNION"
: clause.INTERSECT()
? clause.DISTINCT()
? "INTERSECT DISTINCT"
: "INTERSECT"
: "";
if (op) {
const nextConfig = this.visitSelectStmtWithParens(clause.selectStmtWithParens());
query += ` ${op} ${this.configToSql(nextConfig)}`;
}
}
// Return as a base query (can't use query builder features with UNION)
return {
baseQuery: query,
whereClauses: [],
};
}
return config;
}
visitSelectStmtWithParens(ctx: SelectStmtWithParensContext): QueryConfig {
const selectStmt = ctx.selectStmt();
if (selectStmt) {
return this.visitSelectStmt(selectStmt);
}
const selectSetStmt = ctx.selectSetStmt();
if (selectSetStmt) {
const config = this.visitSelectSetStmt(selectSetStmt);
// Wrap in parentheses for subquery
return {
...config,
baseQuery: `(${this.configToSql(config)})`,
whereClauses: [], // Subqueries don't contribute to outer WHERE
};
}
// Handle placeholder if needed
const placeholder = ctx.placeholder();
if (placeholder) {
const placeholderConfig = this.visitPlaceholder(placeholder);
return placeholderConfig;
}
return {
baseQuery: this.getTextFromContext(ctx),
whereClauses: [],
};
}
visitPlaceholder(ctx: PlaceholderContext): QueryConfig {
return {
baseQuery: this.getTextFromContext(ctx),
whereClauses: [],
};
}
visitSelectStmt(ctx: SelectStmtContext): QueryConfig {
const config: QueryConfig = {
baseQuery: "",
whereClauses: [],
};
const parts: string[] = [];
// WITH clause
const withClause = ctx.withClause();
if (withClause) {
parts.push(this.visitWithClauseString(withClause));
}
// SELECT
parts.push("SELECT");
// DISTINCT
if (ctx.DISTINCT()) {
parts.push("DISTINCT");
}
// TOP clause
const topClause = ctx.topClause();
if (topClause) {
parts.push(this.visitTopClauseString(topClause));
}
// Column list
const columnExprList = ctx.columnExprList();
if (columnExprList) {
parts.push(this.visitColumnExprListString(columnExprList));
}
// FROM clause
const fromClause = ctx.fromClause();
if (fromClause) {
parts.push(this.visitFromClauseString(fromClause));
}
// Array JOIN
const arrayJoinClause = ctx.arrayJoinClause();
if (arrayJoinClause) {
parts.push(this.visitArrayJoinClauseString(arrayJoinClause));
}
// PREWHERE
const prewhereClause = ctx.prewhereClause();
if (prewhereClause) {
parts.push(this.visitPrewhereClauseString(prewhereClause));
}
// Base query is everything up to WHERE
config.baseQuery = parts.join(" ");
// WHERE - extract to whereClauses array
const whereClause = ctx.whereClause();
if (whereClause) {
const whereExpr = this.visitWhereClauseString(whereClause);
// Remove "WHERE " prefix
const whereCondition = whereExpr.replace(/^WHERE\s+/i, "");
config.whereClauses.push({
clause: whereCondition,
});
}
// GROUP BY
const groupByClause = ctx.groupByClause();
if (groupByClause) {
const groupByText = this.visitGroupByClauseString(groupByClause);
// Remove "GROUP BY " prefix
config.groupBy = groupByText.replace(/^GROUP\s+BY\s+/i, "");
}
// HAVING - add as WHERE clause (ClickHouse doesn't distinguish)
const havingClause = ctx.havingClause();
if (havingClause) {
const havingText = this.visitHavingClauseString(havingClause);
// Remove "HAVING " prefix
const havingCondition = havingText.replace(/^HAVING\s+/i, "");
config.whereClauses.push({
clause: havingCondition,
});
}
// WINDOW
const windowClause = ctx.windowClause();
if (windowClause) {
const windowText = this.visitWindowClauseString(windowClause);
// Append to base query
config.baseQuery += " " + windowText;
}
// ORDER BY
const orderByClause = ctx.orderByClause();
if (orderByClause) {
const orderByText = this.visitOrderByClauseString(orderByClause);
// Remove "ORDER BY " prefix
config.orderBy = orderByText.replace(/^ORDER\s+BY\s+/i, "");
}
// LIMIT BY
const limitByClause = ctx.limitByClause();
if (limitByClause) {
const limitByText = this.visitLimitByClauseString(limitByClause);
// Append to base query (LIMIT BY is not supported by query builder)
config.baseQuery += " " + limitByText;
}
// LIMIT / OFFSET
const limitAndOffsetClause = ctx.limitAndOffsetClause();
if (limitAndOffsetClause) {
const limitText = this.visitLimitAndOffsetClauseString(limitAndOffsetClause);
// Try to extract limit number
const limitMatch = limitText.match(/LIMIT\s+(\d+)/i);
if (limitMatch) {
config.limit = parseInt(limitMatch[1], 10);
} else {
// If we can't parse it, append to base query
config.baseQuery += " " + limitText;
}
}
const offsetOnlyClause = ctx.offsetOnlyClause();
if (offsetOnlyClause) {
const offsetText = this.visitOffsetOnlyClauseString(offsetOnlyClause);
// Append to base query (OFFSET is not directly supported by query builder)
config.baseQuery += " " + offsetText;
}
// SETTINGS
const settingsClause = ctx.settingsClause();
if (settingsClause) {
const settingsText = this.visitSettingsClauseString(settingsClause);
// Append to base query
config.baseQuery += " " + settingsText;
}
return config;
}
// Helper methods that return strings for building SQL fragments
// These are private and used internally, not part of the visitor interface
private visitColumnExprListString(ctx: ColumnExprListContext): string {
const exprs: string[] = [];
const columnExprs = ctx.columnExpr();
for (const expr of columnExprs) {
exprs.push(this.visitColumnExprString(expr));
}
return exprs.join(", ");
}
private visitColumnExprString(ctx: ColumnExprContext): string {
return this.getTextFromContext(ctx);
}
private visitFromClauseString(ctx: FromClauseContext): string {
const joinExpr = ctx.joinExpr();
return `FROM ${this.visitJoinExprString(joinExpr)}`;
}
private visitJoinExprString(ctx: JoinExprContext): string {
return this.getTextFromContext(ctx);
}
private visitWhereClauseString(ctx: WhereClauseContext): string {
const columnExpr = ctx.columnExpr();
return `WHERE ${this.visitColumnExprString(columnExpr)}`;
}
private visitGroupByClauseString(ctx: GroupByClauseContext): string {
return this.getTextFromContext(ctx);
}
private visitHavingClauseString(ctx: HavingClauseContext): string {
return this.getTextFromContext(ctx);
}
private visitOrderByClauseString(ctx: OrderByClauseContext): string {
return this.getTextFromContext(ctx);
}
private visitLimitByClauseString(ctx: LimitByClauseContext): string {
return this.getTextFromContext(ctx);
}
private visitLimitAndOffsetClauseString(ctx: LimitAndOffsetClauseContext): string {
return this.getTextFromContext(ctx);
}
private visitOffsetOnlyClauseString(ctx: OffsetOnlyClauseContext): string {
return this.getTextFromContext(ctx);
}
private visitSettingsClauseString(ctx: SettingsClauseContext): string {
return this.getTextFromContext(ctx);
}
private visitWithClauseString(ctx: WithClauseContext): string {
return this.getTextFromContext(ctx);
}
private visitTopClauseString(ctx: TopClauseContext): string {
return this.getTextFromContext(ctx);
}
private visitArrayJoinClauseString(ctx: ArrayJoinClauseContext): string {
return this.getTextFromContext(ctx);
}
private visitPrewhereClauseString(ctx: PrewhereClauseContext): string {
return this.getTextFromContext(ctx);
}
private visitWindowClauseString(ctx: WindowClauseContext): string {
return this.getTextFromContext(ctx);
}
// Interface methods that return QueryConfig (optional, so we can skip some)
visitColumnExprList(ctx: ColumnExprListContext): QueryConfig {
return {
baseQuery: this.visitColumnExprListString(ctx),
whereClauses: [],
};
}
visitColumnExpr(ctx: ColumnExprContext): QueryConfig {
return {
baseQuery: this.visitColumnExprString(ctx),
whereClauses: [],
};
}
visitFromClause(ctx: FromClauseContext): QueryConfig {
return {
baseQuery: this.visitFromClauseString(ctx),
whereClauses: [],
};
}
visitJoinExpr(ctx: JoinExprContext): QueryConfig {
return {
baseQuery: this.visitJoinExprString(ctx),
whereClauses: [],
};
}
visitWhereClause(ctx: WhereClauseContext): QueryConfig {
return {
baseQuery: this.visitWhereClauseString(ctx),
whereClauses: [],
};
}
visitGroupByClause(ctx: GroupByClauseContext): QueryConfig {
return {
baseQuery: this.visitGroupByClauseString(ctx),
whereClauses: [],
};
}
visitHavingClause(ctx: HavingClauseContext): QueryConfig {
return {
baseQuery: this.visitHavingClauseString(ctx),
whereClauses: [],
};
}
visitOrderByClause(ctx: OrderByClauseContext): QueryConfig {
return {
baseQuery: this.visitOrderByClauseString(ctx),
whereClauses: [],
};
}
visitLimitByClause(ctx: LimitByClauseContext): QueryConfig {
return {
baseQuery: this.visitLimitByClauseString(ctx),
whereClauses: [],
};
}
visitLimitAndOffsetClause(ctx: LimitAndOffsetClauseContext): QueryConfig {
return {
baseQuery: this.visitLimitAndOffsetClauseString(ctx),
whereClauses: [],
};
}
visitOffsetOnlyClause(ctx: OffsetOnlyClauseContext): QueryConfig {
return {
baseQuery: this.visitOffsetOnlyClauseString(ctx),
whereClauses: [],
};
}
visitSettingsClause(ctx: SettingsClauseContext): QueryConfig {
return {
baseQuery: this.visitSettingsClauseString(ctx),
whereClauses: [],
};
}
visitWithClause(ctx: WithClauseContext): QueryConfig {
return {
baseQuery: this.visitWithClauseString(ctx),
whereClauses: [],
};
}
visitTopClause(ctx: TopClauseContext): QueryConfig {
return {
baseQuery: this.visitTopClauseString(ctx),
whereClauses: [],
};
}
visitArrayJoinClause(ctx: ArrayJoinClauseContext): QueryConfig {
return {
baseQuery: this.visitArrayJoinClauseString(ctx),
whereClauses: [],
};
}
visitPrewhereClause(ctx: PrewhereClauseContext): QueryConfig {
return {
baseQuery: this.visitPrewhereClauseString(ctx),
whereClauses: [],
};
}
visitWindowClause(ctx: WindowClauseContext): QueryConfig {
return {
baseQuery: this.visitWindowClauseString(ctx),
whereClauses: [],
};
}
/**
* Convert a QueryConfig back to SQL string
* Used for UNION operations and subqueries
*/
private configToSql(config: QueryConfig): string {
let query = config.baseQuery;
if (config.whereClauses.length > 0) {
const clauses = config.whereClauses.map((w) => w.clause);
query += " WHERE " + clauses.join(" AND ");
}
if (config.groupBy) {
query += ` GROUP BY ${config.groupBy}`;
}
if (config.orderBy) {
query += ` ORDER BY ${config.orderBy}`;
}
if (config.limit !== undefined) {
query += ` LIMIT ${config.limit}`;
}
return query;
}
/**
* Extract the original text from a parse tree context
* This uses the text property which contains the original input text
*/
private getTextFromContext(ctx: ParserRuleContext): string {
return (ctx as any).text || "";
}
// Required by ParseTreeVisitor interface
visit(tree: ParseTree): QueryConfig {
// For generic parse trees, return empty config
return {
baseQuery: (tree as any).text || "",
whereClauses: [],
};
}
visitChildren(node: ParserRuleContext): QueryConfig {
// Visit all children and combine their configs
if (!node.children || node.children.length === 0) {
return this.visit(node);
}
// For now, just return the text representation
return {
baseQuery: (node as any).text || "",
whereClauses: [],
};
}
// Visit terminal nodes
visitTerminal(node: TerminalNode): QueryConfig {
return {
baseQuery: node.text,
whereClauses: [],
};
}
visitErrorNode(node: ErrorNode): QueryConfig {
return {
baseQuery: "",
whereClauses: [],
};
}
}
@@ -1,42 +0,0 @@
import type { QueryParams } from "@internal/clickhouse/client/queryBuilder.js";
/**
* Configuration object that represents a parsed TSQL query
* This can be used to build a ClickhouseQueryBuilder
* The structure matches ClickhouseQueryBuilder's API
*/
export interface QueryConfig {
/**
* The base SELECT query without WHERE, GROUP BY, ORDER BY, LIMIT clauses
* Example: "SELECT id, name FROM users"
*/
baseQuery: string;
/**
* WHERE clause conditions
* Each entry represents a call to queryBuilder.where(clause, params)
* The clauses will be joined with AND by the query builder
*/
whereClauses: Array<{
clause: string;
params?: QueryParams;
}>;
/**
* GROUP BY clause string
* Will be passed to queryBuilder.groupBy()
*/
groupBy?: string;
/**
* ORDER BY clause string
* Will be passed to queryBuilder.orderBy()
*/
orderBy?: string;
/**
* LIMIT value
* Will be passed to queryBuilder.limit()
*/
limit?: number;
}
+601
View File
@@ -0,0 +1,601 @@
// TypeScript translation of posthog/hogql/ast.py
// Keep this file in sync with the Python version
import type { HogQLContext } from "./context";
import type {
DatabaseField,
ExpressionField,
FieldOrTable,
FieldTraverser,
LazyJoin,
LazyTable,
StringArrayDatabaseField,
StringJSONDatabaseField,
Table,
UnknownDatabaseField,
VirtualTable,
} from "./models";
import type { ConstantDataType, HogQLQuerySettings } from "./constants";
// Base types
export interface AST {
start?: number;
end?: number;
accept?(visitor: any): any;
}
export interface Type extends AST {
get_child?(name: string, context: HogQLContext): Type;
has_child?(name: string, context: HogQLContext): boolean;
resolve_constant_type?(context: HogQLContext): ConstantType;
resolve_column_constant_type?(name: string, context: HogQLContext): ConstantType;
}
export interface Expr extends AST {
type?: Type;
}
export interface ConstantType extends Type {
data_type: ConstantDataType;
nullable?: boolean;
print_type?(): string;
}
export interface UnknownType extends ConstantType {
data_type: "unknown";
}
export interface CTE extends Expr {
name: string;
expr: Expr;
cte_type: "column" | "subquery";
}
// Type system
export type TableOrSelectType =
| BaseTableType
| SelectSetQueryType
| SelectQueryType
| SelectQueryAliasType;
export interface FieldAliasType extends Type {
alias: string;
type: Type;
}
export interface BaseTableType extends Type {
resolve_database_table?(context: HogQLContext): Table;
}
export interface TableType extends BaseTableType {
table: Table;
}
export interface LazyJoinType extends BaseTableType {
table_type: TableOrSelectType;
field: string;
lazy_join: LazyJoin;
}
export interface LazyTableType extends BaseTableType {
table: LazyTable;
}
export interface TableAliasType extends BaseTableType {
alias: string;
table_type: TableType | LazyTableType;
}
export interface VirtualTableType extends BaseTableType {
table_type: TableOrSelectType;
field: string;
virtual_table: VirtualTable;
}
export interface SelectQueryType extends Type {
aliases: Record<string, FieldAliasType>;
columns: Record<string, Type>;
tables: Record<string, TableOrSelectType>;
ctes: Record<string, CTE>;
anonymous_tables: (SelectQueryType | SelectSetQueryType)[];
parent?: SelectQueryType | SelectSetQueryType;
is_lambda_type?: boolean;
}
export interface SelectSetQueryType extends Type {
types: (SelectQueryType | SelectSetQueryType)[];
}
export interface SelectViewType extends BaseTableType {
view_name: string;
alias: string;
select_query_type: SelectQueryType | SelectSetQueryType;
}
export interface SelectQueryAliasType extends Type {
alias: string;
select_query_type: SelectQueryType | SelectSetQueryType;
}
export interface IntegerType extends ConstantType {
data_type: "int";
}
export interface DecimalType extends ConstantType {
data_type: "unknown";
}
export interface FloatType extends ConstantType {
data_type: "float";
}
export interface StringType extends ConstantType {
data_type: "str";
}
export interface StringJSONType extends StringType {}
export interface StringArrayType extends StringType {}
export interface BooleanType extends ConstantType {
data_type: "bool";
}
export interface DateType extends ConstantType {
data_type: "date";
}
export interface DateTimeType extends ConstantType {
data_type: "datetime";
}
export interface IntervalType extends ConstantType {
data_type: "unknown";
}
export interface UUIDType extends ConstantType {
data_type: "uuid";
}
export interface ArrayType extends ConstantType {
data_type: "array";
item_type: ConstantType;
}
export interface TupleType extends ConstantType {
data_type: "tuple";
item_types: ConstantType[];
repeat?: boolean;
}
export interface CallType extends Type {
name: string;
arg_types: ConstantType[];
param_types?: ConstantType[];
return_type: ConstantType;
}
export interface AsteriskType extends Type {
table_type: TableOrSelectType;
}
export interface FieldTraverserType extends Type {
chain: (string | number)[];
table_type: TableOrSelectType;
}
export interface ExpressionFieldType extends Type {
name: string;
expr: Expr;
table_type: TableOrSelectType;
isolate_scope?: boolean;
}
export interface FieldType extends Type {
name: string;
table_type: TableOrSelectType;
}
export interface UnresolvedFieldType extends Type {
name: string;
}
export interface PropertyType extends Type {
chain: (string | number)[];
field_type: FieldType;
joined_subquery?: SelectQueryAliasType;
joined_subquery_field_name?: string;
}
export interface LambdaArgumentType extends Type {
name: string;
}
// Enums
export enum ArithmeticOperationOp {
Add = "+",
Sub = "-",
Mult = "*",
Div = "/",
Mod = "%",
}
export enum CompareOperationOp {
Eq = "==",
NotEq = "!=",
Gt = ">",
GtEq = ">=",
Lt = "<",
LtEq = "<=",
Like = "like",
ILike = "ilike",
NotLike = "not like",
NotILike = "not ilike",
In = "in",
GlobalIn = "global in",
NotIn = "not in",
GlobalNotIn = "global not in",
InCohort = "in cohort",
NotInCohort = "not in cohort",
Regex = "=~",
IRegex = "=~*",
NotRegex = "!~",
NotIRegex = "!~*",
}
export const NEGATED_COMPARE_OPS: CompareOperationOp[] = [
CompareOperationOp.NotEq,
CompareOperationOp.NotLike,
CompareOperationOp.NotILike,
CompareOperationOp.NotIn,
CompareOperationOp.GlobalNotIn,
CompareOperationOp.NotInCohort,
CompareOperationOp.NotRegex,
CompareOperationOp.NotIRegex,
];
export type SetOperator =
| "UNION ALL"
| "UNION DISTINCT"
| "INTERSECT"
| "INTERSECT DISTINCT"
| "EXCEPT";
// Declaration and Statement types
export interface Declaration extends AST {}
export interface VariableAssignment extends Declaration {
left: Expr;
right: Expr;
}
export interface VariableDeclaration extends Declaration {
name: string;
expr?: Expr;
}
export interface Statement extends Declaration {}
export interface ExprStatement extends Statement {
expr?: Expr;
}
export interface ReturnStatement extends Statement {
expr?: Expr;
}
export interface ThrowStatement extends Statement {
expr: Expr;
}
export interface TryCatchStatement extends Statement {
try_stmt: Statement;
catches: [string | null, string | null, Statement][];
finally_stmt?: Statement;
}
export interface IfStatement extends Statement {
expr: Expr;
then: Statement;
else_?: Statement;
}
export interface WhileStatement extends Statement {
expr: Expr;
body: Statement;
}
export interface ForStatement extends Statement {
initializer?: VariableDeclaration | VariableAssignment | Expr;
condition?: Expr;
increment?: Expr;
body: Statement;
}
export interface ForInStatement extends Statement {
keyVar?: string;
valueVar: string;
expr: Expr;
body: Statement;
}
export interface Function extends Statement {
name: string;
params: string[];
body: Statement;
}
export interface Block extends Statement {
declarations: Declaration[];
}
export interface Program extends AST {
declarations: Declaration[];
}
// Expression types
export interface Alias extends Expr {
alias: string;
expr: Expr;
hidden?: boolean;
from_asterisk?: boolean;
}
export interface ArithmeticOperation extends Expr {
left: Expr;
right: Expr;
op: ArithmeticOperationOp;
}
export interface And extends Expr {
type?: ConstantType;
exprs: Expr[];
}
export interface Or extends Expr {
exprs: Expr[];
type?: ConstantType;
}
export interface CompareOperation extends Expr {
left: Expr;
right: Expr;
op: CompareOperationOp;
type?: ConstantType;
}
export interface Not extends Expr {
expr: Expr;
type?: ConstantType;
}
export interface BetweenExpr extends Expr {
expr: Expr;
low: Expr;
high: Expr;
negated?: boolean;
type?: ConstantType;
}
export interface OrderExpr extends Expr {
expr: Expr;
order?: "ASC" | "DESC";
}
export interface ArrayAccess extends Expr {
array: Expr;
property: Expr;
nullish?: boolean;
}
export interface Array extends Expr {
exprs: Expr[];
}
export interface Dict extends Expr {
items: [Expr, Expr][];
}
export interface TupleAccess extends Expr {
tuple: Expr;
index: number;
nullish?: boolean;
}
export interface Tuple extends Expr {
exprs: Expr[];
}
export interface Lambda extends Expr {
args: string[];
expr: Expr | Block;
}
export interface Constant extends Expr {
value: any;
}
export interface Field extends Expr {
chain: (string | number)[];
from_asterisk?: boolean;
}
export interface Placeholder extends Expr {
expr: Expr;
// Computed properties
chain?: (string | number)[] | null;
field?: string | null;
}
export interface Call extends Expr {
name: string;
args: Expr[];
params?: Expr[];
distinct?: boolean;
}
export interface ExprCall extends Expr {
expr: Expr;
args: Expr[];
}
export interface JoinConstraint extends Expr {
expr: Expr;
constraint_type: "ON" | "USING";
}
export interface JoinExpr extends Expr {
type?: TableOrSelectType;
join_type?: string;
table?: SelectQuery | SelectSetQuery | Placeholder | HogQLXTag | Field;
table_args?: Expr[];
alias?: string;
table_final?: boolean;
constraint?: JoinConstraint;
next_join?: JoinExpr;
sample?: SampleExpr;
}
export interface WindowFrameExpr extends Expr {
frame_type?: "CURRENT ROW" | "PRECEDING" | "FOLLOWING";
frame_value?: number;
}
export interface WindowExpr extends Expr {
partition_by?: Expr[];
order_by?: OrderExpr[];
frame_method?: "ROWS" | "RANGE";
frame_start?: WindowFrameExpr;
frame_end?: WindowFrameExpr;
}
export interface WindowFunction extends Expr {
name: string;
args?: Expr[];
exprs?: Expr[];
over_expr?: WindowExpr;
over_identifier?: string;
}
export interface LimitByExpr extends Expr {
n: Expr;
exprs: Expr[];
offset_value?: Expr;
}
export interface SelectQuery extends Expr {
type?: SelectQueryType;
ctes?: Record<string, CTE>;
select: Expr[];
distinct?: boolean;
select_from?: JoinExpr;
array_join_op?: string;
array_join_list?: Expr[];
window_exprs?: Record<string, WindowExpr>;
where?: Expr;
prewhere?: Expr;
having?: Expr;
group_by?: Expr[];
order_by?: OrderExpr[];
limit?: Expr;
limit_by?: LimitByExpr;
limit_with_ties?: boolean;
offset?: Expr;
settings?: HogQLQuerySettings;
view_name?: string;
}
export interface SelectSetNode extends AST {
select_query: SelectQuery | SelectSetQuery;
set_operator: SetOperator;
}
export interface SelectSetQuery extends Expr {
type?: SelectSetQueryType;
initial_select_query: SelectQuery | SelectSetQuery;
subsequent_select_queries: SelectSetNode[];
// Equivalent to select_queries() method
select_queries?(): (SelectQuery | SelectSetQuery)[];
}
// Add static method equivalent for SelectSetQuery.create_from_queries()
export namespace SelectSetQuery {
export function createFromQueries(
queries: (SelectQuery | SelectSetQuery)[],
set_operator: SetOperator
): SelectQuery | SelectSetQuery {
return createSelectSetQueryFromQueries(queries, set_operator);
}
}
export interface RatioExpr extends Expr {
left: Constant;
right?: Constant;
}
export interface SampleExpr extends Expr {
sample_value: RatioExpr;
offset_value?: RatioExpr;
}
export interface HogQLXAttribute extends AST {
name: string;
value: any;
}
export interface HogQLXTag extends Expr {
kind: string;
attributes: HogQLXAttribute[];
// Equivalent to to_dict() method
to_dict?(): Record<string, any>;
}
// Helper function to create empty SelectQuery (equivalent to SelectQuery.empty())
export function createEmptySelectQuery(columns?: Record<string, FieldOrTable>): SelectQuery {
if (!columns) {
columns = { _: { name: "_" } as UnknownDatabaseField };
}
return {
select: Object.entries(columns).map(([column, field]) => ({
alias: column,
expr: { value: (field as DatabaseField).default_value?.() ?? null } as Constant,
})) as Alias[],
where: { value: false } as Constant,
} as SelectQuery;
}
// Add static method equivalent for SelectQuery.empty()
export namespace SelectQuery {
export function empty(columns?: Record<string, FieldOrTable>): SelectQuery {
return createEmptySelectQuery(columns);
}
}
// Helper function for SelectSetQuery.select_queries()
export function selectQueries(query: SelectSetQuery): (SelectQuery | SelectSetQuery)[] {
return [
query.initial_select_query,
...query.subsequent_select_queries.map((node) => node.select_query),
];
}
// Helper function to create SelectSetQuery from multiple queries
export function createSelectSetQueryFromQueries(
queries: (SelectQuery | SelectSetQuery)[],
set_operator: SetOperator
): SelectQuery | SelectSetQuery {
if (queries.length === 0) {
throw new Error("Cannot create a SelectSetQuery from an empty list of queries");
} else if (queries.length === 1) {
return queries[0];
}
return {
initial_select_query: queries[0],
subsequent_select_queries: queries.slice(1).map((query) => ({
select_query: query,
set_operator,
})) as SelectSetNode[],
} as SelectSetQuery;
}
@@ -0,0 +1,72 @@
// TypeScript translation of posthog/hogql/constants.py
// Keep this file in sync with the Python version
export type ConstantDataType =
| "int"
| "float"
| "str"
| "bool"
| "array"
| "tuple"
| "date"
| "datetime"
| "uuid"
| "unknown";
export type ConstantSupportedPrimitive = number | string | boolean | Date | null;
export type ConstantSupportedData =
| ConstantSupportedPrimitive
| ConstantSupportedPrimitive[]
| [ConstantSupportedPrimitive, ...ConstantSupportedPrimitive[]];
export const KEYWORDS = ["true", "false", "null"] as const;
export const RESERVED_KEYWORDS = [...KEYWORDS, "team_id"] as const;
export const DEFAULT_RETURNED_ROWS = 100;
export const MAX_SELECT_RETURNED_ROWS = 50000;
export const MAX_SELECT_RETENTION_LIMIT = 100000;
export const MAX_SELECT_HEATMAPS_LIMIT = 1000000;
export const MAX_SELECT_COHORT_CALCULATION_LIMIT = 1000000000;
export const MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY = 22 * 1024 * 1024 * 1024;
export const CSV_EXPORT_LIMIT = 300000;
export const CSV_EXPORT_BREAKDOWN_LIMIT_INITIAL = 512;
export const CSV_EXPORT_BREAKDOWN_LIMIT_LOW = 64;
export const BREAKDOWN_VALUES_LIMIT = 25;
export const BREAKDOWN_VALUES_LIMIT_FOR_COUNTRIES = 300;
export enum LimitContext {
QUERY = "query",
QUERY_ASYNC = "query_async",
EXPORT = "export",
COHORT_CALCULATION = "cohort_calculation",
HEATMAPS = "heatmaps",
SAVED_QUERY = "saved_query",
RETENTION = "retention",
}
// Settings applied at the SELECT level
export interface HogQLQuerySettings {
optimize_aggregation_in_order?: boolean;
date_time_output_format?: string;
date_time_input_format?: string;
join_algorithm?: string;
}
// Settings applied on top of all HogQL queries
export interface HogQLGlobalSettings extends HogQLQuerySettings {
readonly?: number;
max_execution_time?: number;
max_memory_usage?: number;
max_threads?: number;
allow_experimental_object_type?: boolean;
format_csv_allow_double_quotes?: boolean;
max_ast_elements?: number;
max_expanded_ast_elements?: number;
max_bytes_before_external_group_by?: number;
allow_experimental_analyzer?: boolean;
transform_null_in?: boolean;
optimize_min_equality_disjunction_chain_length?: number;
allow_experimental_join_condition?: boolean;
preferred_block_size_bytes?: number;
use_hive_partitioning?: number;
}
@@ -0,0 +1,57 @@
// TypeScript translation of posthog/hogql/context.py
// Keep this file in sync with the Python version
import type { LimitContext } from "./constants";
import type { Database } from "./database";
import type { PropertySwapper } from "./property_types";
import type { HogQLTimings } from "./timings";
export interface HogQLNotice {
start?: number;
end?: number;
message: string;
fix?: string;
}
export interface HogQLQueryModifiers {
optimizeJoinedFilters?: boolean;
debug?: boolean;
timings?: boolean;
useMaterializedViews?: boolean;
formatCsvAllowDoubleQuotes?: boolean;
convertToProjectTimezone?: boolean;
usePreaggregatedTableTransforms?: boolean;
optimizeProjections?: boolean;
}
export interface HogQLFieldAccess {
input: string[];
type?: "run";
field?: string;
sql: string;
}
export interface Team {
id: number;
project_id: number;
}
export interface HogQLContext {
team_id?: number;
team?: Team;
database?: Database;
values: Record<string, any>;
within_non_hogql_query?: boolean;
enable_select_queries?: boolean;
limit_top_select?: boolean;
limit_context?: LimitContext;
output_format?: string | null;
globals?: Record<string, any>;
warnings: HogQLNotice[];
notices: HogQLNotice[];
errors: HogQLNotice[];
timings: HogQLTimings;
modifiers: HogQLQueryModifiers;
debug?: boolean;
property_swapper?: PropertySwapper;
}
@@ -0,0 +1,648 @@
// TypeScript translation of posthog/hogql/database/database.py
// Keep this file in sync with the Python version
//
// NOTE: This implementation requires database/ORM access for:
// - serialize() method (needs DataWarehouseTable, DataWarehouseSavedQuery queries)
// - create_for() method (needs Team, DataWarehouseJoin, DataWarehouseSavedQuery queries)
// Adapt these methods to your database/ORM setup
import type {
Table,
TableNode,
FieldOrTable,
DatabaseField,
ExpressionField,
FieldTraverser,
LazyJoin,
VirtualTable,
FunctionCallTable,
SavedQuery,
} from './models';
import type { HogQLContext, Team } from '../context';
import type { HogQLQueryModifiers } from '../context';
import type { HogQLTimings } from '../timings';
import type { Expr, ConstantType } from '../ast';
// Type imports for type checking
import type {
StringType,
BooleanType,
DateType,
DateTimeType,
UUIDType,
ArrayType,
TupleType,
IntegerType,
FloatType,
DecimalType,
} from '../ast';
import { QueryError, ResolutionError } from '../errors';
import { parseExpr } from '../parser';
import { HogQLTimings as HogQLTimingsClass } from '../timings';
// Type definitions for schema serialization (adapt to your schema types)
export interface DatabaseSchemaTable {
// Base schema table type
}
export interface DatabaseSchemaPostHogTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
}
export interface DatabaseSchemaSystemTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
}
export interface DatabaseSchemaDataWarehouseTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
format?: string;
url_pattern?: string;
schema?: DatabaseSchemaSchema;
source?: DatabaseSchemaSource;
row_count?: number;
}
export interface DatabaseSchemaViewTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
query: { query: string };
row_count?: number;
}
export interface DatabaseSchemaManagedViewTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
kind: string;
source_id?: string;
query: { query: string };
}
export interface DatabaseSchemaEndpointTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
query: { query: string };
row_count?: number;
status?: string;
}
export interface DatabaseSchemaField {
name: string;
hogql_value: string;
type: DatabaseSerializedFieldType;
schema_valid: boolean;
fields?: string[];
table?: string;
chain?: Array<string | number>;
id?: string;
}
export interface DatabaseSchemaSchema {
id: string;
name: string;
should_sync: boolean;
incremental: boolean;
status: string;
last_synced_at: string;
}
export interface DatabaseSchemaSource {
id: string;
status: string;
source_type: string;
prefix: string;
last_synced_at?: string | null;
}
export enum DatabaseSerializedFieldType {
STRING = 'string',
INTEGER = 'integer',
FLOAT = 'float',
DECIMAL = 'decimal',
BOOLEAN = 'boolean',
DATE = 'date',
DATETIME = 'datetime',
UUID = 'uuid',
ARRAY = 'array',
JSON = 'json',
TUPLE = 'tuple',
UNKNOWN = 'unknown',
EXPRESSION = 'expression',
VIEW = 'view',
LAZY_TABLE = 'lazy_table',
VIRTUAL_TABLE = 'virtual_table',
FIELD_TRAVERSER = 'field_traverser',
}
export interface SerializedField {
key: string;
name: string;
type: DatabaseSerializedFieldType;
schema_valid: boolean;
fields?: string[];
table?: string;
chain?: Array<string | number>;
}
import { TableNodeImpl } from './models';
export class Database {
// Users can query from the tables below
tables: TableNode;
private _warehouseTableNames: string[] = [];
private _warehouseSelfManagedTableNames: string[] = [];
private _viewTableNames: string[] = [];
private _timezone?: string | null;
private _weekStartDay?: string | null; // WeekStartDay enum
private _serializationErrors: Record<string, string> = {};
constructor(timezone?: string | null, weekStartDay?: string | null) {
// Initialize with root TableNode
this.tables = new TableNodeImpl('root');
this._timezone = timezone || null;
this._weekStartDay = weekStartDay || null;
// NOTE: In Python, tables are initialized with all PostHog tables.
// You'll need to initialize these based on your table definitions.
// For now, this is a minimal structure.
}
getTimezone(): string {
return this._timezone || 'UTC';
}
getWeekStartDay(): string {
return this._weekStartDay || 'sunday'; // Adapt to your WeekStartDay enum
}
getSerializationErrors(): Record<string, string> {
/** Return any errors encountered during serialization. */
return { ...this._serializationErrors };
}
hasTable(tableName: string | string[]): boolean {
const path = typeof tableName === 'string' ? tableName.split('.') : tableName;
return this.tables.has_child ? this.tables.has_child(path) : false;
}
getTableNode(tableName: string | string[]): TableNode {
let path: string[];
if (typeof tableName === 'string') {
path = tableName.split('.');
} else {
path = tableName;
}
// Handle edge case where tableName is a list with a single string containing dots
if (path.length === 1 && path[0].includes('.')) {
path = path[0].split('.');
}
if (!this.tables.get_child) {
throw new ResolutionError(`TableNode.get_child not implemented`);
}
return this.tables.get_child(path);
}
getTable(tableName: string | string[]): Table {
try {
const node = this.getTableNode(tableName);
if (!node.get) {
throw new ResolutionError('TableNode.get not implemented');
}
const table = node.get();
if (!table || typeof table !== 'object' || !('fields' in table)) {
throw new ResolutionError('Table is not set');
}
return table as Table;
} catch (e) {
const name = Array.isArray(tableName) ? tableName.join('.') : tableName;
if (e instanceof ResolutionError) {
throw new QueryError(`Unknown table \`${name}\`.`);
}
throw e;
}
}
getAllTableNames(): string[] {
const warehouseTableNames = this._warehouseTableNames.filter((x) => x.includes('.'));
return [
...this.getPosthogTableNames(),
...warehouseTableNames,
...this._warehouseSelfManagedTableNames,
...this._viewTableNames,
];
}
// These are the tables exposed via SQL editor autocomplete and data management
getPosthogTableNames(): string[] {
return ['events', 'groups', 'persons', 'sessions', ...this.getSystemTableNames()];
}
getSystemTableNames(): string[] {
// NOTE: Adapt this based on your SystemTables implementation
const systemNode = this.tables.children['system'];
if (systemNode && systemNode.resolve_all_table_names) {
return ['query_log', ...systemNode.resolve_all_table_names()];
}
return ['query_log'];
}
getWarehouseTableNames(): string[] {
return [...this._warehouseTableNames, ...this._warehouseSelfManagedTableNames];
}
getViewNames(): string[] {
return this._viewTableNames;
}
private _addWarehouseTables(node: TableNode): void {
if (this.tables.merge_with) {
this.tables.merge_with(node);
}
if (node.resolve_all_table_names) {
const names = node.resolve_all_table_names();
this._warehouseTableNames.push(...names.sort());
}
}
private _addWarehouseSelfManagedTables(node: TableNode): void {
if (this.tables.merge_with) {
this.tables.merge_with(node);
}
if (node.resolve_all_table_names) {
const names = node.resolve_all_table_names();
this._warehouseSelfManagedTableNames.push(...names.sort());
}
}
private _addViews(node: TableNode): void {
if (this.tables.merge_with) {
this.tables.merge_with(node);
}
if (node.resolve_all_table_names) {
const names = node.resolve_all_table_names();
this._viewTableNames.push(...names.sort());
}
}
serialize(
context: HogQLContext,
includeOnly?: Set<string>
): Record<string, DatabaseSchemaTable> {
// NOTE: This method requires database queries to fetch:
// - DataWarehouseTable objects
// - DataWarehouseSavedQuery objects
// - External data sources and schemas
//
// Adapt this to your database/ORM setup
const tables: Record<string, DatabaseSchemaTable> = {};
if (!context.team_id) {
throw new ResolutionError('Must provide team_id to serialize database');
}
// PostHog tables
const posthogTableNames = this.getPosthogTableNames();
for (const tableName of posthogTableNames) {
if (includeOnly && !includeOnly.has(tableName)) {
continue;
}
let fieldInput: Record<string, FieldOrTable> = {};
const table = this.getTable(tableName);
if ('get_asterisk' in table && typeof table.get_asterisk === 'function') {
fieldInput = table.get_asterisk() || {};
} else if ('fields' in table) {
fieldInput = table.fields;
}
const fields = serializeFields(fieldInput, context, tableName.split('.'), undefined, 'posthog');
const fieldsDict: Record<string, DatabaseSchemaField> = {};
for (const field of fields) {
fieldsDict[field.name] = field;
}
tables[tableName] = {
fields: fieldsDict,
id: tableName,
name: tableName,
} as DatabaseSchemaPostHogTable;
}
// System tables
const systemTables = this.getSystemTableNames();
for (const tableKey of systemTables) {
if (includeOnly && !includeOnly.has(tableKey)) {
continue;
}
let systemFieldInput: Record<string, FieldOrTable> = {};
const table = this.getTable(tableKey);
if ('get_asterisk' in table && typeof table.get_asterisk === 'function') {
systemFieldInput = table.get_asterisk() || {};
} else if ('fields' in table) {
systemFieldInput = table.fields;
}
const fields = serializeFields(systemFieldInput, context, tableKey.split('.'), undefined, 'posthog');
const fieldsDict: Record<string, DatabaseSchemaField> = {};
for (const field of fields) {
fieldsDict[field.name] = field;
}
tables[tableKey] = {
fields: fieldsDict,
id: tableKey,
name: tableKey,
} as DatabaseSchemaSystemTable;
}
// NOTE: Data Warehouse Tables and Views processing requires database queries
// Implement based on your database/ORM setup:
// - Fetch DataWarehouseTable objects
// - Fetch DataWarehouseSavedQuery objects
// - Process and serialize them
return tables;
}
static createFor(
teamId?: number,
options?: {
team?: Team;
modifiers?: HogQLQueryModifiers;
timings?: HogQLTimings;
}
): Database {
// NOTE: This method requires extensive database/ORM access:
// - Team model queries
// - DataWarehouseTable queries
// - DataWarehouseSavedQuery queries
// - DataWarehouseJoin queries
// - GroupTypeMapping queries
// - Feature flag checks
//
// This is a skeleton structure - adapt to your setup
const timings = options?.timings || new HogQLTimingsClass();
const { team, modifiers } = options || {};
// Validate team/teamId
if (!teamId && !team) {
throw new Error('Either team_id or team must be provided');
}
if (team && teamId && team.id !== teamId) {
throw new Error('team_id and team must be the same');
}
// NOTE: Fetch team from database if not provided
// const fetchedTeam = team || await Team.findById(teamId);
// Create database instance
const database = timings.measure('database', () => {
// NOTE: Get timezone and week_start_day from team
// const timezone = fetchedTeam.timezone;
// const weekStartDay = fetchedTeam.week_start_day;
return new Database(undefined, undefined);
});
// NOTE: Apply modifiers, setup tables, etc.
// This requires extensive database access and table setup logic
// See Python implementation for full details
return database;
}
}
// Helper functions
const HOGQL_CHARACTERS_TO_BE_WRAPPED = ['@', '-', '!', '$', '+'];
function constantTypeToSerializedFieldType(constantType: ConstantType): DatabaseSerializedFieldType | null {
// Type checking for ConstantType subtypes
// NOTE: In TypeScript, we need to check properties rather than instanceof
// since these are interfaces, not classes
if ('data_type' in constantType) {
const dataType = constantType.data_type;
if (dataType === 'str') {
return DatabaseSerializedFieldType.STRING;
}
if (dataType === 'bool') {
return DatabaseSerializedFieldType.BOOLEAN;
}
if (dataType === 'date') {
return DatabaseSerializedFieldType.DATE;
}
if (dataType === 'datetime') {
return DatabaseSerializedFieldType.DATETIME;
}
if (dataType === 'uuid') {
return DatabaseSerializedFieldType.STRING;
}
if (dataType === 'array') {
return DatabaseSerializedFieldType.ARRAY;
}
if (dataType === 'tuple') {
return DatabaseSerializedFieldType.JSON;
}
if (dataType === 'int') {
return DatabaseSerializedFieldType.INTEGER;
}
if (dataType === 'float') {
return DatabaseSerializedFieldType.FLOAT;
}
}
// Fallback: check print_type if available
if ('print_type' in constantType && typeof constantType.print_type === 'function') {
const printed = constantType.print_type();
if (printed === 'String' || printed === 'JSON' || printed === 'Array') {
return printed === 'String'
? DatabaseSerializedFieldType.STRING
: printed === 'JSON'
? DatabaseSerializedFieldType.JSON
: DatabaseSerializedFieldType.ARRAY;
}
if (printed === 'Boolean') return DatabaseSerializedFieldType.BOOLEAN;
if (printed === 'Date') return DatabaseSerializedFieldType.DATE;
if (printed === 'DateTime') return DatabaseSerializedFieldType.DATETIME;
if (printed === 'UUID') return DatabaseSerializedFieldType.STRING;
if (printed === 'Integer') return DatabaseSerializedFieldType.INTEGER;
if (printed === 'Float') return DatabaseSerializedFieldType.FLOAT;
if (printed === 'Decimal') return DatabaseSerializedFieldType.DECIMAL;
}
return null;
}
export function serializeFields(
fieldInput: Record<string, FieldOrTable>,
context: HogQLContext,
tableChain: string[],
dbColumns?: Record<string, any>, // DataWarehouseTableColumns
tableType: 'posthog' | 'external' = 'posthog'
): DatabaseSchemaField[] {
// NOTE: This requires resolve_types_from_table from resolver
// Import as needed: import { resolveTypesFromTable } from '../resolver';
const fieldOutput: DatabaseSchemaField[] = [];
for (const [fieldKey, field] of Object.entries(fieldInput)) {
let schemaValid = true;
if (dbColumns) {
const column = dbColumns[fieldKey];
if (typeof column === 'string') {
schemaValid = true;
} else if (column && typeof column === 'object') {
schemaValid = column.valid !== false;
}
}
let hogqlValue: string;
if (HOGQL_CHARACTERS_TO_BE_WRAPPED.some((char) => fieldKey.includes(char))) {
hogqlValue = `\`${fieldKey}\``;
} else {
hogqlValue = fieldKey;
}
if ('hidden' in field && field.hidden) {
continue;
}
if (fieldKey === 'team_id' && tableType === 'posthog') {
// Skip team_id for posthog tables
continue;
} else if ('name' in field && 'get_constant_type' in field) {
// DatabaseField
const dbField = field as DatabaseField;
let fieldType: DatabaseSerializedFieldType;
// Determine field type based on DatabaseField subclass
// NOTE: You'll need to check instanceof or use type guards
// For now, using a simplified approach
if (dbField.get_constant_type) {
const constantType = dbField.get_constant_type();
fieldType = constantTypeToSerializedFieldType(constantType) || DatabaseSerializedFieldType.UNKNOWN;
} else {
fieldType = DatabaseSerializedFieldType.UNKNOWN;
}
fieldOutput.push({
name: fieldKey,
hogql_value: hogqlValue,
type: fieldType,
schema_valid: schemaValid,
});
} else if ('expr' in field) {
// ExpressionField
const exprField = field as ExpressionField;
// NOTE: Requires resolve_types_from_table
// const resolvedExpr = resolveTypesFromTable(exprField.expr, tableChain, context, 'hogql');
// const constantType = resolvedExpr.type?.resolve_constant_type(context);
// const fieldType = constantTypeToSerializedFieldType(constantType) || DatabaseSerializedFieldType.EXPRESSION;
fieldOutput.push({
name: fieldKey,
hogql_value: hogqlValue,
type: DatabaseSerializedFieldType.EXPRESSION,
schema_valid: schemaValid,
});
} else if ('resolve_table' in field) {
// LazyJoin
const lazyJoin = field as LazyJoin;
if (lazyJoin.resolve_table) {
const resolvedTable = lazyJoin.resolve_table(context);
const type =
'id' in resolvedTable && resolvedTable.id
? DatabaseSerializedFieldType.VIEW
: DatabaseSerializedFieldType.LAZY_TABLE;
fieldOutput.push({
name: fieldKey,
hogql_value: hogqlValue,
type,
schema_valid: schemaValid,
table: resolvedTable.to_printed_hogql ? resolvedTable.to_printed_hogql() : fieldKey,
fields: 'fields' in resolvedTable ? Object.keys(resolvedTable.fields) : [],
id: 'id' in resolvedTable && resolvedTable.id ? String(resolvedTable.id) : fieldKey,
});
}
} else if ('fields' in field && !('resolve_table' in field)) {
// VirtualTable
const virtualTable = field as VirtualTable;
fieldOutput.push({
name: fieldKey,
hogql_value: hogqlValue,
type: DatabaseSerializedFieldType.VIRTUAL_TABLE,
schema_valid: schemaValid,
table: virtualTable.to_printed_hogql ? virtualTable.to_printed_hogql() : fieldKey,
fields: Object.keys(virtualTable.fields),
});
} else if ('chain' in field) {
// FieldTraverser
const traverser = field as FieldTraverser;
fieldOutput.push({
name: fieldKey,
hogql_value: hogqlValue,
type: DatabaseSerializedFieldType.FIELD_TRAVERSER,
schema_valid: schemaValid,
chain: traverser.chain,
});
}
}
return fieldOutput;
}
// Helper functions for database setup (simplified versions)
function usePersonPropertiesFromEvents(database: Database): void {
const table = database.getTable('events');
table.fields['person'] = { chain: ['poe'] } as FieldTraverser;
}
function usePersonIdFromPersonOverrides(database: Database): void {
const table = database.getTable('events');
table.fields['event_person_id'] = { name: 'person_id' } as DatabaseField;
// NOTE: Setup LazyJoin and ExpressionField for override logic
// This requires complex setup - see Python implementation
}
function useErrorTrackingIssueIdFromErrorTrackingIssueOverrides(database: Database): void {
const table = database.getTable('events');
// NOTE: Setup ExpressionField and LazyJoin for error tracking
// See Python implementation for details
}
function setupGroupKeyFields(database: Database, team: Team): void {
// NOTE: Requires GroupTypeMapping queries from database
// See Python implementation for full logic
const table = database.getTable('events');
// Setup group key fields based on GroupTypeMapping
}
function useVirtualFields(
database: Database,
modifiers: HogQLQueryModifiers,
timings: HogQLTimings
): void {
// NOTE: Requires channel type creation functions
// See Python implementation for full virtual field setup
const eventsTable = database.getTable('events');
const personsTable = database.getTable('persons');
const groupsTable = database.getTable('groups');
// Setup virtual fields like initial_referring_domain_type, initial_channel_type, revenue fields
}
@@ -0,0 +1,62 @@
// TypeScript translation of posthog/hogql/errors.py
// Keep this file in sync with the Python version
import type { Expr } from "./ast";
export class BaseHogQLError extends Error {
message: string;
start?: number;
end?: number;
constructor(
message: string,
options?: {
start?: number;
end?: number;
node?: Expr;
}
) {
super(message);
this.message = message;
if (options?.node && options.node.start !== undefined && options.node.end !== undefined) {
this.start = options.node.start;
this.end = options.node.end;
} else {
this.start = options?.start;
this.end = options?.end;
}
}
}
export class ExposedHogQLError extends BaseHogQLError {
/** An exception that can be exposed to the user. */
}
export class InternalHogQLError extends BaseHogQLError {
/** An internal exception in the HogQL engine. */
}
export class SyntaxError extends ExposedHogQLError {
/** The input does not conform to HogQL syntax. */
}
export class QueryError extends ExposedHogQLError {
/** The query is invalid, though correct syntactically. */
}
export class NotImplementedError extends InternalHogQLError {
/** This feature isn't implemented in HogQL (yet). */
}
export class ParsingError extends InternalHogQLError {
/** Parsing failed. */
}
export class ImpossibleASTError extends InternalHogQLError {
/** Parsing or resolution resulted in an impossible AST. */
}
export class ResolutionError extends InternalHogQLError {
/** Resolution of a table/field/expression failed. */
}
+254
View File
@@ -0,0 +1,254 @@
// TypeScript translation of posthog/hogql/database/models.py
// Keep this file in sync with the Python version
import type { Expr, ConstantType } from "./ast";
import type { HogQLContext } from "./context";
export interface FieldOrTable {
hidden?: boolean;
}
export interface DatabaseField extends FieldOrTable {
name: string;
array?: boolean;
nullable?: boolean;
is_nullable?(): boolean;
get_constant_type?(): ConstantType;
default_value?(): any;
}
export interface IntegerDatabaseField extends DatabaseField {}
export interface FloatDatabaseField extends DatabaseField {}
export interface DecimalDatabaseField extends DatabaseField {}
export interface StringDatabaseField extends DatabaseField {}
export interface UnknownDatabaseField extends DatabaseField {}
export interface StringJSONDatabaseField extends DatabaseField {}
export interface StringArrayDatabaseField extends DatabaseField {}
export interface FloatArrayDatabaseField extends DatabaseField {}
export interface DateDatabaseField extends DatabaseField {}
export interface DateTimeDatabaseField extends DatabaseField {}
export interface BooleanDatabaseField extends DatabaseField {}
export interface UUIDDatabaseField extends DatabaseField {}
export interface ExpressionField extends DatabaseField {
expr: Expr;
isolate_scope?: boolean;
}
export interface FieldTraverser extends FieldOrTable {
chain: Array<string | number>;
}
export interface Table extends FieldOrTable {
fields: Record<string, FieldOrTable>;
has_field?(name: string | number): boolean;
get_field?(name: string | number): FieldOrTable;
to_printed_clickhouse?(context: HogQLContext): string;
to_printed_hogql?(): string;
avoid_asterisk_fields?(): string[];
get_asterisk?(): Record<string, FieldOrTable>;
}
export interface LazyJoin extends FieldOrTable {
join_function?(from_table: Table, to_table: Table, requesting_table: Table): Expr;
resolve_table?(context: HogQLContext): Table;
}
export interface LazyTable extends Table {}
export interface VirtualTable extends Table {}
export interface SavedQuery extends Table {
query: Expr;
}
export interface FunctionCallTable extends Table {
call_function?(context: HogQLContext): Expr;
}
export interface TableNode {
name: "root" | string;
table?: FieldOrTable | null;
children: Record<string, TableNode>;
get?(): FieldOrTable;
has_child?(path: string[]): boolean;
get_child?(path: string[]): TableNode;
add_child?(
child: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void;
merge_with?(
other: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void;
resolve_all_table_names?(): string[];
}
// Basic TableNode implementation class
export class TableNodeImpl implements TableNode {
name: "root" | string;
table?: FieldOrTable | null;
children: Record<string, TableNode>;
constructor(name: "root" | string = "root", table?: FieldOrTable | null) {
this.name = name;
this.table = table || null;
this.children = {};
}
get(): FieldOrTable {
if (this.table === null || this.table === undefined) {
throw new Error(`Table is not set at \`${this.name}\``);
}
return this.table;
}
has_child(path: string[]): boolean {
if (path.length === 0) {
return this.table !== null && this.table !== undefined;
}
const [first, ...restOfPath] = path;
if (!(first in this.children)) {
return false;
}
return this.children[first].has_child ? this.children[first].has_child!(restOfPath) : false;
}
get_child(path: string[]): TableNode {
if (path.length === 0) {
return this;
}
const [first, ...restOfPath] = path;
if (!(first in this.children)) {
throw new Error(`Unknown child \`${first}\` at \`${this.name}\`.`);
}
return this.children[first].get_child
? this.children[first].get_child!(restOfPath)
: this.children[first];
}
add_child(
child: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void {
const tableConflictMode = options?.table_conflict_mode || "ignore";
const childrenConflictMode = options?.children_conflict_mode || "merge";
if (child.name in this.children) {
if (childrenConflictMode === "override") {
this.children[child.name] = child;
} else if (childrenConflictMode === "merge") {
const existing = this.children[child.name];
if (existing.merge_with) {
existing.merge_with(child, {
table_conflict_mode: tableConflictMode,
children_conflict_mode: childrenConflictMode,
});
}
}
// ignore mode: do nothing
return;
}
this.children[child.name] = child;
}
merge_with(
other: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void {
const tableConflictMode = options?.table_conflict_mode || "ignore";
const childrenConflictMode = options?.children_conflict_mode || "merge";
if (other.table !== null && other.table !== undefined) {
if (this.table === null || this.table === undefined) {
this.table = other.table;
} else {
// Conflict - check conflict mode
if (tableConflictMode === "override") {
this.table = other.table;
}
// ignore mode: do nothing
}
}
for (const child of Object.values(other.children)) {
this.add_child(child, {
table_conflict_mode: tableConflictMode,
children_conflict_mode: childrenConflictMode,
});
}
}
resolve_all_table_names(): string[] {
const names: string[] = [];
if (this.table !== null && this.table !== undefined) {
names.push(this.name);
}
for (const child of Object.values(this.children)) {
const childNames = child.resolve_all_table_names ? child.resolve_all_table_names() : [];
// The root node should NOT include itself in the names
if (this.name === "root") {
names.push(...childNames);
} else {
names.push(...childNames.map((x) => `${this.name}.${x}`));
}
}
return names;
}
static createNestedForChain(chain: string[], table: Table): TableNode {
if (chain.length === 0) {
throw new Error("Chain must have at least one element");
}
const start = new TableNodeImpl(chain[0]);
let current: TableNode = start;
for (let i = 1; i < chain.length; i++) {
const child = new TableNodeImpl(chain[i]);
if (current.add_child) {
current.add_child(child);
} else {
current.children[child.name] = child;
}
current = child;
}
current.table = table;
return start;
}
}
export interface LazyTableToAdd {
lazy_table: LazyTable;
fields_accessed: Record<string, Array<string | number>>;
}
export interface LazyJoinToAdd {
from_table: string;
to_table: string;
lazy_join: LazyJoin;
lazy_join_type: any; // LazyJoinType from ast.ts
fields_accessed: Record<string, Array<string | number>>;
}
@@ -0,0 +1,65 @@
// TypeScript translation of posthog/hogql/parse_string.py
// Keep this file in sync with the Python version
import { SyntaxError } from './errors';
function replaceCommonEscapeCharacters(text: string): string {
// copied from clickhouse_driver/util/escape.py
// Note: \a (bell) and \v (vertical tab) are not directly supported in JavaScript strings
// but we handle them as escape sequences that get replaced
text = text.replace(/\\b/g, '\b');
text = text.replace(/\\f/g, '\f');
text = text.replace(/\\r/g, '\r');
text = text.replace(/\\n/g, '\n');
text = text.replace(/\\t/g, '\t');
text = text.replace(/\\0/g, ''); // NUL characters are ignored
text = text.replace(/\\a/g, '\x07'); // Bell character (ASCII 7)
text = text.replace(/\\v/g, '\x0B'); // Vertical tab (ASCII 11)
text = text.replace(/\\\\/g, '\\');
return text;
}
export function parseStringLiteralText(text: string): string {
/** Converts a string received from antlr via ctx.getText() into a JavaScript string */
let result: string;
if (text.startsWith("'") && text.endsWith("'")) {
result = text.slice(1, -1);
result = result.replace(/''/g, "'");
result = result.replace(/\\'/g, "'");
} else if (text.startsWith('"') && text.endsWith('"')) {
result = text.slice(1, -1);
result = result.replace(/""/g, '"');
result = result.replace(/\\"/g, '"');
} else if (text.startsWith('`') && text.endsWith('`')) {
result = text.slice(1, -1);
result = result.replace(/``/g, '`');
result = result.replace(/\\`/g, '`');
} else if (text.startsWith('{') && text.endsWith('}')) {
result = text.slice(1, -1);
result = result.replace(/{{/g, '{');
result = result.replace(/\\{/g, '{');
} else {
throw new SyntaxError(`Invalid string literal, must start and end with the same quote type: ${text}`);
}
return replaceCommonEscapeCharacters(result);
}
export function parseStringLiteralCtx(ctx: { getText(): string }): string {
/** Converts a STRING_LITERAL received from antlr via ctx.getText() into a JavaScript string */
const text = ctx.getText();
return parseStringLiteralText(text);
}
export function parseStringTextCtx(ctx: { getText(): string }, escapeQuotes: boolean = true): string {
/** Converts a STRING_TEXT received from antlr via ctx.getText() into a JavaScript string */
let text = ctx.getText();
if (escapeQuotes) {
text = text.replace(/''/g, "'");
text = text.replace(/\\'/g, "'");
}
text = text.replace(/\\{/g, '{');
return replaceCommonEscapeCharacters(text);
}
@@ -0,0 +1,359 @@
import { describe, it, expect } from "vitest";
import { CharStreams, CommonTokenStream } from "antlr4ts";
import { TSQLLexer } from "../grammar/TSQLLexer.js";
import { TSQLParser } from "../grammar/TSQLParser.js";
import { TSQLParseTreeConverter } from "./parser.js";
import type {
SelectQuery,
SelectSetQuery,
Field,
Constant,
Call,
CompareOperation,
ArithmeticOperation,
Alias,
JoinExpr,
} from "./ast.js";
import { ArithmeticOperationOp, CompareOperationOp } from "./ast.js";
import { SyntaxError } from "./errors.js";
/**
* Helper function to parse TSQL input and convert to AST
*/
function parseAndConvert(input: string) {
const inputStream = CharStreams.fromString(input);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
const parseTree = parser.select();
const converter = new TSQLParseTreeConverter();
return converter.visit(parseTree);
}
describe("TSQLParseTreeConverter", () => {
describe("SELECT statements", () => {
it("should convert a simple SELECT statement", () => {
const ast = parseAndConvert("SELECT * FROM users");
expect(ast).toBeDefined();
expect("select" in ast).toBe(true);
const selectQuery = ast as SelectQuery;
expect(selectQuery.select).toBeDefined();
expect(selectQuery.select_from).toBeDefined();
expect(selectQuery.select.length).toBe(1);
expect((selectQuery.select[0] as Field).chain).toEqual(["*"]);
});
it("should convert SELECT with multiple columns", () => {
const ast = parseAndConvert("SELECT id, name, email FROM users");
const selectQuery = ast as SelectQuery;
expect(selectQuery.select.length).toBe(3);
expect((selectQuery.select[0] as Field).chain).toEqual(["id"]);
expect((selectQuery.select[1] as Field).chain).toEqual(["name"]);
expect((selectQuery.select[2] as Field).chain).toEqual(["email"]);
});
it("should convert SELECT with DISTINCT", () => {
const ast = parseAndConvert("SELECT DISTINCT id FROM users");
const selectQuery = ast as SelectQuery;
expect(selectQuery.distinct).toBe(true);
});
it("should convert SELECT with WHERE clause", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id = 1");
const selectQuery = ast as SelectQuery;
expect(selectQuery.where).toBeDefined();
const whereExpr = selectQuery.where as CompareOperation;
expect(whereExpr.op).toBe(CompareOperationOp.Eq);
expect((whereExpr.left as Field).chain).toEqual(["id"]);
expect((whereExpr.right as Constant).value).toBe(1);
});
it("should convert SELECT with ORDER BY", () => {
const ast = parseAndConvert("SELECT * FROM users ORDER BY id DESC");
const selectQuery = ast as SelectQuery;
expect(selectQuery.order_by).toBeDefined();
expect(selectQuery.order_by!.length).toBe(1);
expect(selectQuery.order_by![0].order).toBe("DESC");
expect((selectQuery.order_by![0].expr as Field).chain).toEqual(["id"]);
});
it("should convert SELECT with LIMIT", () => {
const ast = parseAndConvert("SELECT * FROM users LIMIT 10");
const selectQuery = ast as SelectQuery;
expect(selectQuery.limit).toBeDefined();
expect((selectQuery.limit as Constant).value).toBe(10);
});
it("should convert SELECT with LIMIT and OFFSET", () => {
const ast = parseAndConvert("SELECT * FROM users LIMIT 10 OFFSET 5");
const selectQuery = ast as SelectQuery;
expect(selectQuery.limit).toBeDefined();
expect((selectQuery.limit as Constant).value).toBe(10);
expect(selectQuery.offset).toBeDefined();
expect((selectQuery.offset as Constant).value).toBe(5);
});
it("should convert SELECT with GROUP BY", () => {
const ast = parseAndConvert("SELECT category, COUNT(*) FROM products GROUP BY category");
const selectQuery = ast as SelectQuery;
expect(selectQuery.group_by).toBeDefined();
expect(selectQuery.group_by!.length).toBe(1);
expect((selectQuery.group_by![0] as Field).chain).toEqual(["category"]);
});
it("should convert SELECT with HAVING", () => {
const ast = parseAndConvert(
"SELECT category FROM products GROUP BY category HAVING COUNT(*) > 10"
);
const selectQuery = ast as SelectQuery;
expect(selectQuery.having).toBeDefined();
const havingExpr = selectQuery.having as CompareOperation;
expect(havingExpr.op).toBe(CompareOperationOp.Gt);
});
});
describe("expressions", () => {
it("should convert numeric constants", () => {
const ast = parseAndConvert("SELECT 42 FROM users");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.select[0] as Constant;
expect(expr.value).toBe(42);
});
it("should convert string constants", () => {
const ast = parseAndConvert("SELECT 'hello' FROM users");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.select[0] as Constant;
expect(expr.value).toBe("hello");
});
it("should convert boolean constants", () => {
const ast = parseAndConvert("SELECT true FROM users");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.select[0] as Constant;
expect(expr.value).toBe(true);
});
it("should convert arithmetic addition", () => {
const ast = parseAndConvert("SELECT 1 + 2 FROM users");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.select[0] as ArithmeticOperation;
expect(expr.op).toBe(ArithmeticOperationOp.Add);
expect((expr.left as Constant).value).toBe(1);
expect((expr.right as Constant).value).toBe(2);
});
it("should convert arithmetic subtraction", () => {
const ast = parseAndConvert("SELECT 5 - 3 FROM users");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.select[0] as ArithmeticOperation;
expect(expr.op).toBe(ArithmeticOperationOp.Sub);
});
it("should convert arithmetic multiplication", () => {
const ast = parseAndConvert("SELECT 2 * 3 FROM users");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.select[0] as ArithmeticOperation;
expect(expr.op).toBe(ArithmeticOperationOp.Mult);
});
it("should convert arithmetic division", () => {
const ast = parseAndConvert("SELECT 10 / 2 FROM users");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.select[0] as ArithmeticOperation;
expect(expr.op).toBe(ArithmeticOperationOp.Div);
});
it("should convert comparison equals", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id = 1");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.where as CompareOperation;
expect(expr.op).toBe(CompareOperationOp.Eq);
});
it("should convert comparison not equals", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id != 1");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.where as CompareOperation;
expect(expr.op).toBe(CompareOperationOp.NotEq);
});
it("should convert comparison less than", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id < 10");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.where as CompareOperation;
expect(expr.op).toBe(CompareOperationOp.Lt);
});
it("should convert comparison greater than", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id > 5");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.where as CompareOperation;
expect(expr.op).toBe(CompareOperationOp.Gt);
});
it("should convert LIKE comparison", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE name LIKE '%john%'");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.where as CompareOperation;
expect(expr.op).toBe(CompareOperationOp.Like);
});
it("should convert IN comparison", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id IN (1, 2, 3)");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.where as CompareOperation;
expect(expr.op).toBe(CompareOperationOp.In);
});
it("should convert function calls", () => {
const ast = parseAndConvert("SELECT COUNT(*) FROM users");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.select[0] as Call;
expect(expr.name).toBe("count");
});
it("should convert nested field access", () => {
const ast = parseAndConvert("SELECT user.profile.name FROM users");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.select[0] as Field;
expect(expr.chain).toEqual(["user", "profile", "name"]);
});
it("should convert aliased expressions", () => {
const ast = parseAndConvert("SELECT id AS user_id FROM users");
const selectQuery = ast as SelectQuery;
const expr = selectQuery.select[0] as Alias;
expect(expr.alias).toBe("user_id");
expect((expr.expr as Field).chain).toEqual(["id"]);
});
});
describe("JOINs", () => {
it("should convert INNER JOIN", () => {
const ast = parseAndConvert(
"SELECT * FROM users INNER JOIN orders ON users.id = orders.user_id"
);
const selectQuery = ast as SelectQuery;
expect(selectQuery.select_from).toBeDefined();
const joinExpr = selectQuery.select_from as JoinExpr;
expect(joinExpr.next_join).toBeDefined();
expect(joinExpr.next_join!.join_type).toBe("INNER JOIN");
expect(joinExpr.next_join!.constraint).toBeDefined();
expect(joinExpr.next_join!.constraint!.constraint_type).toBe("ON");
});
it("should convert LEFT JOIN", () => {
const ast = parseAndConvert(
"SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id"
);
const selectQuery = ast as SelectQuery;
const joinExpr = selectQuery.select_from as JoinExpr;
expect(joinExpr.next_join!.join_type).toContain("LEFT");
});
it("should convert CROSS JOIN", () => {
const ast = parseAndConvert("SELECT * FROM users CROSS JOIN orders");
const selectQuery = ast as SelectQuery;
const joinExpr = selectQuery.select_from as JoinExpr;
expect(joinExpr.next_join!.join_type).toBe("CROSS JOIN");
});
});
describe("UNION queries", () => {
it("should convert UNION query", () => {
const ast = parseAndConvert("SELECT id FROM users UNION SELECT id FROM customers");
expect("initial_select_query" in ast).toBe(true);
const setQuery = ast as SelectSetQuery;
expect(setQuery.initial_select_query).toBeDefined();
expect(setQuery.subsequent_select_queries).toBeDefined();
expect(setQuery.subsequent_select_queries.length).toBe(1);
expect(setQuery.subsequent_select_queries[0].set_operator).toBe("UNION");
});
it("should convert UNION ALL query", () => {
const ast = parseAndConvert("SELECT id FROM users UNION ALL SELECT id FROM customers");
const setQuery = ast as SelectSetQuery;
expect(setQuery.subsequent_select_queries[0].set_operator).toBe("UNION ALL");
});
});
describe("WITH clauses (CTEs)", () => {
it("should convert SELECT with WITH clause", () => {
const ast = parseAndConvert(
"WITH recent_users AS (SELECT * FROM users WHERE created_at > '2024-01-01') SELECT * FROM recent_users"
);
const selectQuery = ast as SelectQuery;
expect(selectQuery.ctes).toBeDefined();
expect(selectQuery.ctes!["recent_users"]).toBeDefined();
expect(selectQuery.ctes!["recent_users"].cte_type).toBe("subquery");
});
});
describe("error handling", () => {
it("should preserve position information in errors", () => {
const input = "SELECT * FROM users WHERE invalid syntax";
const inputStream = CharStreams.fromString(input);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
// This might not parse correctly, but if it does and we visit an error node,
// it should throw with position info
try {
const parseTree = parser.select();
const converter = new TSQLParseTreeConverter();
converter.visit(parseTree);
} catch (error) {
if (error instanceof SyntaxError) {
// Error should have position information if available
expect(error).toBeInstanceOf(SyntaxError);
}
}
});
});
describe("complex queries", () => {
it("should convert a complex query with multiple clauses", () => {
const ast = parseAndConvert(
"SELECT category, COUNT(*) as count " +
"FROM products " +
"WHERE price > 100 " +
"GROUP BY category " +
"HAVING COUNT(*) > 5 " +
"ORDER BY count DESC " +
"LIMIT 10"
);
const selectQuery = ast as SelectQuery;
expect(selectQuery.select.length).toBe(2);
expect(selectQuery.where).toBeDefined();
expect(selectQuery.group_by).toBeDefined();
expect(selectQuery.having).toBeDefined();
expect(selectQuery.order_by).toBeDefined();
expect(selectQuery.limit).toBeDefined();
});
it("should convert query with multiple JOINs", () => {
const ast = parseAndConvert(
"SELECT * FROM users " +
"INNER JOIN orders ON users.id = orders.user_id " +
"LEFT JOIN products ON orders.product_id = products.id"
);
const selectQuery = ast as SelectQuery;
const joinExpr = selectQuery.select_from as JoinExpr;
expect(joinExpr.next_join).toBeDefined();
expect(joinExpr.next_join!.next_join).toBeDefined();
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,716 @@
// TypeScript translation of posthog/hogql/transforms/property_types.py
// Keep this file in sync with the Python version
import type {
AST,
Expr,
Field,
PropertyType,
FieldType,
BaseTableType,
VirtualTableType,
LazyJoinType,
LazyTableType,
Call,
Constant,
CallType,
DateTimeType,
} from "./ast";
import type { HogQLContext } from "./context";
import type { BooleanDatabaseField, DateTimeDatabaseField, Table } from "./models";
// Helper function to escape HogQL identifiers
function escapeHogQLIdentifier(identifier: string | number): string {
if (typeof identifier === "number") {
return String(identifier);
}
if (identifier.includes("%")) {
throw new Error(
`The HogQL identifier "${identifier}" is not permitted as it contains the "%" character`
);
}
// HogQL allows dollars in the identifier
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier)) {
return identifier;
}
// Escape backticks and other special characters
const backquoteEscapeChars: Record<string, string> = {
"\b": "\\b",
"\f": "\\f",
"\r": "\\r",
"\n": "\\n",
"\t": "\\t",
"\0": "\\0",
a: "\\a",
"\v": "\\v",
"\\": "\\\\",
"`": "\\`",
};
return `\`${identifier
.split("")
.map((c) => backquoteEscapeChars[c] || c)
.join("")}\``;
}
// Visitor dispatcher - converts node type to visitor method name
// Matches Python's camel_case_pattern.sub("_", class_name).lower() logic
function getVisitorMethodName(node: AST): string {
// Get the constructor name or use a type guard to determine the type
const nodeType = (node as any).constructor?.name || detectNodeType(node);
if (!nodeType) {
return "visit_unknown";
}
// Convert CamelCase to snake_case (e.g., "PropertyType" -> "property_type")
const snakeCase = nodeType
.replace(/([A-Z])/g, "_$1")
.toLowerCase()
.replace(/^_/, "");
// Handle special cases (matching Python replacements)
const replacements: Record<string, string> = {
hog_qlxtag: "hogqlx_tag",
hog_qlxattribute: "hogqlx_attribute",
uuidtype: "uuid_type",
string_jsontype: "string_json_type",
};
return replacements[snakeCase] || snakeCase;
}
// Type detection helper (since we can't use instanceof with interfaces)
function detectNodeType(node: AST): string {
// Use property presence to detect node types
if ("chain" in node && "type" in node && !("name" in node)) {
return "Field";
}
if ("chain" in node && "field_type" in node) {
return "PropertyType";
}
if ("name" in node && "args" in node && !("expr" in node)) {
return "Call";
}
if ("value" in node && !("name" in node) && !("args" in node)) {
return "Constant";
}
// Add more type detection as needed
return "";
}
// Base visitor class - matches Python Visitor pattern
abstract class Visitor<T> {
visit(node: AST | null | undefined): T {
if (node === null || node === undefined) {
return node as T;
}
// Try using accept method if available (double dispatch)
if (node.accept) {
return node.accept(this) as T;
}
// Fallback: use dispatcher
const methodName = getVisitorMethodName(node);
const method = (this as any)[methodName];
if (method && typeof method === "function") {
return method.call(this, node) as T;
}
// Try visit_unknown as fallback
if ((this as any).visit_unknown) {
return (this as any).visit_unknown(node) as T;
}
throw new Error(`${this.constructor.name} has no method ${methodName} or visit_unknown`);
}
}
// TraversingVisitor - matches Python TraversingVisitor
class TraversingVisitor extends Visitor<void> {
visitPropertyType(node: PropertyType): void {
this.visit(node.field_type);
}
visitField(node: Field): void {
if (node.type) {
this.visit(node.type as any);
}
}
visitCall(node: Call): void {
for (const arg of node.args) {
this.visit(arg);
}
if (node.params) {
for (const param of node.params) {
this.visit(param);
}
}
}
visitConstant(node: Constant): void {
if (node.type) {
this.visit(node.type as any);
}
}
// Default handler for unknown types - traverse common properties
visit_unknown(node: AST): void {
// Traverse children based on common AST node properties
if ("expr" in node) {
this.visit((node as any).expr);
}
if ("exprs" in node) {
for (const expr of (node as any).exprs) {
this.visit(expr);
}
}
if ("left" in node && "right" in node) {
this.visit((node as any).left);
this.visit((node as any).right);
}
if ("args" in node) {
for (const arg of (node as any).args) {
this.visit(arg);
}
}
if ("type" in node) {
this.visit((node as any).type);
}
}
}
// CloningVisitor - matches Python CloningVisitor
class CloningVisitor extends Visitor<any> {
protected clearTypes: boolean;
protected clearLocations: boolean;
constructor(clearTypes: boolean = true, clearLocations: boolean = false) {
super();
this.clearTypes = clearTypes;
this.clearLocations = clearLocations;
}
visitField(node: Field): Field {
return {
...node,
type: this.clearTypes ? undefined : node.type ? this.visit(node.type as any) : node.type,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
};
}
visitPropertyType(node: PropertyType): PropertyType {
return {
...node,
field_type: this.visit(node.field_type) as FieldType,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
};
}
visitCall(node: Call): Call {
return {
...node,
args: node.args.map((arg) => this.visit(arg)),
params: node.params ? node.params.map((param) => this.visit(param)) : undefined,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
type: this.clearTypes ? undefined : node.type,
};
}
visitConstant(node: Constant): Constant {
return {
...node,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
type: this.clearTypes ? undefined : node.type,
};
}
// Default handler for unknown types - shallow clone
visit_unknown(node: AST): any {
const cloned: any = { ...node };
// Clone common properties
if ("expr" in node) {
cloned.expr = this.visit((node as any).expr);
}
if ("exprs" in node) {
cloned.exprs = (node as any).exprs.map((e: any) => this.visit(e));
}
if ("left" in node && "right" in node) {
cloned.left = this.visit((node as any).left);
cloned.right = this.visit((node as any).right);
}
if ("args" in node) {
cloned.args = (node as any).args.map((a: any) => this.visit(a));
}
if ("type" in node) {
cloned.type = this.clearTypes ? undefined : this.visit((node as any).type);
}
if (this.clearLocations) {
cloned.start = undefined;
cloned.end = undefined;
}
return cloned;
}
}
// PropertyFinder: Traverses AST to find all property references
class PropertyFinder extends TraversingVisitor {
context: HogQLContext;
personProperties: Set<string> = new Set();
eventProperties: Set<string> = new Set();
groupProperties: Map<number, Set<string>> = new Map();
foundTimestamps: boolean = false;
constructor(context: HogQLContext) {
super();
this.context = context;
}
visitPropertyType(node: PropertyType): void {
if (node.field_type.name === "properties" && node.chain.length === 1) {
const tableType = node.field_type.table_type;
if (this.isBaseTableType(tableType)) {
const table = tableType.resolve_database_table?.(this.context);
if (table) {
const tableName = table.to_printed_hogql?.() || "";
const propertyName = String(node.chain[0]);
if (tableName === "persons" || tableName === "raw_persons") {
this.personProperties.add(propertyName);
} else if (tableName === "groups") {
if (this.isLazyJoinType(tableType)) {
if (tableType.field.startsWith("group_")) {
const groupId = parseInt(tableType.field.split("_")[1], 10);
if (!this.groupProperties.has(groupId)) {
this.groupProperties.set(groupId, new Set());
}
this.groupProperties.get(groupId)!.add(propertyName);
}
} else if (this.isLazyTableType(tableType)) {
const globalGroupId = this.context.globals?.group_id;
if (typeof globalGroupId === "number") {
if (!this.groupProperties.has(globalGroupId)) {
this.groupProperties.set(globalGroupId, new Set());
}
this.groupProperties.get(globalGroupId)!.add(propertyName);
}
}
} else if (tableName === "events") {
if (this.isVirtualTableType(tableType) && tableType.field === "poe") {
this.personProperties.add(propertyName);
} else {
this.eventProperties.add(propertyName);
}
}
}
}
}
super.visitPropertyType(node);
}
visitField(node: Field): void {
super.visitField(node);
if (this.isFieldType(node.type)) {
const dbField = (node.type as any).resolve_database_field?.(this.context);
if (this.isDateTimeDatabaseField(dbField)) {
this.foundTimestamps = true;
}
}
}
private isBaseTableType(type: any): type is BaseTableType {
return type && typeof type.resolve_database_table === "function";
}
private isLazyJoinType(type: any): type is LazyJoinType {
return type && "lazy_join" in type && "field" in type;
}
private isLazyTableType(type: any): type is LazyTableType {
return type && "table" in type && !("lazy_join" in type);
}
private isVirtualTableType(type: any): type is VirtualTableType {
return type && "virtual_table" in type && "field" in type;
}
private isFieldType(type: any): type is FieldType {
return type && typeof type.resolve_database_field === "function";
}
private isDateTimeDatabaseField(field: any): field is DateTimeDatabaseField {
return field && "name" in field; // Simplified check
}
}
// PropertySwapper: Transforms property accesses with type conversions
export class PropertySwapper extends CloningVisitor {
timezone: string;
eventProperties: Map<string, string>;
personProperties: Map<string, string>;
groupProperties: Map<string, string>;
context: HogQLContext;
setTimeZones: boolean;
constructor(
timezone: string,
eventProperties: Map<string, string> | Record<string, string>,
personProperties: Map<string, string> | Record<string, string>,
groupProperties: Map<string, string> | Record<string, string>,
context: HogQLContext,
setTimeZones: boolean
) {
super(false); // Don't clear types
this.timezone = timezone;
this.eventProperties =
eventProperties instanceof Map ? eventProperties : new Map(Object.entries(eventProperties));
this.personProperties =
personProperties instanceof Map
? personProperties
: new Map(Object.entries(personProperties));
this.groupProperties =
groupProperties instanceof Map ? groupProperties : new Map(Object.entries(groupProperties));
this.context = context;
this.setTimeZones = setTimeZones;
}
visitField(node: Field): any {
if (this.isFieldType(node.type)) {
if (this.setTimeZones) {
const dbField = (node.type as any).resolve_database_field?.(this.context);
if (this.isDateTimeDatabaseField(dbField)) {
return this.createToTimeZoneCall(node);
}
}
if (this.isLazyJoinType(node.type.table_type)) {
const lazyJoinType = node.type.table_type;
const resolvedTable = lazyJoinType.lazy_join.resolve_table?.(this.context);
// Check if it's an S3Table-like table (has fields property)
if (resolvedTable && "fields" in resolvedTable) {
const field = node.chain[node.chain.length - 1];
const fieldType = resolvedTable.fields[String(field)];
let propType = "String";
if (this.isDateTimeDatabaseField(fieldType)) {
propType = "DateTime";
} else if (this.isBooleanDatabaseField(fieldType)) {
propType = "Boolean";
}
return this.fieldTypeToPropertyCall(node, propType);
}
}
}
const type = node.type;
if (
this.isPropertyType(type) &&
type.field_type.name === "properties" &&
type.chain.length === 1
) {
const propertyName = String(type.chain[0]);
const tableType = type.field_type.table_type;
if (this.isVirtualTableType(tableType) && tableType.field === "poe") {
if (this.personProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "person", propertyName);
}
} else if (this.isBaseTableType(tableType)) {
const table = tableType.resolve_database_table?.(this.context);
if (table) {
const tableName = table.to_printed_hogql?.() || "";
if (tableName === "persons" || tableName === "raw_persons") {
if (this.personProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "person", propertyName);
}
} else if (tableName === "groups") {
if (this.isLazyJoinType(tableType)) {
if (tableType.field.startsWith("group_")) {
const groupId = parseInt(tableType.field.split("_")[1], 10);
const groupKey = `${groupId}_${propertyName}`;
if (this.groupProperties.has(groupKey)) {
return this.convertStringPropertyToType(node, "group", groupKey);
}
}
} else if (this.isLazyTableType(tableType)) {
const globalGroupId = this.context.globals?.group_id;
if (typeof globalGroupId === "number") {
const groupKey = `${globalGroupId}_${propertyName}`;
if (this.groupProperties.has(groupKey)) {
return this.convertStringPropertyToType(node, "group", groupKey);
}
}
}
} else if (tableName === "events") {
if (this.eventProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "event", propertyName);
}
}
}
}
}
if (
this.isPropertyType(type) &&
type.field_type.name === "person_properties" &&
type.chain.length === 1
) {
const propertyName = String(type.chain[0]);
const tableType = type.field_type.table_type;
if (this.isBaseTableType(tableType)) {
const table = tableType.resolve_database_table?.(this.context);
if (table) {
const tableName = table.to_printed_hogql?.() || "";
if (tableName === "events") {
if (this.personProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "person", propertyName);
}
}
}
}
}
return super.visitField(node);
}
private convertStringPropertyToType(
node: Field,
propertyType: "event" | "person" | "group",
propertyName: string
): Expr {
let posthogFieldType: string | undefined;
if (propertyType === "person") {
posthogFieldType = this.personProperties.get(propertyName);
} else if (propertyType === "group") {
posthogFieldType = this.groupProperties.get(propertyName);
} else {
posthogFieldType = this.eventProperties.get(propertyName);
}
const fieldType = posthogFieldType === "Numeric" ? "Float" : posthogFieldType || "String";
this.addPropertyNotice(node, propertyType, fieldType);
return this.fieldTypeToPropertyCall(node, fieldType);
}
private fieldTypeToPropertyCall(node: Field, fieldType: string): Expr {
if (fieldType === "DateTime") {
return this.createToDateTimeCall(node);
}
if (fieldType === "Float") {
return this.createToFloatCall(node);
}
if (fieldType === "Boolean") {
return this.createToBoolCall(node);
}
return node;
}
private createToTimeZoneCall(node: Field): Call {
return {
name: "toTimeZone",
args: [node, this.createConstant(this.timezone)],
type: {
name: "toTimeZone",
arg_types: [{ data_type: "datetime" } as DateTimeType],
return_type: { data_type: "datetime" } as DateTimeType,
} as CallType,
start: node.start,
end: node.end,
} as Call;
}
private createToDateTimeCall(node: Field): Call {
return {
name: "toDateTime",
args: [node],
start: node.start,
end: node.end,
};
}
private createToFloatCall(node: Field): Call {
return {
name: "toFloat",
args: [node],
start: node.start,
end: node.end,
};
}
private createToBoolCall(node: Field): Call {
return {
name: "toBool",
args: [
{
name: "transform",
args: [
{
name: "toString",
args: [node],
start: node.start,
end: node.end,
} as Call,
this.createConstant(["true", "false"]),
this.createConstant([1, 0]),
this.createConstant(null),
],
start: node.start,
end: node.end,
} as Call,
],
start: node.start,
end: node.end,
} as Call;
}
private createConstant(value: any): Constant {
return {
value,
};
}
private addPropertyNotice(
node: Field,
propertyType: "event" | "person" | "group",
fieldType: string
): void {
let propertyName = String(node.chain[node.chain.length - 1]);
let materializedColumn: any = null; // MaterializedColumn type not yet defined
if (propertyType === "person") {
// if (this.context.modifiers.personsOnEventsMode !== "disabled") {
// materializedColumn = getMaterializedColumnForProperty('events', propertyName, 'person_properties');
// } else {
// materializedColumn = getMaterializedColumnForProperty('person', propertyName, 'properties');
// }
} else if (propertyType === "group") {
const nameParts = propertyName.split("_");
nameParts.shift();
propertyName = nameParts.join("_");
// materializedColumn = getMaterializedColumnForProperty('groups', propertyName, 'properties');
} else {
// materializedColumn = getMaterializedColumnForProperty('events', propertyName, 'properties');
}
let message = `${
propertyType.charAt(0).toUpperCase() + propertyType.slice(1)
} property '${propertyName}' is of type '${fieldType}'.`;
if (this.context.debug) {
if (materializedColumn !== null) {
message += " This property is materialized ⚡️.";
} else {
message += " This property is not materialized 🐢.";
}
}
this.addNotice(node, message);
}
private addNotice(node: Field, message: string): void {
if (node.start === undefined || node.end === undefined) {
return; // Don't add notices for nodes without location
}
// Only highlight the last part of the chain
const lastPart = node.chain[node.chain.length - 1];
const identifierLength = escapeHogQLIdentifier(lastPart).length;
this.context.notices.push({
start: Math.max(node.start, node.end - identifierLength),
end: node.end,
message,
});
}
private isFieldType(type: any): type is FieldType {
return type && typeof type.resolve_database_field === "function";
}
private isPropertyType(type: any): type is PropertyType {
return type && "field_type" in type && "chain" in type;
}
private isBaseTableType(type: any): type is BaseTableType {
return type && typeof type.resolve_database_table === "function";
}
private isLazyJoinType(type: any): type is LazyJoinType {
return type && "lazy_join" in type && "field" in type;
}
private isLazyTableType(type: any): type is LazyTableType {
return type && "table" in type && !("lazy_join" in type);
}
private isVirtualTableType(type: any): type is VirtualTableType {
return type && "virtual_table" in type && "field" in type;
}
private isDateTimeDatabaseField(field: any): field is DateTimeDatabaseField {
return field && "name" in field; // Simplified check
}
private isBooleanDatabaseField(field: any): field is BooleanDatabaseField {
return field && "name" in field; // Simplified check
}
}
// Main function to build property swapper
export function buildPropertySwapper(node: AST, context: HogQLContext): void {
if (!context || !context.team_id) {
return;
}
// NOTE: In TypeScript, you'll need to fetch the team from your database/ORM
// This is a placeholder - replace with your actual team fetching logic
// if (!context.team) {
// context.team = await Team.findById(context.team_id);
// }
if (!context.team) {
return;
}
// Find all properties
const propertyFinder = new PropertyFinder(context);
propertyFinder.visit(node);
// NOTE: In TypeScript, you'll need to query PropertyDefinition from your database
// This is a placeholder - replace with your actual property definition fetching logic
// const eventPropertyValues = await PropertyDefinition.find({
// project_id: context.team.project_id,
// name: { $in: Array.from(propertyFinder.eventProperties) },
// type: { $in: [null, 'event'] },
// }).select('name property_type');
// const eventProperties = new Map(
// eventPropertyValues.filter((p: any) => p.property_type).map((p: any) => [p.name, p.property_type])
// );
const eventProperties = new Map<string, string>();
const personProperties = new Map<string, string>();
const groupProperties = new Map<string, string>();
// TODO: Implement actual property definition fetching from database
// For now, these are empty maps
const timezone = (context.database as any)?._timezone || "UTC";
context.property_swapper = new PropertySwapper(
timezone,
eventProperties,
personProperties,
groupProperties,
context,
true
);
}
+108
View File
@@ -0,0 +1,108 @@
// TypeScript translation of posthog/hogql/timings.py
// Keep this file in sync with the Python version
/**
* Get performance counter in milliseconds (Node.js equivalent of perf_counter)
* Uses performance.now() which is available in:
* - Node.js 18+ (global)
* - Browser (global)
* - Node.js <18 via perf_hooks module
*/
function getPerformanceNow(): number {
// Check for global performance (Node.js 18+ or browser)
if (typeof globalThis !== 'undefined' && 'performance' in globalThis) {
const perf = (globalThis as any).performance;
if (perf && typeof perf.now === 'function') {
return perf.now();
}
}
// Fallback to Date.now() if performance API is not available
// Note: This is less precise but works everywhere
return Date.now();
}
export interface QueryTiming {
key: string; // Key identifying the timing measurement
time: number; // Time in seconds
}
const TIMING_DECIMAL_PLACES = 3; // round to milliseconds
// Not thread safe.
// See trends_query_runner for an example of how to use for multithreaded queries
export class HogQLTimings {
// Completed time in seconds for different parts of the HogQL query
timings: Record<string, number> = {};
// Used for housekeeping
private _timingPointer: string;
private _timingStarts: Record<string, number> = {};
constructor(_timingPointer: string = '.') {
this._timingPointer = _timingPointer;
this._timingStarts[this._timingPointer] = this.perfCounter();
}
cloneForSubquery(seriesIndex: number): HogQLTimings {
return new HogQLTimings(`${this._timingPointer}/series_${seriesIndex}`);
}
clearTimings(): void {
this.timings = {};
}
/**
* Measure execution time of a function.
* Usage: timings.measure('operation', () => { ... });
*/
measure<T>(key: string, fn: () => T): T {
const lastKey = this._timingPointer;
const fullKey = `${this._timingPointer}/${key}`;
this._timingPointer = fullKey;
this._timingStarts[fullKey] = this.perfCounter();
try {
return fn();
} finally {
const duration = (this.perfCounter() - this._timingStarts[fullKey]) / 1000; // Convert to seconds
this.timings[fullKey] = (this.timings[fullKey] || 0.0) + duration;
delete this._timingStarts[fullKey];
this._timingPointer = lastKey;
}
}
/**
* Get performance counter in milliseconds (Node.js equivalent of perf_counter)
*/
private perfCounter(): number {
return getPerformanceNow();
}
toDict(): Record<string, number> {
const timings = { ...this.timings };
// Process in reverse order to handle nested timings correctly
const keys = Object.keys(this._timingStarts).reverse();
for (const key of keys) {
const start = this._timingStarts[key];
const elapsed = (this.perfCounter() - start) / 1000; // Convert to seconds
timings[key] = this.round((timings[key] || 0.0) + elapsed);
}
return timings;
}
toList(backOutStack: boolean = true): QueryTiming[] {
const timingDict = backOutStack ? this.toDict() : this.timings;
return Object.entries(timingDict).map(([key, time]) => ({
key: key,
time: this.round(time),
}));
}
/**
* Round to specified decimal places (milliseconds precision)
*/
private round(value: number): number {
return Math.round(value * Math.pow(10, TIMING_DECIMAL_PLACES)) / Math.pow(10, TIMING_DECIMAL_PLACES);
}
}