Custom intermediate format

This commit is contained in:
Matt Aitken
2025-12-10 14:05:38 +00:00
parent a9f8dbfb8d
commit b50c6321da
4 changed files with 391 additions and 154 deletions
+1
View File
@@ -2,3 +2,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";
@@ -27,12 +27,14 @@ import {
WithClauseContext,
} from "../grammar/TSQLParser.js";
import { TSQLParserVisitor } from "../grammar/TSQLParserVisitor.js";
import { QueryConfig } from "./QueryConfig.js";
/**
* Visitor that converts TSQL AST to ClickHouse SQL
* Visitor that converts TSQL AST to a QueryConfig
* The QueryConfig can then be used to build a ClickhouseQueryBuilder
*/
export class ClickHouseQueryVisitor implements TSQLParserVisitor<string> {
visitSelect(ctx: SelectContext): string {
export class ClickHouseQueryVisitor implements TSQLParserVisitor<QueryConfig> {
visitSelect(ctx: SelectContext): QueryConfig {
const selectSetStmt = ctx.selectSetStmt();
if (selectSetStmt) {
return this.visitSelectSetStmt(selectSetStmt);
@@ -43,41 +45,55 @@ export class ClickHouseQueryVisitor implements TSQLParserVisitor<string> {
return this.visitSelectStmt(selectStmt);
}
// Handle tSQLxTagElement if needed
return "";
// Handle tSQLxTagElement if needed - return empty config
return {
baseQuery: "",
whereClauses: [],
};
}
visitSelectSetStmt(ctx: SelectSetStmtContext): string {
visitSelectSetStmt(ctx: SelectSetStmtContext): QueryConfig {
const selectStmtWithParens = ctx.selectStmtWithParens();
let query = this.visitSelectStmtWithParens(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();
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 (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 nextSelect = clause.selectStmtWithParens();
query += ` ${op} ${this.visitSelectStmtWithParens(nextSelect)}`;
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 query;
return config;
}
visitSelectStmtWithParens(ctx: SelectStmtWithParensContext): string {
visitSelectStmtWithParens(ctx: SelectStmtWithParensContext): QueryConfig {
const selectStmt = ctx.selectStmt();
if (selectStmt) {
return this.visitSelectStmt(selectStmt);
@@ -85,29 +101,47 @@ export class ClickHouseQueryVisitor implements TSQLParserVisitor<string> {
const selectSetStmt = ctx.selectSetStmt();
if (selectSetStmt) {
return `(${this.visitSelectSetStmt(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) {
return this.visitPlaceholder(placeholder);
const placeholderConfig = this.visitPlaceholder(placeholder);
return placeholderConfig;
}
return this.getTextFromContext(ctx);
return {
baseQuery: this.getTextFromContext(ctx),
whereClauses: [],
};
}
visitPlaceholder(ctx: PlaceholderContext): string {
return this.getTextFromContext(ctx);
visitPlaceholder(ctx: PlaceholderContext): QueryConfig {
return {
baseQuery: this.getTextFromContext(ctx),
whereClauses: [],
};
}
visitSelectStmt(ctx: SelectStmtContext): string {
let parts: string[] = [];
visitSelectStmt(ctx: SelectStmtContext): QueryConfig {
const config: QueryConfig = {
baseQuery: "",
whereClauses: [],
};
const parts: string[] = [];
// WITH clause
const withClause = ctx.withClause();
if (withClause) {
parts.push(this.visitWithClause(withClause));
parts.push(this.visitWithClauseString(withClause));
}
// SELECT
@@ -121,194 +155,382 @@ export class ClickHouseQueryVisitor implements TSQLParserVisitor<string> {
// TOP clause
const topClause = ctx.topClause();
if (topClause) {
parts.push(this.visitTopClause(topClause));
parts.push(this.visitTopClauseString(topClause));
}
// Column list
const columnExprList = ctx.columnExprList();
if (columnExprList) {
parts.push(this.visitColumnExprList(columnExprList));
parts.push(this.visitColumnExprListString(columnExprList));
}
// FROM clause
const fromClause = ctx.fromClause();
if (fromClause) {
parts.push(this.visitFromClause(fromClause));
parts.push(this.visitFromClauseString(fromClause));
}
// Array JOIN
const arrayJoinClause = ctx.arrayJoinClause();
if (arrayJoinClause) {
parts.push(this.visitArrayJoinClause(arrayJoinClause));
parts.push(this.visitArrayJoinClauseString(arrayJoinClause));
}
// PREWHERE
const prewhereClause = ctx.prewhereClause();
if (prewhereClause) {
parts.push(this.visitPrewhereClause(prewhereClause));
parts.push(this.visitPrewhereClauseString(prewhereClause));
}
// WHERE
// Base query is everything up to WHERE
config.baseQuery = parts.join(" ");
// WHERE - extract to whereClauses array
const whereClause = ctx.whereClause();
if (whereClause) {
parts.push(this.visitWhereClause(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) {
parts.push(this.visitGroupByClause(groupByClause));
const groupByText = this.visitGroupByClauseString(groupByClause);
// Remove "GROUP BY " prefix
config.groupBy = groupByText.replace(/^GROUP\s+BY\s+/i, "");
}
// HAVING
// HAVING - add as WHERE clause (ClickHouse doesn't distinguish)
const havingClause = ctx.havingClause();
if (havingClause) {
parts.push(this.visitHavingClause(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) {
parts.push(this.visitWindowClause(windowClause));
const windowText = this.visitWindowClauseString(windowClause);
// Append to base query
config.baseQuery += " " + windowText;
}
// ORDER BY
const orderByClause = ctx.orderByClause();
if (orderByClause) {
parts.push(this.visitOrderByClause(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) {
parts.push(this.visitLimitByClause(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) {
parts.push(this.visitLimitAndOffsetClause(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) {
parts.push(this.visitOffsetOnlyClause(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) {
parts.push(this.visitSettingsClause(settingsClause));
const settingsText = this.visitSettingsClauseString(settingsClause);
// Append to base query
config.baseQuery += " " + settingsText;
}
return parts.join(" ");
return config;
}
visitColumnExprList(ctx: ColumnExprListContext): string {
// 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.visitColumnExpr(expr));
exprs.push(this.visitColumnExprString(expr));
}
return exprs.join(", ");
}
visitColumnExpr(ctx: ColumnExprContext): string {
// Use the text property which contains the original input text for this node
private visitColumnExprString(ctx: ColumnExprContext): string {
return this.getTextFromContext(ctx);
}
visitFromClause(ctx: FromClauseContext): string {
private visitFromClauseString(ctx: FromClauseContext): string {
const joinExpr = ctx.joinExpr();
return `FROM ${this.visitJoinExpr(joinExpr)}`;
return `FROM ${this.visitJoinExprString(joinExpr)}`;
}
visitJoinExpr(ctx: JoinExprContext): string {
private visitJoinExprString(ctx: JoinExprContext): string {
return this.getTextFromContext(ctx);
}
visitWhereClause(ctx: WhereClauseContext): string {
private visitWhereClauseString(ctx: WhereClauseContext): string {
const columnExpr = ctx.columnExpr();
return `WHERE ${this.visitColumnExpr(columnExpr)}`;
return `WHERE ${this.visitColumnExprString(columnExpr)}`;
}
visitGroupByClause(ctx: GroupByClauseContext): string {
private visitGroupByClauseString(ctx: GroupByClauseContext): string {
return this.getTextFromContext(ctx);
}
visitHavingClause(ctx: HavingClauseContext): string {
private visitHavingClauseString(ctx: HavingClauseContext): string {
return this.getTextFromContext(ctx);
}
visitOrderByClause(ctx: OrderByClauseContext): string {
private visitOrderByClauseString(ctx: OrderByClauseContext): string {
return this.getTextFromContext(ctx);
}
visitLimitByClause(ctx: LimitByClauseContext): string {
private visitLimitByClauseString(ctx: LimitByClauseContext): string {
return this.getTextFromContext(ctx);
}
visitLimitAndOffsetClause(ctx: LimitAndOffsetClauseContext): string {
private visitLimitAndOffsetClauseString(ctx: LimitAndOffsetClauseContext): string {
return this.getTextFromContext(ctx);
}
visitOffsetOnlyClause(ctx: OffsetOnlyClauseContext): string {
private visitOffsetOnlyClauseString(ctx: OffsetOnlyClauseContext): string {
return this.getTextFromContext(ctx);
}
visitSettingsClause(ctx: SettingsClauseContext): string {
private visitSettingsClauseString(ctx: SettingsClauseContext): string {
return this.getTextFromContext(ctx);
}
visitWithClause(ctx: WithClauseContext): string {
private visitWithClauseString(ctx: WithClauseContext): string {
return this.getTextFromContext(ctx);
}
visitTopClause(ctx: TopClauseContext): string {
private visitTopClauseString(ctx: TopClauseContext): string {
return this.getTextFromContext(ctx);
}
visitArrayJoinClause(ctx: ArrayJoinClauseContext): string {
private visitArrayJoinClauseString(ctx: ArrayJoinClauseContext): string {
return this.getTextFromContext(ctx);
}
visitPrewhereClause(ctx: PrewhereClauseContext): string {
private visitPrewhereClauseString(ctx: PrewhereClauseContext): string {
return this.getTextFromContext(ctx);
}
visitWindowClause(ctx: WindowClauseContext): string {
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 {
// The text property exists at runtime but may not be in types
return (ctx as any).text || "";
}
// Required by ParseTreeVisitor interface
visit(tree: ParseTree): string {
// Use the text property which contains the original input text
return (tree as any).text || "";
visit(tree: ParseTree): QueryConfig {
// For generic parse trees, return empty config
return {
baseQuery: (tree as any).text || "",
whereClauses: [],
};
}
visitChildren(node: ParserRuleContext): string {
// Visit all children and concatenate their results
visitChildren(node: ParserRuleContext): QueryConfig {
// Visit all children and combine their configs
if (!node.children || node.children.length === 0) {
return this.visit(node);
}
return node.children.map((child: ParseTree) => this.visit(child)).join(" ");
// For now, just return the text representation
return {
baseQuery: (node as any).text || "",
whereClauses: [],
};
}
// Visit terminal nodes
visitTerminal(node: TerminalNode): string {
return node.text;
visitTerminal(node: TerminalNode): QueryConfig {
return {
baseQuery: node.text,
whereClauses: [],
};
}
visitErrorNode(node: ErrorNode): string {
return "";
visitErrorNode(node: ErrorNode): QueryConfig {
return {
baseQuery: "",
whereClauses: [],
};
}
}
@@ -0,0 +1,42 @@
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;
}
+43 -71
View File
@@ -3,6 +3,7 @@ import { TSQLLexer } from "../grammar/TSQLLexer.js";
import { TSQLParser } from "../grammar/TSQLParser.js";
import { ClickHouseQueryVisitor } from "./ClickHouseQueryVisitor.js";
import type { ClickHouse } from "@internal/clickhouse";
import { ClickhouseQueryBuilder } from "@internal/clickhouse/client/queryBuilder.js";
import { z } from "zod";
export interface TQueryOptions {
@@ -43,80 +44,51 @@ export class TQuery {
// Parse as a SELECT statement
const tree = parser.select();
// Convert AST to ClickHouse SQL
// Convert AST to QueryConfig
const visitor = new ClickHouseQueryVisitor();
let clickhouseQuery = visitor.visit(tree);
const queryConfig = visitor.visit(tree);
// Add WHERE clauses for scoping
clickhouseQuery = this.addScopingWhereClauses(clickhouseQuery);
// Use ClickhouseQueryBuilder to build the query
const queryBuilder = new ClickhouseQueryBuilder(
"tsql-query",
queryConfig.baseQuery,
this.clickhouseReader.reader,
schema
);
// Execute the query using ClickHouse client
const queryFunction = this.clickhouseReader.reader.query({
name: "tsql-query",
query: clickhouseQuery,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
}),
schema,
});
return await queryFunction({
organizationId: this.organizationId,
projectId: this.projectId,
environmentId: this.environmentId,
});
}
/**
* Add WHERE clauses for organization_id, project_id, and environment_id
* If the query already has a WHERE clause, we add AND conditions
*/
private addScopingWhereClauses(query: string): string {
const scopingConditions = [
"organization_id = {organizationId: String}",
"project_id = {projectId: String}",
"environment_id = {environmentId: String}",
].join(" AND ");
const upperQuery = query.toUpperCase();
// Check if query already has a WHERE clause
const whereIndex = upperQuery.indexOf(" WHERE ");
if (whereIndex !== -1) {
// Find the end of the WHERE clause (before GROUP BY, HAVING, ORDER BY, LIMIT, etc.)
const groupByIndex = upperQuery.indexOf(" GROUP BY ", whereIndex);
const havingIndex = upperQuery.indexOf(" HAVING ", whereIndex);
const orderByIndex = upperQuery.indexOf(" ORDER BY ", whereIndex);
const limitIndex = upperQuery.indexOf(" LIMIT ", whereIndex);
let whereEndIndex = query.length;
if (groupByIndex !== -1) whereEndIndex = Math.min(whereEndIndex, groupByIndex);
if (havingIndex !== -1) whereEndIndex = Math.min(whereEndIndex, havingIndex);
if (orderByIndex !== -1) whereEndIndex = Math.min(whereEndIndex, orderByIndex);
if (limitIndex !== -1) whereEndIndex = Math.min(whereEndIndex, limitIndex);
// Insert AND conditions before the end of WHERE clause
const beforeWhereEnd = query.substring(0, whereEndIndex);
const afterWhereEnd = query.substring(whereEndIndex);
return `${beforeWhereEnd} AND ${scopingConditions}${afterWhereEnd}`;
} else {
// Add WHERE clause before GROUP BY, HAVING, ORDER BY, LIMIT, etc.
const groupByIndex = upperQuery.indexOf(" GROUP BY ");
const havingIndex = upperQuery.indexOf(" HAVING ");
const orderByIndex = upperQuery.indexOf(" ORDER BY ");
const limitIndex = upperQuery.indexOf(" LIMIT ");
let insertIndex = query.length;
if (groupByIndex !== -1) insertIndex = Math.min(insertIndex, groupByIndex);
if (havingIndex !== -1) insertIndex = Math.min(insertIndex, havingIndex);
if (orderByIndex !== -1) insertIndex = Math.min(insertIndex, orderByIndex);
if (limitIndex !== -1) insertIndex = Math.min(insertIndex, limitIndex);
return `${query.substring(0, insertIndex)} WHERE ${scopingConditions}${query.substring(
insertIndex
)}`;
// Add existing WHERE clauses from the TSQL query
for (const whereClause of queryConfig.whereClauses) {
queryBuilder.where(whereClause.clause, whereClause.params);
}
// Add scoping WHERE clauses
queryBuilder
.where("organization_id = {organizationId: String}", {
organizationId: this.organizationId,
})
.where("project_id = {projectId: String}", {
projectId: this.projectId,
})
.where("environment_id = {environmentId: String}", {
environmentId: this.environmentId,
});
// Add GROUP BY if present
if (queryConfig.groupBy) {
queryBuilder.groupBy(queryConfig.groupBy);
}
// Add ORDER BY if present
if (queryConfig.orderBy) {
queryBuilder.orderBy(queryConfig.orderBy);
}
// Add LIMIT if present
if (queryConfig.limit !== undefined) {
queryBuilder.limit(queryConfig.limit);
}
// Execute the query
return await queryBuilder.execute();
}
}