diff --git a/internal-packages/tsql/src/query/printer.test.ts b/internal-packages/tsql/src/query/printer.test.ts index 4008cd645..3361dc23c 100644 --- a/internal-packages/tsql/src/query/printer.test.ts +++ b/internal-packages/tsql/src/query/printer.test.ts @@ -53,6 +53,30 @@ const taskEventsSchema: TableSchema = { }, }; +/** + * Schema with user-friendly names that map to internal ClickHouse names + */ +const runsSchema: TableSchema = { + name: "runs", // User writes: FROM runs + clickhouseName: "trigger_dev.task_runs_v2", // ClickHouse sees this + columns: { + id: { name: "id", clickhouseName: "run_id", ...column("String") }, + friendly_id: { name: "friendly_id", ...column("String") }, // No mapping + created: { name: "created", clickhouseName: "created_at", ...column("DateTime64") }, + updated: { name: "updated", clickhouseName: "updated_at", ...column("DateTime64") }, + status: { name: "status", ...column("String") }, + task: { name: "task", clickhouseName: "task_identifier", ...column("String") }, + org_id: { name: "org_id", clickhouseName: "organization_id", ...column("String") }, + proj_id: { name: "proj_id", clickhouseName: "project_id", ...column("String") }, + env_id: { name: "env_id", clickhouseName: "environment_id", ...column("String") }, + }, + tenantColumns: { + organizationId: "organization_id", + projectId: "project_id", + environmentId: "environment_id", + }, +}; + /** * Helper to create a test context */ @@ -112,6 +136,81 @@ describe("ClickHousePrinter", () => { }); }); + describe("Table and column name mapping", () => { + function createMappedContext() { + const schema = createSchemaRegistry([runsSchema]); + return createPrinterContext({ + organizationId: "org_test", + projectId: "proj_test", + environmentId: "env_test", + schema, + }); + } + + it("should map user-friendly table name to ClickHouse name", () => { + const ctx = createMappedContext(); + const { sql } = printQuery("SELECT * FROM runs", ctx); + + // Table name should be mapped + expect(sql).toContain("FROM trigger_dev.task_runs_v2"); + expect(sql).not.toContain("FROM runs"); + }); + + it("should map user-friendly column names to ClickHouse names", () => { + const ctx = createMappedContext(); + const { sql } = printQuery("SELECT id, created, status FROM runs", ctx); + + // id -> run_id, created -> created_at, status stays as status + expect(sql).toContain("run_id"); + expect(sql).toContain("created_at"); + expect(sql).toContain("status"); + }); + + it("should map column names in WHERE clause", () => { + const ctx = createMappedContext(); + const { sql } = printQuery("SELECT * FROM runs WHERE task = 'my-task'", ctx); + + // task -> task_identifier + expect(sql).toContain("task_identifier"); + expect(sql).not.toMatch(/\btask\b.*=/); // task should not appear as column + }); + + it("should map column names in ORDER BY", () => { + const ctx = createMappedContext(); + const { sql } = printQuery("SELECT * FROM runs ORDER BY created DESC", ctx); + + // created -> created_at + expect(sql).toContain("ORDER BY created_at DESC"); + }); + + it("should map column names in GROUP BY", () => { + const ctx = createMappedContext(); + const { sql } = printQuery("SELECT task, count(*) FROM runs GROUP BY task", ctx); + + // task -> task_identifier in both SELECT and GROUP BY + expect(sql).toContain("task_identifier"); + expect(sql).toContain("GROUP BY task_identifier"); + }); + + it("should preserve unmapped column names", () => { + const ctx = createMappedContext(); + const { sql } = printQuery("SELECT friendly_id, status FROM runs", ctx); + + // friendly_id has no clickhouseName, should stay as-is + expect(sql).toContain("friendly_id"); + expect(sql).toContain("status"); + }); + + it("should handle qualified column references (table.column)", () => { + const ctx = createMappedContext(); + const { sql } = printQuery("SELECT runs.id, runs.created FROM runs", ctx); + + // Should still map the column names + expect(sql).toContain("run_id"); + expect(sql).toContain("created_at"); + }); + }); + describe("WHERE clauses", () => { it("should print WHERE with equality comparison", () => { const { sql, params } = printQuery("SELECT * FROM task_runs WHERE status = 'completed'"); diff --git a/internal-packages/tsql/src/query/printer.ts b/internal-packages/tsql/src/query/printer.ts index 39e3f53b9..7121d80b6 100644 --- a/internal-packages/tsql/src/query/printer.ts +++ b/internal-packages/tsql/src/query/printer.ts @@ -76,6 +76,7 @@ interface JoinExprResponse { * - Automatic tenant isolation (organization_id, project_id, environment_id) * - Schema-based table/column validation * - SQL injection protection via parameterized queries + * - Table and column name mapping (user-friendly → internal ClickHouse names) */ export class ClickHousePrinter { /** Stack of AST nodes being visited (for context) */ @@ -86,6 +87,11 @@ export class ClickHousePrinter { private tabSize = 4; /** Whether to pretty print output */ private pretty: boolean; + /** + * Map of table aliases to their schemas (for column name resolution) + * Key is the alias/name used in the query, value is the TableSchema + */ + private tableContexts: Map = new Map(); constructor( private context: PrinterContext, @@ -266,6 +272,11 @@ export class ClickHousePrinter { const partOfSelectUnion = this.stack.length >= 2 && this.isSelectSetQuery(this.stack[this.stack.length - 2]); const isTopLevelQuery = this.stack.length <= 1 || (this.stack.length === 2 && partOfSelectUnion); + // Clear table contexts for top-level queries (subqueries inherit parent context) + if (isTopLevelQuery) { + this.tableContexts.clear(); + } + // Build WHERE clause starting with any existing where let where: Expression | undefined = node.where; @@ -444,6 +455,11 @@ export class ClickHousePrinter { const tableSchema = this.lookupTable(tableName); joinStrings.push(tableSchema.clickhouseName); + // Register this table context for column name resolution + // Use the alias if provided, otherwise use the TSQL table name + const contextKey = node.alias || tableName; + this.tableContexts.set(contextKey, tableSchema); + // Add tenant isolation guard extraWhere = this.createTenantGuard(tableSchema, node.alias || tableName); } else if ( @@ -777,8 +793,76 @@ export class ClickHousePrinter { return "*"; } + // Handle table.* asterisk + if (node.chain.length === 2 && node.chain[1] === "*") { + const tableAlias = node.chain[0]; + if (typeof tableAlias === "string") { + return `${this.printIdentifier(tableAlias)}.*`; + } + } + + // Try to resolve column names through table context + const resolvedChain = this.resolveFieldChain(node.chain); + // Print each chain element - return node.chain.map((part) => this.printIdentifierOrIndex(part)).join("."); + return resolvedChain.map((part) => this.printIdentifierOrIndex(part)).join("."); + } + + /** + * Resolve field chain to use ClickHouse column names where applicable + * Handles both qualified (table.column) and unqualified (column) references + */ + private resolveFieldChain(chain: Array): Array { + if (chain.length === 0) { + return chain; + } + + const firstPart = chain[0]; + if (typeof firstPart !== "string") { + return chain; // Index access, return as-is + } + + // Case 1: Qualified reference like table.column or table.column.nested + if (chain.length >= 2) { + const tableAlias = firstPart; + const tableSchema = this.tableContexts.get(tableAlias); + + if (tableSchema) { + // This is a table.column reference + const columnName = chain[1]; + if (typeof columnName === "string") { + const resolvedColumn = this.resolveColumnName(tableSchema, columnName); + return [tableAlias, resolvedColumn, ...chain.slice(2)]; + } + } + // Not a known table alias, might be a nested field - return as-is + return chain; + } + + // Case 2: Unqualified reference like just "column" + // Try to find the column in any table context + const columnName = firstPart; + for (const tableSchema of this.tableContexts.values()) { + const columnSchema = tableSchema.columns[columnName]; + if (columnSchema) { + return [columnSchema.clickhouseName || columnSchema.name, ...chain.slice(1)]; + } + } + + // Column not found in any table context - return as-is (might be a function, subquery alias, etc.) + return chain; + } + + /** + * Resolve a column name to its ClickHouse name using the table schema + */ + private resolveColumnName(tableSchema: TableSchema, columnName: string): string { + const columnSchema = tableSchema.columns[columnName]; + if (columnSchema) { + return columnSchema.clickhouseName || columnSchema.name; + } + // Column not in schema - return as-is (might be a computed column, etc.) + return columnName; } private visitPlaceholder(node: Placeholder): string {