Added column/table mapping
This commit is contained in:
@@ -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'");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user