Added column/table mapping

This commit is contained in:
Matt Aitken
2025-12-15 23:01:11 +00:00
parent 3985d6e0c1
commit 34dd68185e
2 changed files with 184 additions and 1 deletions
@@ -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'");
+85 -1
View File
@@ -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<string, TableSchema> = 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<string | number>): Array<string | number> {
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 {