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 @@ -95,7 +95,7 @@ describe('generate-resolution-schemas', () => {
const result = generateResolutionSchemas(config);

expect(result[0].dimensions[0].sql).toBe(
'orders__customer_id.display_name'
'orders__customer_id."orders__customer_id__display_name"'
);
});

Expand Down Expand Up @@ -257,7 +257,12 @@ describe('generate-resolution-schemas', () => {

const result = generateResolutionSchemas(config);

expect(result[0].sql).toBe('SELECT * FROM customers WHERE active = true');
// The lookup SQL is rewritten to project declared columns (plus the join
// column) under table-qualified aliases, so the bare `*` over the join
// can no longer expose colliding raw column names (ISS-301213).
expect(result[0].sql).toBe(
'SELECT orders__customer_id."name" AS "orders__customer_id__name", orders__customer_id."id" AS "orders__customer_id__id" FROM (SELECT * FROM customers WHERE active = true) AS orders__customer_id'
);
});

it('should have empty measures array in resolution schema', () => {
Expand Down Expand Up @@ -301,7 +306,9 @@ describe('generate-resolution-schemas', () => {

expect(result[0].name).toBe('orders__tag_ids');
expect(result[0].dimensions[0].name).toBe('orders__tag_ids__tag_name');
expect(result[0].dimensions[0].sql).toBe('orders__tag_ids.tag_name');
expect(result[0].dimensions[0].sql).toBe(
'orders__tag_ids."orders__tag_ids__tag_name"'
);
});

it('should handle multiple resolution columns from same source', () => {
Expand Down Expand Up @@ -329,19 +336,19 @@ describe('generate-resolution-schemas', () => {
expect(result[0].dimensions).toHaveLength(3);
expect(result[0].dimensions[0]).toEqual({
name: 'orders__user_id__first_name',
sql: 'orders__user_id.first_name',
sql: 'orders__user_id."orders__user_id__first_name"',
type: 'string',
alias: 'orders__user_id__first_name',
});
expect(result[0].dimensions[1]).toEqual({
name: 'orders__user_id__last_name',
sql: 'orders__user_id.last_name',
sql: 'orders__user_id."orders__user_id__last_name"',
type: 'string',
alias: 'orders__user_id__last_name',
});
expect(result[0].dimensions[2]).toEqual({
name: 'orders__user_id__email',
sql: 'orders__user_id.email',
sql: 'orders__user_id."orders__user_id__email"',
type: 'string',
alias: 'orders__user_id__email',
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,70 @@
import { getNamespacedKey, memberKeyToSafeKey } from '../../member-formatters';
import { TableSchema } from '../../types/cube-types/table';
import { Dimension, TableSchema } from '../../types/cube-types/table';
import { findInDimensionSchemas } from '../../utils/find-in-table-schema';
import { ResolutionConfig } from '../types';

/**
* Builds the qualified alias under which a lookup column is projected.
* The alias is the table-qualified safe key (`<config>__<column>`), which is
* globally unique per resolution config — so even when two different lookup
* tables expose a column with the same raw name (e.g. `deprecated_created_by_agent_id`
* on both `dim_devu` and `dim_group`), the projected names never collide.
*/
const qualifiedLookupAlias = (configName: string, column: string): string =>
memberKeyToSafeKey(getNamespacedKey(configName, column));

/**
* Rewrites a lookup table's SQL so it projects every declared dimension (plus
* the join column) under a table-qualified alias instead of leaking its raw
* physical columns.
*
* The resolution pipeline joins each lookup as `SELECT <named cols>, * FROM
* (<lookupSql>)`. The trailing bare `*` re-exposes every raw column of the
* lookup — including undeclared passthrough columns that several `dim_*` tables
* share (`deprecated_created_by_agent_id`, etc.). When two lookups are joined,
* those shared raw names collide and DuckDB's binder rejects the export with
* `Binder Error: Ambiguous reference to column name` (ISS-301213 / TKT-65015).
*
* By projecting each column as `<expr> AS "<config>__<column>"`, the lookup
* subquery only ever emits uniquely-named columns, so the bare `*` is harmless.
*
* Note: declared measures are intentionally not projected here. Resolution
* lookups are never aggregated (the resolution schema always carries
* `measures: []`), and a measure's SQL is an aggregate expression that is
* invalid in this flat, non-grouped projection.
*/
const buildQualifiedLookupSql = (
sourceSchema: TableSchema,
configName: string,
joinColumn: string
): string => {
const wrapAlias = memberKeyToSafeKey(configName);
const projections: string[] = [];
const projectedColumns = new Set<string>();

const addProjection = (column: string) => {
if (projectedColumns.has(column)) {
return;
}
projectedColumns.add(column);
// Reference the raw column off the wrapped subquery alias (the inner
// `(sourceSchema.sql) AS wrapAlias` exposes each declared column by its
// physical name), then re-alias it to a globally-unique qualified key.
projections.push(
`${wrapAlias}."${column}" AS "${qualifiedLookupAlias(configName, column)}"`
);
};

sourceSchema.dimensions.forEach((dimension) => {
addProjection(dimension.name);
});

// The join column may not be a declared dimension, but the JOIN needs it.
addProjection(joinColumn);

return `SELECT ${projections.join(', ')} FROM (${sourceSchema.sql}) AS ${wrapAlias}`;
};

export const generateResolutionSchemas = (config: ResolutionConfig) => {
const resolutionSchemas: TableSchema[] = [];
config.columnConfigs.forEach((colConfig) => {
Expand All @@ -15,12 +77,20 @@ export const generateResolutionSchemas = (config: ResolutionConfig) => {

const baseName = memberKeyToSafeKey(colConfig.name);

// Qualify every column the lookup projects so its bare `*` can never expose
// a raw column name that clashes with another lookup table (ISS-301213).
const qualifiedSql = buildQualifiedLookupSql(
tableSchema,
colConfig.name,
colConfig.joinColumn
);

// For each column that needs to be resolved, create a copy of the relevant table schema.
// We use the name of the column in the base query as the table schema name
// to avoid conflicts.
const resolutionSchema: TableSchema = {
name: baseName,
sql: tableSchema.sql,
sql: qualifiedSql,
measures: [],
dimensions: colConfig.resolutionColumns.map((col) => {
const dimension = findInDimensionSchemas(
Expand All @@ -30,14 +100,17 @@ export const generateResolutionSchemas = (config: ResolutionConfig) => {
if (!dimension) {
throw new Error(`Dimension not found: ${col}`);
}
return {
const dimensionDef: Dimension = {
// Need to create a new name due to limitations with how
// CubeToSql handles duplicate dimension names between different sources.
name: memberKeyToSafeKey(getNamespacedKey(colConfig.name, col)),
sql: `${baseName}.${col}`,
// Reference the qualified alias projected by buildQualifiedLookupSql,
// not the raw column name (which is no longer exposed).
sql: `${baseName}."${qualifiedLookupAlias(colConfig.name, col)}"`,
type: dimension.type,
alias: memberKeyToSafeKey(getNamespacedKey(colConfig.name, col)),
};
return dimensionDef;
}),
};

Expand All @@ -46,3 +119,13 @@ export const generateResolutionSchemas = (config: ResolutionConfig) => {

return resolutionSchemas;
};

/**
* The qualified alias under which a lookup's join column is projected by
* {@link buildQualifiedLookupSql}. Exported so the join ON clause can reference
* the same alias.
*/
export const resolutionJoinColumnAlias = (
configName: string,
joinColumn: string
): string => qualifiedLookupAlias(configName, joinColumn);
26 changes: 13 additions & 13 deletions meerkat-core/src/resolution/resolution.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,10 @@ describe('Create base table schema', () => {
],
joins: [
{
sql: '__base_query.base_table__column1 = base_table__column1.id',
sql: '__base_query.base_table__column1 = base_table__column1."base_table__column1__id"',
},
{
sql: '__base_query.base_table__column2 = base_table__column2.id',
sql: '__base_query.base_table__column2 = base_table__column2."base_table__column2__id"',
},
],
});
Expand Down Expand Up @@ -269,10 +269,10 @@ describe('Create base table schema', () => {
],
joins: [
{
sql: '__base_query."Column 1" = base_table__column1.id',
sql: '__base_query."Column 1" = base_table__column1."base_table__column1__id"',
},
{
sql: '__base_query."Column 2" = base_table__column2.id',
sql: '__base_query."Column 2" = base_table__column2."base_table__column2__id"',
},
],
});
Expand Down Expand Up @@ -349,31 +349,31 @@ describe('Generate resolution schemas', () => {
expect(schemas).toEqual([
{
name: 'base_table__column1',
sql: '<resolution_table_sql>',
sql: 'SELECT base_table__column1."id" AS "base_table__column1__id", base_table__column1."display_id" AS "base_table__column1__display_id", base_table__column1."display_name" AS "base_table__column1__display_name" FROM (<resolution_table_sql>) AS base_table__column1',
measures: [],
dimensions: [
{
name: 'base_table__column1__display_id',
sql: 'base_table__column1.display_id',
sql: 'base_table__column1."base_table__column1__display_id"',
type: 'string',
alias: 'base_table__column1__display_id',
},
],
},
{
name: 'base_table__column2',
sql: '<resolution_table_sql>',
sql: 'SELECT base_table__column2."id" AS "base_table__column2__id", base_table__column2."display_id" AS "base_table__column2__display_id", base_table__column2."display_name" AS "base_table__column2__display_name" FROM (<resolution_table_sql>) AS base_table__column2',
measures: [],
dimensions: [
{
name: 'base_table__column2__id',
sql: 'base_table__column2.id',
sql: 'base_table__column2."base_table__column2__id"',
type: 'string',
alias: 'base_table__column2__id',
},
{
name: 'base_table__column2__display_name',
sql: 'base_table__column2.display_name',
sql: 'base_table__column2."base_table__column2__display_name"',
type: 'string',
alias: 'base_table__column2__display_name',
},
Expand Down Expand Up @@ -505,12 +505,12 @@ describe('Generate resolution schemas', () => {
expect(schemas).toEqual([
{
name: 'base_table__column1',
sql: '<resolution_table_sql>',
sql: 'SELECT base_table__column1."id" AS "base_table__column1__id", base_table__column1."display_id" AS "base_table__column1__display_id" FROM (<resolution_table_sql>) AS base_table__column1',
measures: [],
dimensions: [
{
name: 'base_table__column1__display_id',
sql: 'base_table__column1.display_id',
sql: 'base_table__column1."base_table__column1__display_id"',
type: 'string',
alias: 'base_table__column1__display_id',
},
Expand Down Expand Up @@ -573,12 +573,12 @@ describe('Generate resolution schemas', () => {
expect(schemas).toEqual([
{
name: 'base_table__column1',
sql: '<resolution_table_sql>',
sql: 'SELECT base_table__column1."id" AS "base_table__column1__id", base_table__column1."display_id" AS "base_table__column1__display_id" FROM (<resolution_table_sql>) AS base_table__column1',
measures: [],
dimensions: [
{
name: 'base_table__column1__display_id',
sql: 'base_table__column1.display_id',
sql: 'base_table__column1."base_table__column1__display_id"',
type: 'string',
alias: 'base_table__column1__display_id',
},
Expand Down
9 changes: 8 additions & 1 deletion meerkat-core/src/resolution/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { memberKeyToSafeKey } from '../member-formatters/member-key-to-safe-key'
import { Member, Query } from '../types/cube-types/query';
import { Dimension, Measure, TableSchema } from '../types/cube-types/table';
import { isArrayTypeMember } from '../utils/is-array-member-type';
import { resolutionJoinColumnAlias } from './generators/generate-resolution-schemas';
import {
BASE_DATA_SOURCE_NAME,
ResolutionColumnConfig,
Expand Down Expand Up @@ -92,10 +93,16 @@ export const createBaseTableSchema = (
joins: resolutionConfig.columnConfigs.map((columnConfig) => {
const targetTable = memberKeyToSafeKey(columnConfig.name);
return {
// The lookup's join column is projected under a table-qualified alias
// (see buildQualifiedLookupSql), so reference that alias, not the raw
// column name which the lookup subquery no longer exposes.
sql: `${BASE_DATA_SOURCE_NAME}.${constructAliasForSQL(
columnConfig.name,
schemaByName[columnConfig.name]?.alias
)} = ${targetTable}.${columnConfig.joinColumn}`,
)} = ${targetTable}."${resolutionJoinColumnAlias(
columnConfig.name,
columnConfig.joinColumn
)}"`,
};
}),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ describe('resolution-step', () => {
'orders__owner_id__display_name'
);
expect(resolutionSchema.dimensions[0].sql).toBe(
'orders__owner_id.display_name'
'orders__owner_id."orders__owner_id__display_name"'
);
});

Expand Down
Loading
Loading