Incomplete port of PostHog Python code
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user