Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ describe('ensureTableSchemasAlias', () => {

expect(ensureColumnAliasBatch).toHaveBeenCalledWith({
items: [
{
expect.objectContaining({
sql: 'SUM(order_amount)',
tableName: 'orders',
},
}),
],
executeQuery: expect.any(Function),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const ensureTableSchemasAlias = async ({
items: items.map((item) => ({
sql: item.sql,
tableName: item.context.tableName,
knownTableNames: item.context.knownTableNames,
})),
executeQuery,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface EnsureColumnAliasScenario {
expectedSql: string;
shouldChange: boolean;
notes?: string;
knownTableNames?: string[];
}

export interface DeferredEnsureColumnAliasScenario
Expand Down Expand Up @@ -159,6 +160,40 @@ export const ENSURE_COLUMN_ALIAS_SCENARIOS: EnsureColumnAliasScenario[] = [
expectedSql: 'customers.id',
shouldChange: false,
},
{
description: 'struct field access on local column is qualified',
tableName: 'issue',
knownTableNames: ['issue', 'devusers'],
inputSql: 'stage.stage_id',
expectedSql: 'issue.stage.stage_id',
shouldChange: true,
},
{
description:
'struct field access inside aggregate on local column is qualified',
tableName: 'issue',
knownTableNames: ['issue', 'devusers'],
inputSql: 'COUNT(stage.stage_id)',
expectedSql: 'COUNT(issue.stage.stage_id)',
shouldChange: true,
},
{
description:
'multi-part ref where leading identifier is another table stays untouched',
tableName: 'orders',
knownTableNames: ['orders', 'customers'],
inputSql: 'customers.id',
expectedSql: 'customers.id',
shouldChange: false,
},
{
description: 'already-qualified struct access is not double-qualified',
tableName: 'issue',
knownTableNames: ['issue'],
inputSql: 'issue.stage.stage_id',
expectedSql: 'issue.stage.stage_id',
shouldChange: false,
},
];

export const DEFERRED_ENSURE_COLUMN_ALIAS_SCENARIOS: DeferredEnsureColumnAliasScenario[] =
Expand Down
296 changes: 296 additions & 0 deletions meerkat-core/src/utils/ensure-sql-expression-column-alias.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,67 @@ const expressionAstBySql: Record<string, ParsedExpression> = {
}),
"'customer_id'": createStringConstant('customer_id'),
'"Order ID"': createColumnRef('Order ID'),
'stage.stage_id': createColumnRef(['stage', 'stage_id']),
'issue.stage.stage_id': createColumnRef(['issue', 'stage', 'stage_id']),
'COUNT(stage.stage_id)': createFunction({
functionName: 'COUNT',
children: [createColumnRef(['stage', 'stage_id'])],
}),
'foo.bar': createColumnRef(['foo', 'bar']),
missing_column: createColumnRef('missing_column'),
'stage.stage_id + amount': createFunction({
functionName: '+',
children: [
createColumnRef(['stage', 'stage_id']),
createColumnRef('amount'),
],
isOperator: true,
}),
'stage.stage_id = devusers.id': createComparison({
type: ExpressionType.COMPARE_EQUAL,
left: createColumnRef(['stage', 'stage_id']),
right: createColumnRef(['devusers', 'id']),
}),
'CASE WHEN stage.stage_id = 1 THEN owner.id END': createCase({
whenExpr: createComparison({
type: ExpressionType.COMPARE_EQUAL,
left: createColumnRef(['stage', 'stage_id']),
right: {
class: ExpressionClass.CONSTANT,
type: ExpressionType.VALUE_CONSTANT,
alias: '',
value: {
type: { id: 'INTEGER', type_info: null },
is_null: false,
value: 1,
},
} as ParsedExpression,
}),
thenExpr: createColumnRef(['owner', 'id']),
elseExpr: {
class: ExpressionClass.CONSTANT,
type: ExpressionType.VALUE_CONSTANT,
alias: '',
value: {
type: { id: 'NULL', type_info: null },
is_null: true,
},
} as ParsedExpression,
}),
'SUM(stage.stage_id)': createFunction({
functionName: 'SUM',
children: [createColumnRef(['stage', 'stage_id'])],
}),
'list_transform(stage.items, x -> x)': createFunction({
functionName: 'list_transform',
children: [
createColumnRef(['stage', 'items']),
createLambda({
lhs: createColumnRef('x'),
expr: createColumnRef('x'),
}),
],
}),
"list_transform(priority_tags, x -> CASE WHEN x = 1 THEN 'P1' ELSE 'Unknown' END)":
createFunction({
functionName: 'list_transform',
Expand Down Expand Up @@ -567,6 +628,241 @@ describe('column refs with quotes and dots are not re-aliased', () => {
});
});

describe('schema-aware struct field aliasing', () => {
const runWithContext = async ({
sql,
tableName,
knownTableNames,
}: {
sql: string;
tableName: string;
knownTableNames?: string[];
}) => {
const [result] = await ensureColumnAliasBatch({
items: [
{
sql,
tableName,
knownTableNames: knownTableNames
? new Set(knownTableNames)
: undefined,
},
],
executeQuery: dummyGetQueryOutput,
});
if (!result) {
throw new Error('Missing alias result');
}
return result.sql;
};

it('qualifies struct field access on a local column', async () => {
const result = await runWithContext({
sql: 'stage.stage_id',
tableName: 'issue',
knownTableNames: ['issue', 'devusers'],
});
expect(result).toBe('issue.stage.stage_id');
});

it('qualifies struct access inside an aggregate', async () => {
const result = await runWithContext({
sql: 'COUNT(stage.stage_id)',
tableName: 'issue',
knownTableNames: ['issue', 'devusers'],
});
expect(result).toBe('COUNT(issue.stage.stage_id)');
});

it('leaves cross-table references to a known table alias untouched', async () => {
const result = await runWithContext({
sql: 'customers.id',
tableName: 'orders',
knownTableNames: ['orders', 'customers'],
});
expect(result).toBe('customers.id');
});

it('does not double-qualify already-qualified struct access', async () => {
const result = await runWithContext({
sql: 'issue.stage.stage_id',
tableName: 'issue',
knownTableNames: ['issue'],
});
expect(result).toBe('issue.stage.stage_id');
});

it('falls back to legacy behavior when knownTableNames is omitted', async () => {
const result = await runWithContext({
sql: 'customer_id',
tableName: 'orders',
});
expect(result).toBe('orders.customer_id');
});
});

describe('schema-aware struct aliasing — extended coverage', () => {
const run = async ({
sql,
tableName,
knownTableNames,
}: {
sql: string;
tableName: string;
knownTableNames?: string[];
}) => {
const [result] = await ensureColumnAliasBatch({
items: [
{
sql,
tableName,
knownTableNames: knownTableNames
? new Set(knownTableNames)
: undefined,
},
],
executeQuery: dummyGetQueryOutput,
});
return result?.sql;
};

it('qualifies struct access mixed with bare columns in arithmetic', async () => {
const result = await run({
sql: 'stage.stage_id + amount',
tableName: 'issue',
knownTableNames: ['issue', 'devusers'],
});
expect(result).toBe('issue.stage.stage_id + issue.amount');
});

it('qualifies local struct but preserves cross-table ref in comparison', async () => {
const result = await run({
sql: 'stage.stage_id = devusers.id',
tableName: 'issue',
knownTableNames: ['issue', 'devusers'],
});
expect(result).toBe('issue.stage.stage_id = devusers.id');
});

it('qualifies struct access inside CASE branches', async () => {
const result = await run({
sql: 'CASE WHEN stage.stage_id = 1 THEN owner.id END',
tableName: 'issue',
knownTableNames: ['issue', 'devusers'],
});
expect(result).toBe(
'CASE WHEN issue.stage.stage_id = 1 THEN issue.owner.id END'
);
});

it('qualifies struct access inside SUM aggregate', async () => {
const result = await run({
sql: 'SUM(stage.stage_id)',
tableName: 'issue',
knownTableNames: ['issue', 'devusers'],
});
expect(result).toBe('SUM(issue.stage.stage_id)');
});

it('qualifies struct access inside lambda function argument but not lambda-bound identifier', async () => {
const result = await run({
sql: 'list_transform(stage.items, x -> x)',
tableName: 'issue',
knownTableNames: ['issue', 'devusers'],
});
expect(result).toBe('LIST_TRANSFORM(issue.stage.items, x -> x)');
});

it('treats ambiguous multi-part ref as cross-table when knownTableNames contains root', async () => {
const result = await run({
sql: 'customers.id',
tableName: 'orders',
knownTableNames: ['orders', 'customers'],
});
expect(result).toBe('customers.id');
});

it('qualifies multi-part ref with unknown root as struct when schema batch is present', async () => {
const result = await run({
sql: 'foo.bar',
tableName: 'issue',
knownTableNames: ['issue'],
});
expect(result).toBe('issue.foo.bar');
});

it('stays conservative on multi-part ref when knownTableNames is omitted', async () => {
const result = await run({
sql: 'foo.bar',
tableName: 'issue',
});
expect(result).toBe('foo.bar');
});

it('does not double-qualify when root matches table even without knownTableNames', async () => {
const result = await run({
sql: 'orders.order_amount',
tableName: 'orders',
});
expect(result).toBe('orders.order_amount');
});
});

describe('ensureColumnAliasBatch — batched mixed tables', () => {
it('applies per-item knownTableNames correctly within a single batch', async () => {
const results = await ensureColumnAliasBatch({
items: [
{
sql: 'stage.stage_id',
tableName: 'issue',
knownTableNames: new Set(['issue', 'devusers']),
},
{
sql: 'customers.id',
tableName: 'orders',
knownTableNames: new Set(['orders', 'customers']),
},
{
sql: 'customer_id',
tableName: 'orders',
},
],
executeQuery: dummyGetQueryOutput,
});

expect(results.map((r) => r.sql)).toEqual([
'issue.stage.stage_id',
'customers.id',
'orders.customer_id',
]);
expect(results.map((r) => r.didChange)).toEqual([true, false, true]);
});

it('preserves context per-item through batched aliasing', async () => {
const results = await ensureColumnAliasBatch({
items: [
{
sql: 'customer_id',
tableName: 'orders',
context: { memberName: 'a' },
},
{
sql: 'stage.stage_id',
tableName: 'issue',
knownTableNames: new Set(['issue']),
context: { memberName: 'b' },
},
],
executeQuery: dummyGetQueryOutput,
});

expect(results.map((r) => r.context)).toEqual([
{ memberName: 'a' },
{ memberName: 'b' },
]);
});
});

describe.skip('single-item batch aliasing pending scenarios', () => {
for (const scenario of ENSURE_COLUMN_ALIAS_SCENARIOS) {
if (expressionAstBySql[scenario.inputSql]) {
Expand Down
Loading
Loading