From a4276427fc5409cddc00e8d51a27cab157b41ea5 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Sun, 5 Jul 2026 22:16:03 +0530 Subject: [PATCH 01/18] feat(meerkat-core): add join filter condition support for structured joins --- meerkat-core/src/joins/v2/joins.ts | 86 +++++++++++++++++++--- meerkat-core/src/types/cube-types/query.ts | 33 +++++++++ 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index d91a2efb..233d87c4 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -1,7 +1,14 @@ import { getUsedTableSchema } from '../../get-used-table-schema/get-used-table-schema'; import { memberKeyToSafeKey } from '../../member-formatters/member-key-to-safe-key'; +import { + JoinFilterCondition, + JoinFilterExpression, + JoinFilterOperand, + Query, + StructuredJoin, + TableSchema, +} from '../../types/cube-types'; import { Graph, quoteIdentifierIfNeeded } from '../v1/joins'; -import { Query, StructuredJoin, TableSchema } from '../../types/cube-types'; const UNNEST_ALIAS_PREFIX = '__mk_u_'; const ARRAY_DIMENSION_TYPES = new Set(['string_array', 'number_array']); @@ -88,7 +95,8 @@ const collectArrayJoinSources = ( * the names emitted in the SELECT list stay in lock-step with the names * referenced in the ON clause. */ -const getUnnestAlias = (column: string): string => `${UNNEST_ALIAS_PREFIX}${column}`; +const getUnnestAlias = (column: string): string => + `${UNNEST_ALIAS_PREFIX}${column}`; /** * ` AS ` — re-aliases the unnested @@ -96,9 +104,14 @@ const getUnnestAlias = (column: string): string => `${UNNEST_ALIAS_PREFIX}${colu * via the same `tableName____mk_u_` identifier they use for any other * member. */ -const buildSafeKeyAliasProjection = (tableName: string, column: string): string => { +const buildSafeKeyAliasProjection = ( + tableName: string, + column: string +): string => { const unnestAlias = getUnnestAlias(column); - return `${unnestAlias} AS ${memberKeyToSafeKey(`${tableName}.${unnestAlias}`)}`; + return `${unnestAlias} AS ${memberKeyToSafeKey( + `${tableName}.${unnestAlias}` + )}`; }; const wrapTableSqlForArrayFrom = ( @@ -124,9 +137,57 @@ const wrapTableSqlForArrayFrom = ( )}`; }; +const escapeValue = (value: unknown): string => { + if (value === null || value === undefined) return 'NULL'; + return `'${String(value).replace(/'/g, "''")}'`; +}; + +const conditionToSql = (cond: JoinFilterCondition): string => { + const { key, operator, json_value } = cond; + switch (operator) { + case 'equals': + return `${key} = ${escapeValue(json_value)}`; + case 'not_equals': + return `${key} != ${escapeValue(json_value)}`; + case 'null': + return `${key} IS NULL`; + case 'not_null': + return `${key} IS NOT NULL`; + case 'empty': + return `${key} = ''`; + case 'not_empty': + return `${key} != ''`; + default: + throw new Error(`Unsupported join condition operator: ${operator}`); + } +}; + +const operandToSql = (operand: JoinFilterOperand): string => { + if (operand.type === 'condition' && operand.condition) { + return conditionToSql(operand.condition); + } + if (operand.type === 'expression' && operand.expression) { + return expressionToSql(operand.expression); + } + throw new Error( + 'Invalid join filter operand: missing condition or expression' + ); +}; + +const expressionToSql = (expr: JoinFilterExpression): string => { + const parts = expr.operands.map(operandToSql); + if (parts.length === 1) return parts[0]; + const joiner = expr.operator === 'and' ? ' AND ' : ' OR '; + return `(${parts.join(joiner)})`; +}; + const buildPredicate = (edge: StructuredJoin, fromIsArray: boolean): string => { - const leftColumn = fromIsArray ? getUnnestAlias(edge.from.column) : edge.from.column; - return `${edge.from.table}.${leftColumn} = ${edge.to.table}.${edge.to.column}`; + const leftColumn = fromIsArray + ? getUnnestAlias(edge.from.column) + : edge.from.column; + const basePredicate = `${edge.from.table}.${leftColumn} = ${edge.to.table}.${edge.to.column}`; + if (!edge.condition) return basePredicate; + return `${basePredicate} AND ${expressionToSql(edge.condition)}`; }; export const createDirectedGraphV2 = ( @@ -141,7 +202,9 @@ export const createDirectedGraphV2 = ( for (const edge of path) { const { from, to } = edge; if (from.table === to.table) { - throw new Error(`Invalid structured join: self-join on "${from.table}"`); + throw new Error( + `Invalid structured join: self-join on "${from.table}"` + ); } if (!tableSchemaSqlMap[from.table] || !tableSchemaSqlMap[to.table]) { continue; @@ -158,7 +221,10 @@ export const createDirectedGraphV2 = ( } graph[from.table] ??= {}; graph[from.table][to.table] ??= {}; - graph[from.table][to.table][from.column] = buildPredicate(edge, fromIsArray); + graph[from.table][to.table][from.column] = buildPredicate( + edge, + fromIsArray + ); } } return graph; @@ -223,7 +289,9 @@ export const generateSqlQueryV2 = ( rightArrayCols, tableSchemas ) - : `(${tableSchemaSqlMap[edge.to.table]}) AS ${quoteIdentifierIfNeeded(edge.to.table)}`; + : `(${tableSchemaSqlMap[edge.to.table]}) AS ${quoteIdentifierIfNeeded( + edge.to.table + )}`; query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; } } diff --git a/meerkat-core/src/types/cube-types/query.ts b/meerkat-core/src/types/cube-types/query.ts index 266011e6..a919b964 100644 --- a/meerkat-core/src/types/cube-types/query.ts +++ b/meerkat-core/src/types/cube-types/query.ts @@ -137,10 +137,43 @@ interface JoinNode { */ } +/** Operator for filter conditions on join edges. */ +export type JoinConditionOperator = + | 'equals' + | 'not_equals' + | 'contains' + | 'empty' + | 'not_empty' + | 'not_null' + | 'null' + | 'range'; + +/** A filter condition on a join edge (e.g. link_type_id = '1234'). */ +export interface JoinFilterCondition { + key: string; + operator: JoinConditionOperator; + json_value?: unknown; + value_type: 'json_value' | 'part_value' | 'relative_date_value' | 'template_value'; +} + +/** An operand in a join filter expression — either a leaf condition or a nested expression. */ +export interface JoinFilterOperand { + type: 'condition' | 'expression'; + condition?: JoinFilterCondition; + expression?: JoinFilterExpression; +} + +/** A filter expression (AND/OR) applied to the intermediate table in a link-type join. */ +export interface JoinFilterExpression { + operator: 'and' | 'or'; + operands: JoinFilterOperand[]; +} + /** Structured join edge used by `joinPathsV2`. See `joins/v2/`. */ export type StructuredJoin = { from: { table: string; column: string }; to: { table: string; column: string }; + condition?: JoinFilterExpression; }; /** From 59f6864a6e5b0da816ed58453ce3ebfed5389f73 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Mon, 6 Jul 2026 11:36:06 +0530 Subject: [PATCH 02/18] feat(meerkat-core): add join filter condition support for structured joins --- meerkat-core/src/joins/v2/joins.ts | 23 +++++++++++++++------- meerkat-core/src/types/cube-types/query.ts | 4 +--- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index 233d87c4..36342078 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -138,25 +138,34 @@ const wrapTableSqlForArrayFrom = ( }; const escapeValue = (value: unknown): string => { - if (value === null || value === undefined) return 'NULL'; return `'${String(value).replace(/'/g, "''")}'`; }; +const quoteKey = (key: string): string => { + return key + .split('.') + .map((part) => quoteIdentifierIfNeeded(part)) + .join('.'); +}; + const conditionToSql = (cond: JoinFilterCondition): string => { const { key, operator, json_value } = cond; + const quotedKey = quoteKey(key); switch (operator) { case 'equals': - return `${key} = ${escapeValue(json_value)}`; + if (json_value === null || json_value === undefined) return `${quotedKey} IS NULL`; + return `${quotedKey} = ${escapeValue(json_value)}`; case 'not_equals': - return `${key} != ${escapeValue(json_value)}`; + if (json_value === null || json_value === undefined) return `${quotedKey} IS NOT NULL`; + return `${quotedKey} != ${escapeValue(json_value)}`; case 'null': - return `${key} IS NULL`; + return `${quotedKey} IS NULL`; case 'not_null': - return `${key} IS NOT NULL`; + return `${quotedKey} IS NOT NULL`; case 'empty': - return `${key} = ''`; + return `${quotedKey} = ''`; case 'not_empty': - return `${key} != ''`; + return `${quotedKey} != ''`; default: throw new Error(`Unsupported join condition operator: ${operator}`); } diff --git a/meerkat-core/src/types/cube-types/query.ts b/meerkat-core/src/types/cube-types/query.ts index a919b964..921f7cd3 100644 --- a/meerkat-core/src/types/cube-types/query.ts +++ b/meerkat-core/src/types/cube-types/query.ts @@ -141,12 +141,10 @@ interface JoinNode { export type JoinConditionOperator = | 'equals' | 'not_equals' - | 'contains' | 'empty' | 'not_empty' | 'not_null' - | 'null' - | 'range'; + | 'null'; /** A filter condition on a join edge (e.g. link_type_id = '1234'). */ export interface JoinFilterCondition { From 933f64e70cf45c917698d9bf31853a7c0ad769ac Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Tue, 7 Jul 2026 15:18:30 +0530 Subject: [PATCH 03/18] Handle bridge tables (link) appearing multiple times in a join path Alias repeated bridge tables (link__1, link__2) to avoid DuckDB ambiguit --- meerkat-core/src/joins/v2/joins.ts | 49 ++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index 36342078..3a2dc825 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -153,10 +153,12 @@ const conditionToSql = (cond: JoinFilterCondition): string => { const quotedKey = quoteKey(key); switch (operator) { case 'equals': - if (json_value === null || json_value === undefined) return `${quotedKey} IS NULL`; + if (json_value === null || json_value === undefined) + return `${quotedKey} IS NULL`; return `${quotedKey} = ${escapeValue(json_value)}`; case 'not_equals': - if (json_value === null || json_value === undefined) return `${quotedKey} IS NOT NULL`; + if (json_value === null || json_value === undefined) + return `${quotedKey} IS NOT NULL`; return `${quotedKey} != ${escapeValue(json_value)}`; case 'null': return `${quotedKey} IS NULL`; @@ -199,6 +201,8 @@ const buildPredicate = (edge: StructuredJoin, fromIsArray: boolean): string => { return `${basePredicate} AND ${expressionToSql(edge.condition)}`; }; +const BRIDGE_TABLES = new Set(['link']); + export const createDirectedGraphV2 = ( tableSchemas: TableSchema[], tableSchemaSqlMap: { [key: string]: string }, @@ -226,7 +230,10 @@ export const createDirectedGraphV2 = ( ); } if (graph[from.table]?.[to.table]?.[from.column]) { - throw new Error('An invalid path was detected.'); + if (!BRIDGE_TABLES.has(to.table)) { + throw new Error('An invalid path was detected.'); + } + continue; } graph[from.table] ??= {}; graph[from.table][to.table] ??= {}; @@ -249,7 +256,7 @@ export const createDirectedGraphV2 = ( export const generateSqlQueryV2 = ( paths: StructuredJoin[][], tableSchemaSqlMap: { [key: string]: string }, - directedGraph: Graph, + _directedGraph: Graph, tableSchemas: TableSchema[] ): string => { if (paths.length === 0) { @@ -276,30 +283,51 @@ export const generateSqlQueryV2 = ( ); const visited = new Map(); + const bridgeAliasCount = new Map(); + const lastBridgeAlias = new Map(); for (const path of paths) { for (const edge of path) { + const fromAlias = lastBridgeAlias.get(edge.from.table) ?? edge.from.table; + lastBridgeAlias.delete(edge.from.table); + const prev = visited.get(edge.to.table); if (prev?.from.table === edge.from.table) continue; - if (prev) { + if (prev && !BRIDGE_TABLES.has(edge.to.table)) { throw new Error( `Path ambiguity, node ${edge.to.table} visited from different sources` ); } - visited.set(edge.to.table, edge); - const onClause = - directedGraph[edge.from.table][edge.to.table][edge.from.column]; + let toAlias = edge.to.table; + if (prev && BRIDGE_TABLES.has(edge.to.table)) { + const count = (bridgeAliasCount.get(edge.to.table) ?? 0) + 1; + bridgeAliasCount.set(edge.to.table, count); + toAlias = `${edge.to.table}__${count}`; + lastBridgeAlias.set(edge.to.table, toAlias); + } else { + visited.set(edge.to.table, edge); + } + + const resolvedEdge: StructuredJoin = { + ...edge, + from: { ...edge.from, table: fromAlias }, + to: { ...edge.to, table: toAlias }, + }; + const onClause = buildPredicate( + resolvedEdge, + isArrayColumn(tableSchemas, edge.from.table, edge.from.column) + ); const rightArrayCols = arraySourcesByTable.get(edge.to.table); const rightSubquery = rightArrayCols?.size ? wrapTableSqlForArrayFrom( tableSchemaSqlMap[edge.to.table], - edge.to.table, + toAlias, rightArrayCols, tableSchemas ) : `(${tableSchemaSqlMap[edge.to.table]}) AS ${quoteIdentifierIfNeeded( - edge.to.table + toAlias )}`; query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; } @@ -313,6 +341,7 @@ const hasLoop = (paths: StructuredJoin[][]): boolean => { const visited = new Set(); if (path[0]) visited.add(path[0].from.table); for (const edge of path) { + if (BRIDGE_TABLES.has(edge.to.table)) continue; if (visited.has(edge.to.table)) return true; visited.add(edge.to.table); } From e11c31009977f4d0f31e3945a9934e13f45c52d9 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Tue, 7 Jul 2026 15:41:31 +0530 Subject: [PATCH 04/18] make the alias functions modular --- meerkat-core/src/joins/v2/joins.ts | 74 +++++++++++++++++------------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index 3a2dc825..e0825d17 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -203,6 +203,35 @@ const buildPredicate = (edge: StructuredJoin, fromIsArray: boolean): string => { const BRIDGE_TABLES = new Set(['link']); +const aliasBridgeTables = (paths: StructuredJoin[][]): StructuredJoin[][] => + paths.map((path) => { + const seen = new Map(); + if (path[0]) seen.set(path[0].from.table, 1); + + const result: StructuredJoin[] = []; + for (let i = 0; i < path.length; i++) { + const edge = path[i]; + const count = seen.get(edge.to.table) ?? 0; + seen.set(edge.to.table, count + 1); + + if (count === 0 || !BRIDGE_TABLES.has(edge.to.table)) { + result.push(edge); + continue; + } + + const alias = `${edge.to.table}__${count}`; + result.push({ ...edge, to: { ...edge.to, table: alias } }); + + if (i + 1 < path.length && path[i + 1].from.table === edge.to.table) { + path[i + 1] = { + ...path[i + 1], + from: { ...path[i + 1].from, table: alias }, + }; + } + } + return result; + }); + export const createDirectedGraphV2 = ( tableSchemas: TableSchema[], tableSchemaSqlMap: { [key: string]: string }, @@ -230,10 +259,7 @@ export const createDirectedGraphV2 = ( ); } if (graph[from.table]?.[to.table]?.[from.column]) { - if (!BRIDGE_TABLES.has(to.table)) { - throw new Error('An invalid path was detected.'); - } - continue; + throw new Error('An invalid path was detected.'); } graph[from.table] ??= {}; graph[from.table][to.table] ??= {}; @@ -282,53 +308,37 @@ export const generateSqlQueryV2 = ( tableSchemas ); + const resolvedPaths = aliasBridgeTables(paths); + const visited = new Map(); - const bridgeAliasCount = new Map(); - const lastBridgeAlias = new Map(); - for (const path of paths) { + for (const path of resolvedPaths) { for (const edge of path) { - const fromAlias = lastBridgeAlias.get(edge.from.table) ?? edge.from.table; - lastBridgeAlias.delete(edge.from.table); - const prev = visited.get(edge.to.table); if (prev?.from.table === edge.from.table) continue; - if (prev && !BRIDGE_TABLES.has(edge.to.table)) { + if (prev) { throw new Error( `Path ambiguity, node ${edge.to.table} visited from different sources` ); } + visited.set(edge.to.table, edge); - let toAlias = edge.to.table; - if (prev && BRIDGE_TABLES.has(edge.to.table)) { - const count = (bridgeAliasCount.get(edge.to.table) ?? 0) + 1; - bridgeAliasCount.set(edge.to.table, count); - toAlias = `${edge.to.table}__${count}`; - lastBridgeAlias.set(edge.to.table, toAlias); - } else { - visited.set(edge.to.table, edge); - } - - const resolvedEdge: StructuredJoin = { - ...edge, - from: { ...edge.from, table: fromAlias }, - to: { ...edge.to, table: toAlias }, - }; const onClause = buildPredicate( - resolvedEdge, + edge, isArrayColumn(tableSchemas, edge.from.table, edge.from.column) ); + const baseSql = + tableSchemaSqlMap[edge.to.table] ?? + tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')]; const rightArrayCols = arraySourcesByTable.get(edge.to.table); const rightSubquery = rightArrayCols?.size ? wrapTableSqlForArrayFrom( - tableSchemaSqlMap[edge.to.table], - toAlias, + baseSql, + edge.to.table, rightArrayCols, tableSchemas ) - : `(${tableSchemaSqlMap[edge.to.table]}) AS ${quoteIdentifierIfNeeded( - toAlias - )}`; + : `(${baseSql}) AS ${quoteIdentifierIfNeeded(edge.to.table)}`; query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; } } From 41c8b3944c870dfdfb54c4385fe43cb1fabbb398 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Tue, 7 Jul 2026 15:58:03 +0530 Subject: [PATCH 05/18] remove unnecessary function quoteKey --- meerkat-core/src/joins/v2/joins.ts | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index e0825d17..a1e648c3 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -141,33 +141,25 @@ const escapeValue = (value: unknown): string => { return `'${String(value).replace(/'/g, "''")}'`; }; -const quoteKey = (key: string): string => { - return key - .split('.') - .map((part) => quoteIdentifierIfNeeded(part)) - .join('.'); -}; - const conditionToSql = (cond: JoinFilterCondition): string => { const { key, operator, json_value } = cond; - const quotedKey = quoteKey(key); switch (operator) { case 'equals': if (json_value === null || json_value === undefined) - return `${quotedKey} IS NULL`; - return `${quotedKey} = ${escapeValue(json_value)}`; + return `${key} IS NULL`; + return `${key} = ${escapeValue(json_value)}`; case 'not_equals': if (json_value === null || json_value === undefined) - return `${quotedKey} IS NOT NULL`; - return `${quotedKey} != ${escapeValue(json_value)}`; + return `${key} IS NOT NULL`; + return `${key} != ${escapeValue(json_value)}`; case 'null': - return `${quotedKey} IS NULL`; + return `${key} IS NULL`; case 'not_null': - return `${quotedKey} IS NOT NULL`; + return `${key} IS NOT NULL`; case 'empty': - return `${quotedKey} = ''`; + return `${key} = ''`; case 'not_empty': - return `${quotedKey} != ''`; + return `${key} != ''`; default: throw new Error(`Unsupported join condition operator: ${operator}`); } From c8d9e4ceb4c8d516a9bde13ab6c1b8a11a17608e Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Tue, 7 Jul 2026 16:16:38 +0530 Subject: [PATCH 06/18] simplyfiy the implementation --- meerkat-core/src/joins/v2/joins.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index a1e648c3..50302a9c 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -274,7 +274,7 @@ export const createDirectedGraphV2 = ( export const generateSqlQueryV2 = ( paths: StructuredJoin[][], tableSchemaSqlMap: { [key: string]: string }, - _directedGraph: Graph, + directedGraph: Graph, tableSchemas: TableSchema[] ): string => { if (paths.length === 0) { @@ -315,22 +315,18 @@ export const generateSqlQueryV2 = ( } visited.set(edge.to.table, edge); - const onClause = buildPredicate( - edge, - isArrayColumn(tableSchemas, edge.from.table, edge.from.column) - ); - const baseSql = - tableSchemaSqlMap[edge.to.table] ?? - tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')]; + const onClause = + directedGraph[edge.from.table]?.[edge.to.table]?.[edge.from.column] ?? + buildPredicate(edge, isArrayColumn(tableSchemas, edge.from.table, edge.from.column)); const rightArrayCols = arraySourcesByTable.get(edge.to.table); const rightSubquery = rightArrayCols?.size ? wrapTableSqlForArrayFrom( - baseSql, + tableSchemaSqlMap[edge.to.table] ?? tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')], edge.to.table, rightArrayCols, tableSchemas ) - : `(${baseSql}) AS ${quoteIdentifierIfNeeded(edge.to.table)}`; + : `(${tableSchemaSqlMap[edge.to.table] ?? tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')]}) AS ${quoteIdentifierIfNeeded(edge.to.table)}`; query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; } } From 4501a1ced349e76f8e862a40cd204c17810bf251 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Tue, 7 Jul 2026 16:24:43 +0530 Subject: [PATCH 07/18] links gives An invalid path was detected --- meerkat-core/src/joins/v1/joins.ts | 12 +++++++++--- meerkat-core/src/joins/v2/joins.ts | 15 +++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/meerkat-core/src/joins/v1/joins.ts b/meerkat-core/src/joins/v1/joins.ts index 138cb186..7b7b782c 100644 --- a/meerkat-core/src/joins/v1/joins.ts +++ b/meerkat-core/src/joins/v1/joins.ts @@ -1,5 +1,10 @@ import { getUsedTableSchema } from '../../get-used-table-schema/get-used-table-schema'; -import { JoinPath, Query, TableSchema, isJoinNode } from '../../types/cube-types'; +import { + JoinPath, + Query, + TableSchema, + isJoinNode, +} from '../../types/cube-types'; /** * Regex pattern to match a CONTAINS function call in join SQL. @@ -20,7 +25,8 @@ import { JoinPath, Query, TableSchema, isJoinNode } from '../../types/cube-types * - " contains( table1.items , table2.id ) " * - "CONTAINS(\"quoted.table\".col, other.col)" */ -const CONTAINS_FUNCTION_REGEX = /^\s*CONTAINS\s*\(\s*(.+?)\s*,\s*(.+?)\s*\)\s*$/i; +const CONTAINS_FUNCTION_REGEX = + /^\s*CONTAINS\s*\(\s*(.+?)\s*,\s*(.+?)\s*\)\s*$/i; /** * Regex pattern to validate table.column reference format. @@ -253,7 +259,7 @@ export const createDirectedGraph = ( directedGraph[table1][table2] && directedGraph[table1][table2][joinOn]) ) { - throw new Error('An invalid path was detected.'); + return; } if (!directedGraph[table1]) directedGraph[table1] = {}; if (!directedGraph[table1][table2]) directedGraph[table1][table2] = {}; diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index 50302a9c..fa0c71ce 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -251,7 +251,7 @@ export const createDirectedGraphV2 = ( ); } if (graph[from.table]?.[to.table]?.[from.column]) { - throw new Error('An invalid path was detected.'); + continue; } graph[from.table] ??= {}; graph[from.table][to.table] ??= {}; @@ -317,16 +317,23 @@ export const generateSqlQueryV2 = ( const onClause = directedGraph[edge.from.table]?.[edge.to.table]?.[edge.from.column] ?? - buildPredicate(edge, isArrayColumn(tableSchemas, edge.from.table, edge.from.column)); + buildPredicate( + edge, + isArrayColumn(tableSchemas, edge.from.table, edge.from.column) + ); const rightArrayCols = arraySourcesByTable.get(edge.to.table); const rightSubquery = rightArrayCols?.size ? wrapTableSqlForArrayFrom( - tableSchemaSqlMap[edge.to.table] ?? tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')], + tableSchemaSqlMap[edge.to.table] ?? + tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')], edge.to.table, rightArrayCols, tableSchemas ) - : `(${tableSchemaSqlMap[edge.to.table] ?? tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')]}) AS ${quoteIdentifierIfNeeded(edge.to.table)}`; + : `(${ + tableSchemaSqlMap[edge.to.table] ?? + tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')] + }) AS ${quoteIdentifierIfNeeded(edge.to.table)}`; query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; } } From bb38c054d2ad903ba25decca955634e2f1644945 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Wed, 8 Jul 2026 11:39:58 +0530 Subject: [PATCH 08/18] refactor: reuse existing filter AST infrastructure for join conditions --- .../browser-cube-to-sql.ts | 3 +- meerkat-core/src/joins/index.spec.ts | 8 +- meerkat-core/src/joins/index.ts | 8 +- meerkat-core/src/joins/v2/joins.spec.ts | 280 +++++++++++++++++- meerkat-core/src/joins/v2/joins.ts | 223 +++++++++++--- meerkat-node/src/cube-to-sql/cube-to-sql.ts | 2 +- 6 files changed, 454 insertions(+), 70 deletions(-) diff --git a/meerkat-browser/src/browser-cube-to-sql/browser-cube-to-sql.ts b/meerkat-browser/src/browser-cube-to-sql/browser-cube-to-sql.ts index ecf7165e..942984e2 100644 --- a/meerkat-browser/src/browser-cube-to-sql/browser-cube-to-sql.ts +++ b/meerkat-browser/src/browser-cube-to-sql/browser-cube-to-sql.ts @@ -54,7 +54,8 @@ export const cubeQueryToSQL = async ({ const updatedTableSchema = await getCombinedTableSchema( updatedTableSchemas, - query + query, + (q) => getQueryOutput(q, connection) ); const ast = cubeToDuckdbAST(query, updatedTableSchema, { diff --git a/meerkat-core/src/joins/index.spec.ts b/meerkat-core/src/joins/index.spec.ts index 6e352fdb..0b6d3237 100644 --- a/meerkat-core/src/joins/index.spec.ts +++ b/meerkat-core/src/joins/index.spec.ts @@ -41,7 +41,7 @@ const withArrayCols = ( }); describe('joins router', () => { - it('routes to v2 when joinPathsV2 is set', () => { + it('routes to v2 when joinPathsV2 is set', async () => { const schemas = [ withArrayCols('issues', ['id'], ['owned_by_ids']), scalar('users'), @@ -58,12 +58,12 @@ describe('joins router', () => { ], ], }; - const result = getCombinedTableSchema(schemas, cubeQuery); + const result = await getCombinedTableSchema(schemas, cubeQuery); expect(result.sql).toContain('UNNEST(owned_by_ids)'); expect(result.sql).not.toMatch(/CONTAINS/i); }); - it('routes to v1 when joinPathsV2 is absent', () => { + it('routes to v1 when joinPathsV2 is absent', async () => { const schemas = [ { ...scalar('orders', ['id', 'customer_id']), @@ -76,7 +76,7 @@ describe('joins router', () => { dimensions: ['customers.id'], joinPaths: [[{ left: 'orders', right: 'customers', on: 'customer_id' }]], }; - const result = getCombinedTableSchema(schemas, cubeQuery); + const result = await getCombinedTableSchema(schemas, cubeQuery); expect(result.sql).toContain('orders.customer_id = customers.id'); expect(result.sql).not.toMatch(/UNNEST/); }); diff --git a/meerkat-core/src/joins/index.ts b/meerkat-core/src/joins/index.ts index 45273d1b..e937e65c 100644 --- a/meerkat-core/src/joins/index.ts +++ b/meerkat-core/src/joins/index.ts @@ -1,16 +1,18 @@ import type { Query } from '../types/cube-types'; +import type { GetQueryOutput } from '../utils/duckdb-ast-parse-serialize'; import { hasJoinPathsV2 } from './accessors'; import { getCombinedTableSchemaV2 } from './v2/joins'; import { getCombinedTableSchema as getCombinedTableSchemaV1 } from './v1/joins'; export { hasAnyJoinPaths, hasJoinPathsV2 } from './accessors'; -export const getCombinedTableSchema = ( +export const getCombinedTableSchema = async ( tableSchema: Parameters[0], - cubeQuery: Query + cubeQuery: Query, + getQueryOutput?: GetQueryOutput ) => { if (hasJoinPathsV2(cubeQuery)) { - return getCombinedTableSchemaV2(tableSchema, cubeQuery); + return getCombinedTableSchemaV2(tableSchema, cubeQuery, getQueryOutput); } return getCombinedTableSchemaV1(tableSchema, cubeQuery); }; diff --git a/meerkat-core/src/joins/v2/joins.spec.ts b/meerkat-core/src/joins/v2/joins.spec.ts index 349215bb..187249e9 100644 --- a/meerkat-core/src/joins/v2/joins.spec.ts +++ b/meerkat-core/src/joins/v2/joins.spec.ts @@ -1,4 +1,5 @@ -import { StructuredJoin, TableSchema } from '../../types/cube-types'; +import { JoinFilterExpression, StructuredJoin, TableSchema } from '../../types/cube-types'; +import { GetQueryOutput } from '../../utils/duckdb-ast-parse-serialize'; import { createDirectedGraphV2, generateSqlQueryV2 } from './joins'; const scalar = (name: string, cols: string[] = ['id']): TableSchema => ({ @@ -43,7 +44,7 @@ const sqlMapOf = (schemas: TableSchema[]): { [k: string]: string } => ); describe('joins-v2', () => { - it('emits a plain equi-join when from is scalar', () => { + it('emits a plain equi-join when from is scalar', async () => { const schemas = [scalar('orders', ['id', 'customer_id']), scalar('customers')]; const sqlMap = sqlMapOf(schemas); const paths: StructuredJoin[][] = [ @@ -55,13 +56,13 @@ describe('joins-v2', () => { ], ]; const graph = createDirectedGraphV2(schemas, sqlMap, paths); - const sql = generateSqlQueryV2(paths, sqlMap, graph, schemas); + const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas); expect(sql).not.toMatch(/UNNEST/); expect(sql).toContain('orders.customer_id = customers.id'); }); - it('wraps the base with UNNEST when from.column is array-typed', () => { + it('wraps the base with UNNEST when from.column is array-typed', async () => { const schemas = [ withArrayCols('issues', ['id'], ['owned_by_ids']), scalar('users'), @@ -76,14 +77,14 @@ describe('joins-v2', () => { ], ]; const graph = createDirectedGraphV2(schemas, sqlMap, paths); - const sql = generateSqlQueryV2(paths, sqlMap, graph, schemas); + const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas); expect(sql).toContain('UNNEST(owned_by_ids) AS __mk_u_owned_by_ids'); expect(sql).toContain('issues.__mk_u_owned_by_ids = users.id'); expect(sql).not.toMatch(/CONTAINS/i); }); - it('shares one UNNEST projection across edges on the same base array column', () => { + it('shares one UNNEST projection across edges on the same base array column', async () => { const schemas = [ withArrayCols('issues', ['id'], ['owned_by_ids']), scalar('users'), @@ -103,14 +104,14 @@ describe('joins-v2', () => { ], ]; const graph = createDirectedGraphV2(schemas, sqlMap, paths); - const sql = generateSqlQueryV2(paths, sqlMap, graph, schemas); + const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas); expect(sql.match(/UNNEST\(owned_by_ids\)/g)).toHaveLength(1); expect(sql).toContain('issues.__mk_u_owned_by_ids = users.id'); expect(sql).toContain('issues.__mk_u_owned_by_ids = admins.id'); }); - it('inlines dim.sql for composite-child synthetic array columns whose name is not a real column', () => { + it('inlines dim.sql for composite-child synthetic array columns whose name is not a real column', async () => { // The synthetic dim `tags_$0_tag_id` is not a real column on the // base table — only the parent `tags` struct array is. UNNEST must // reference the dim's `sql` expression, not the synthetic name. @@ -141,7 +142,7 @@ describe('joins-v2', () => { ], ]; const graph = createDirectedGraphV2(schemas, sqlMap, paths); - const sql = generateSqlQueryV2(paths, sqlMap, graph, schemas); + const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas); expect(sql).toContain( "UNNEST(json_extract_string(tags, '$[*].tag_id')) AS __mk_u_tags_$0_tag_id" @@ -149,7 +150,7 @@ describe('joins-v2', () => { expect(sql).toContain('parts.__mk_u_tags_$0_tag_id = tags.id'); }); - it('wraps a multi-hop intermediate table when its from.column is array-typed', () => { + it('wraps a multi-hop intermediate table when its from.column is array-typed', async () => { const schemas = [ scalar('tickets', ['id', 'part_id']), withArrayCols('parts', ['id'], ['tag_ids']), @@ -169,14 +170,14 @@ describe('joins-v2', () => { ], ]; const graph = createDirectedGraphV2(schemas, sqlMap, paths); - const sql = generateSqlQueryV2(paths, sqlMap, graph, schemas); + const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas); expect(sql).toContain('tickets.part_id = parts.id'); expect(sql).toContain('UNNEST(tag_ids) AS __mk_u_tag_ids'); expect(sql).toContain('parts.__mk_u_tag_ids = tags.id'); }); - it('strips table qualifier from CAST-wrapped array dim sql (ensureTableSchemasAlias output)', () => { + it('strips table qualifier from CAST-wrapped array dim sql (ensureTableSchemasAlias output)', async () => { // After ensureTableSchemasAlias runs, the dim's sql becomes // `CAST(issue.owned_by_ids AS VARCHAR[])` instead of plain `owned_by_ids`. // The UNNEST wrap must strip the `issue.` qualifier since the inner @@ -208,7 +209,7 @@ describe('joins-v2', () => { ], ]; const graph = createDirectedGraphV2(schemas, sqlMap, paths); - const sql = generateSqlQueryV2(paths, sqlMap, graph, schemas); + const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas); // The UNNEST expression must NOT contain `issue.` since it's in an unnamed subquery scope expect(sql).toContain('UNNEST(CAST(owned_by_ids AS VARCHAR[]))'); @@ -235,4 +236,257 @@ describe('joins-v2', () => { ); }); + it('throws when condition is present but getQueryOutput is not provided', async () => { + const schemas = [ + scalar('issue', ['id']), + scalar('link', ['id', 'source_id', 'link_type_id']), + ]; + const sqlMap = sqlMapOf(schemas); + const condition: JoinFilterExpression = { + operator: 'and', + operands: [ + { + type: 'condition', + condition: { + key: 'link_type_id', + operator: 'equals', + json_value: '1234', + value_type: 'json_value', + }, + }, + ], + }; + const paths: StructuredJoin[][] = [ + [ + { + from: { table: 'issue', column: 'id' }, + to: { table: 'link', column: 'source_id' }, + condition, + }, + ], + ]; + const graph = createDirectedGraphV2(schemas, sqlMap, paths); + await expect( + generateSqlQueryV2(paths, sqlMap, graph, schemas) + ).rejects.toThrow(/getQueryOutput is required/); + }); + + it('appends serialized condition to ON clause when getQueryOutput is provided', async () => { + const schemas = [ + scalar('issue', ['id']), + scalar('link', ['id', 'source_id', 'link_type_id']), + ]; + const sqlMap = sqlMapOf(schemas); + const condition: JoinFilterExpression = { + operator: 'and', + operands: [ + { + type: 'condition', + condition: { + key: 'link_type_id', + operator: 'equals', + json_value: '1234', + value_type: 'json_value', + }, + }, + ], + }; + const paths: StructuredJoin[][] = [ + [ + { + from: { table: 'issue', column: 'id' }, + to: { table: 'link', column: 'source_id' }, + condition, + }, + ], + ]; + const graph = createDirectedGraphV2(schemas, sqlMap, paths); + + const mockGetQueryOutput: GetQueryOutput = async () => { + return [ + { + result: "SELECT (link_type_id = '1234') AS __meerkat_batch_expr_0__;", + }, + ]; + }; + + const sql = await generateSqlQueryV2( + paths, + sqlMap, + graph, + schemas, + mockGetQueryOutput + ); + + expect(sql).toContain('issue.id = link.source_id'); + expect(sql).toContain("AND (link_type_id = '1234')"); + }); + + it('serializes multiple conditions across edges in a single batch', async () => { + const schemas = [ + scalar('issue', ['id']), + scalar('link', ['id', 'source_id', 'target_id', 'link_type_id']), + scalar('part', ['id']), + ]; + const sqlMap = sqlMapOf(schemas); + const paths: StructuredJoin[][] = [ + [ + { + from: { table: 'issue', column: 'id' }, + to: { table: 'link', column: 'source_id' }, + condition: { + operator: 'and', + operands: [ + { + type: 'condition', + condition: { + key: 'link_type_id', + operator: 'equals', + json_value: 'type_a', + value_type: 'json_value', + }, + }, + ], + }, + }, + { + from: { table: 'link', column: 'target_id' }, + to: { table: 'part', column: 'id' }, + }, + ], + ]; + const graph = createDirectedGraphV2(schemas, sqlMap, paths); + + let callCount = 0; + const mockGetQueryOutput: GetQueryOutput = async () => { + callCount++; + return [ + { + result: "SELECT (link_type_id = 'type_a') AS __meerkat_batch_expr_0__;", + }, + ]; + }; + + const sql = await generateSqlQueryV2( + paths, + sqlMap, + graph, + schemas, + mockGetQueryOutput + ); + + expect(callCount).toBe(1); + expect(sql).toContain("issue.id = link.source_id AND (link_type_id = 'type_a')"); + expect(sql).toContain('link.target_id = part.id'); + expect(sql).not.toContain("AND (link_type_id = 'type_a') AND"); + }); + + it('handles IS NULL condition (null operator)', async () => { + const schemas = [ + scalar('issue', ['id']), + scalar('link', ['id', 'source_id', 'deleted_at']), + ]; + const sqlMap = sqlMapOf(schemas); + const paths: StructuredJoin[][] = [ + [ + { + from: { table: 'issue', column: 'id' }, + to: { table: 'link', column: 'source_id' }, + condition: { + operator: 'and', + operands: [ + { + type: 'condition', + condition: { + key: 'deleted_at', + operator: 'null', + value_type: 'json_value', + }, + }, + ], + }, + }, + ], + ]; + const graph = createDirectedGraphV2(schemas, sqlMap, paths); + + const mockGetQueryOutput: GetQueryOutput = async () => { + return [ + { + result: 'SELECT (deleted_at IS NULL) AS __meerkat_batch_expr_0__;', + }, + ]; + }; + + const sql = await generateSqlQueryV2( + paths, + sqlMap, + graph, + schemas, + mockGetQueryOutput + ); + + expect(sql).toContain('issue.id = link.source_id AND (deleted_at IS NULL)'); + }); + + it('handles OR expression with multiple conditions', async () => { + const schemas = [ + scalar('issue', ['id']), + scalar('link', ['id', 'source_id', 'link_type_id']), + ]; + const sqlMap = sqlMapOf(schemas); + const paths: StructuredJoin[][] = [ + [ + { + from: { table: 'issue', column: 'id' }, + to: { table: 'link', column: 'source_id' }, + condition: { + operator: 'or', + operands: [ + { + type: 'condition', + condition: { + key: 'link_type_id', + operator: 'equals', + json_value: 'type_a', + value_type: 'json_value', + }, + }, + { + type: 'condition', + condition: { + key: 'link_type_id', + operator: 'equals', + json_value: 'type_b', + value_type: 'json_value', + }, + }, + ], + }, + }, + ], + ]; + const graph = createDirectedGraphV2(schemas, sqlMap, paths); + + const mockGetQueryOutput: GetQueryOutput = async () => { + return [ + { + result: + "SELECT ((link_type_id = 'type_a') OR (link_type_id = 'type_b')) AS __meerkat_batch_expr_0__;", + }, + ]; + }; + + const sql = await generateSqlQueryV2( + paths, + sqlMap, + graph, + schemas, + mockGetQueryOutput + ); + + expect(sql).toContain('issue.id = link.source_id AND'); + expect(sql).toContain("(link_type_id = 'type_a') OR (link_type_id = 'type_b')"); + }); + }); diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index fa0c71ce..f39113f7 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -1,4 +1,10 @@ import { getUsedTableSchema } from '../../get-used-table-schema/get-used-table-schema'; +import { andDuckdbCondition } from '../../cube-filter-transformer/and/and'; +import { orDuckdbCondition } from '../../cube-filter-transformer/or/or'; +import { + baseDuckdbCondition, + createColumnRef, +} from '../../cube-filter-transformer/base-condition-builder/base-condition-builder'; import { memberKeyToSafeKey } from '../../member-formatters/member-key-to-safe-key'; import { JoinFilterCondition, @@ -8,6 +14,14 @@ import { StructuredJoin, TableSchema, } from '../../types/cube-types'; +import { Dimension, Measure } from '../../types/cube-types/table'; +import { + ExpressionClass, + ExpressionType, +} from '../../types/duckdb-serialization-types/serialization/Expression'; +import { ParsedExpression } from '../../types/duckdb-serialization-types/serialization/ParsedExpression'; +import { serializeExpressions, GetQueryOutput } from '../../utils/duckdb-ast-parse-serialize'; +import { findInSchema } from '../../utils/find-in-table-schema'; import { Graph, quoteIdentifierIfNeeded } from '../v1/joins'; const UNNEST_ALIAS_PREFIX = '__mk_u_'; @@ -137,60 +151,128 @@ const wrapTableSqlForArrayFrom = ( )}`; }; -const escapeValue = (value: unknown): string => { - return `'${String(value).replace(/'/g, "''")}'`; +const getMemberInfo = ( + key: string, + tableSchema: TableSchema | undefined +): Measure | Dimension => { + if (tableSchema) { + const memberInfo = findInSchema(key, tableSchema); + if (memberInfo) return memberInfo; + } + return { name: key, sql: key, type: 'string' }; }; -const conditionToSql = (cond: JoinFilterCondition): string => { +const conditionToAST = ( + cond: JoinFilterCondition, + tableSchema: TableSchema | undefined +): ParsedExpression => { const { key, operator, json_value } = cond; + const columnRef = createColumnRef(key, { isAlias: true }); + const memberInfo = getMemberInfo(key, tableSchema); + switch (operator) { case 'equals': - if (json_value === null || json_value === undefined) - return `${key} IS NULL`; - return `${key} = ${escapeValue(json_value)}`; + if (json_value === null || json_value === undefined) { + return { + class: ExpressionClass.OPERATOR, + type: ExpressionType.OPERATOR_IS_NULL, + alias: '', + children: [columnRef], + }; + } + return baseDuckdbCondition( + key, + ExpressionType.COMPARE_EQUAL, + String(json_value), + memberInfo, + { isAlias: true } + ); case 'not_equals': - if (json_value === null || json_value === undefined) - return `${key} IS NOT NULL`; - return `${key} != ${escapeValue(json_value)}`; + if (json_value === null || json_value === undefined) { + return { + class: ExpressionClass.OPERATOR, + type: ExpressionType.OPERATOR_IS_NOT_NULL, + alias: '', + children: [columnRef], + }; + } + return baseDuckdbCondition( + key, + ExpressionType.COMPARE_NOTEQUAL, + String(json_value), + memberInfo, + { isAlias: true } + ); case 'null': - return `${key} IS NULL`; + return { + class: ExpressionClass.OPERATOR, + type: ExpressionType.OPERATOR_IS_NULL, + alias: '', + children: [columnRef], + }; case 'not_null': - return `${key} IS NOT NULL`; + return { + class: ExpressionClass.OPERATOR, + type: ExpressionType.OPERATOR_IS_NOT_NULL, + alias: '', + children: [columnRef], + }; case 'empty': - return `${key} = ''`; + return baseDuckdbCondition( + key, + ExpressionType.COMPARE_EQUAL, + '', + memberInfo, + { isAlias: true } + ); case 'not_empty': - return `${key} != ''`; + return baseDuckdbCondition( + key, + ExpressionType.COMPARE_NOTEQUAL, + '', + memberInfo, + { isAlias: true } + ); default: throw new Error(`Unsupported join condition operator: ${operator}`); } }; -const operandToSql = (operand: JoinFilterOperand): string => { +const operandToAST = ( + operand: JoinFilterOperand, + tableSchema: TableSchema | undefined +): ParsedExpression => { if (operand.type === 'condition' && operand.condition) { - return conditionToSql(operand.condition); + return conditionToAST(operand.condition, tableSchema); } if (operand.type === 'expression' && operand.expression) { - return expressionToSql(operand.expression); + return expressionToAST(operand.expression, tableSchema); } throw new Error( 'Invalid join filter operand: missing condition or expression' ); }; -const expressionToSql = (expr: JoinFilterExpression): string => { - const parts = expr.operands.map(operandToSql); +const expressionToAST = ( + expr: JoinFilterExpression, + tableSchema: TableSchema | undefined +): ParsedExpression => { + const parts = expr.operands.map((op) => operandToAST(op, tableSchema)); if (parts.length === 1) return parts[0]; - const joiner = expr.operator === 'and' ? ' AND ' : ' OR '; - return `(${parts.join(joiner)})`; + const conjunction = + expr.operator === 'and' ? andDuckdbCondition() : orDuckdbCondition(); + conjunction.children = parts; + return conjunction; }; -const buildPredicate = (edge: StructuredJoin, fromIsArray: boolean): string => { +const buildEquiJoinPredicate = ( + edge: StructuredJoin, + fromIsArray: boolean +): string => { const leftColumn = fromIsArray ? getUnnestAlias(edge.from.column) : edge.from.column; - const basePredicate = `${edge.from.table}.${leftColumn} = ${edge.to.table}.${edge.to.column}`; - if (!edge.condition) return basePredicate; - return `${basePredicate} AND ${expressionToSql(edge.condition)}`; + return `${edge.from.table}.${leftColumn} = ${edge.to.table}.${edge.to.column}`; }; const BRIDGE_TABLES = new Set(['link']); @@ -255,7 +337,7 @@ export const createDirectedGraphV2 = ( } graph[from.table] ??= {}; graph[from.table][to.table] ??= {}; - graph[from.table][to.table][from.column] = buildPredicate( + graph[from.table][to.table][from.column] = buildEquiJoinPredicate( edge, fromIsArray ); @@ -271,12 +353,13 @@ export const createDirectedGraphV2 = ( * gets UNNEST projections so the join becomes a hash-joinable equi-join * (`base.__mk_u_col = right.col`) instead of a `CONTAINS(...)` scan. */ -export const generateSqlQueryV2 = ( +export const generateSqlQueryV2 = async ( paths: StructuredJoin[][], tableSchemaSqlMap: { [key: string]: string }, directedGraph: Graph, - tableSchemas: TableSchema[] -): string => { + tableSchemas: TableSchema[], + getQueryOutput?: GetQueryOutput +): Promise => { if (paths.length === 0) { throw new Error( 'Invalid path, multiple data sources are present without a join path.' @@ -304,6 +387,11 @@ export const generateSqlQueryV2 = ( const visited = new Map(); + // Collect edges in visitation order and their condition ASTs + const edgeOrder: { edge: StructuredJoin; equiJoin: string }[] = []; + const conditionASTs: ParsedExpression[] = []; + const conditionIndexByEdge: number[] = []; + for (const path of resolvedPaths) { for (const edge of path) { const prev = visited.get(edge.to.table); @@ -315,29 +403,61 @@ export const generateSqlQueryV2 = ( } visited.set(edge.to.table, edge); - const onClause = + const equiJoin = directedGraph[edge.from.table]?.[edge.to.table]?.[edge.from.column] ?? - buildPredicate( + buildEquiJoinPredicate( edge, isArrayColumn(tableSchemas, edge.from.table, edge.from.column) ); - const rightArrayCols = arraySourcesByTable.get(edge.to.table); - const rightSubquery = rightArrayCols?.size - ? wrapTableSqlForArrayFrom( - tableSchemaSqlMap[edge.to.table] ?? - tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')], - edge.to.table, - rightArrayCols, - tableSchemas - ) - : `(${ - tableSchemaSqlMap[edge.to.table] ?? - tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')] - }) AS ${quoteIdentifierIfNeeded(edge.to.table)}`; - query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; + + edgeOrder.push({ edge, equiJoin }); + + if (edge.condition) { + const toTableSchema = tableSchemas.find( + (s) => s.name === edge.to.table || s.name === edge.to.table.replace(/__\d+$/, '') + ); + conditionIndexByEdge.push(conditionASTs.length); + conditionASTs.push(expressionToAST(edge.condition, toTableSchema)); + } else { + conditionIndexByEdge.push(-1); + } } } + // Batch-serialize all condition ASTs to SQL in one DuckDB call + let conditionSqls: string[] = []; + if (conditionASTs.length > 0) { + if (!getQueryOutput) { + throw new Error( + 'getQueryOutput is required when join edges have filter conditions' + ); + } + conditionSqls = await serializeExpressions(conditionASTs, getQueryOutput); + } + + // Build the final JOIN SQL + for (let i = 0; i < edgeOrder.length; i++) { + const { edge, equiJoin } = edgeOrder[i]; + const condIdx = conditionIndexByEdge[i]; + const onClause = + condIdx >= 0 ? `${equiJoin} AND ${conditionSqls[condIdx]}` : equiJoin; + + const rightArrayCols = arraySourcesByTable.get(edge.to.table); + const rightSubquery = rightArrayCols?.size + ? wrapTableSqlForArrayFrom( + tableSchemaSqlMap[edge.to.table] ?? + tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')], + edge.to.table, + rightArrayCols, + tableSchemas + ) + : `(${ + tableSchemaSqlMap[edge.to.table] ?? + tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')] + }) AS ${quoteIdentifierIfNeeded(edge.to.table)}`; + query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; + } + return query; }; @@ -354,10 +474,11 @@ const hasLoop = (paths: StructuredJoin[][]): boolean => { return false; }; -export const getCombinedTableSchemaV2 = ( +export const getCombinedTableSchemaV2 = async ( tableSchema: TableSchema[], - cubeQuery: Query -): TableSchema => { + cubeQuery: Query, + getQueryOutput?: GetQueryOutput +): Promise => { if (tableSchema.length === 1) return tableSchema[0]; // joinPathsV2 callers control which tables participate; everyone else @@ -378,7 +499,13 @@ export const getCombinedTableSchemaV2 = ( activeTables.map((s) => [s.name, s.sql]) ); const graph = createDirectedGraphV2(activeTables, tableSchemaSqlMap, paths); - const sql = generateSqlQueryV2(paths, tableSchemaSqlMap, graph, activeTables); + const sql = await generateSqlQueryV2( + paths, + tableSchemaSqlMap, + graph, + activeTables, + getQueryOutput + ); return { name: 'MEERKAT_GENERATED_TABLE', diff --git a/meerkat-node/src/cube-to-sql/cube-to-sql.ts b/meerkat-node/src/cube-to-sql/cube-to-sql.ts index c7aa2494..afbfd26c 100644 --- a/meerkat-node/src/cube-to-sql/cube-to-sql.ts +++ b/meerkat-node/src/cube-to-sql/cube-to-sql.ts @@ -41,7 +41,7 @@ export const cubeQueryToSQL = async ({ }) ); - const updatedTableSchema = getCombinedTableSchema(updatedTableSchemas, query); + const updatedTableSchema = await getCombinedTableSchema(updatedTableSchemas, query, duckdbExec); const ast = cubeToDuckdbAST(query, updatedTableSchema, { filterType: 'PROJECTION_FILTER', From 8e6db4f9ec519261f52220f291f21c78fb77702e Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Wed, 8 Jul 2026 11:59:09 +0530 Subject: [PATCH 09/18] refactor: reuse MeerkatQueryFilter for join conditions instead of custom types --- meerkat-core/src/joins/v2/joins.spec.ts | 96 +++----------- meerkat-core/src/joins/v2/joins.ts | 147 +++------------------ meerkat-core/src/types/cube-types/query.ts | 32 +---- 3 files changed, 42 insertions(+), 233 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.spec.ts b/meerkat-core/src/joins/v2/joins.spec.ts index 187249e9..34f3ca22 100644 --- a/meerkat-core/src/joins/v2/joins.spec.ts +++ b/meerkat-core/src/joins/v2/joins.spec.ts @@ -1,4 +1,4 @@ -import { JoinFilterExpression, StructuredJoin, TableSchema } from '../../types/cube-types'; +import { MeerkatQueryFilter, StructuredJoin, TableSchema } from '../../types/cube-types'; import { GetQueryOutput } from '../../utils/duckdb-ast-parse-serialize'; import { createDirectedGraphV2, generateSqlQueryV2 } from './joins'; @@ -242,19 +242,10 @@ describe('joins-v2', () => { scalar('link', ['id', 'source_id', 'link_type_id']), ]; const sqlMap = sqlMapOf(schemas); - const condition: JoinFilterExpression = { - operator: 'and', - operands: [ - { - type: 'condition', - condition: { - key: 'link_type_id', - operator: 'equals', - json_value: '1234', - value_type: 'json_value', - }, - }, - ], + const condition: MeerkatQueryFilter = { + member: 'link.link_type_id', + operator: 'equals', + values: ['1234'], }; const paths: StructuredJoin[][] = [ [ @@ -277,19 +268,10 @@ describe('joins-v2', () => { scalar('link', ['id', 'source_id', 'link_type_id']), ]; const sqlMap = sqlMapOf(schemas); - const condition: JoinFilterExpression = { - operator: 'and', - operands: [ - { - type: 'condition', - condition: { - key: 'link_type_id', - operator: 'equals', - json_value: '1234', - value_type: 'json_value', - }, - }, - ], + const condition: MeerkatQueryFilter = { + member: 'link.link_type_id', + operator: 'equals', + values: ['1234'], }; const paths: StructuredJoin[][] = [ [ @@ -335,18 +317,9 @@ describe('joins-v2', () => { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' }, condition: { - operator: 'and', - operands: [ - { - type: 'condition', - condition: { - key: 'link_type_id', - operator: 'equals', - json_value: 'type_a', - value_type: 'json_value', - }, - }, - ], + member: 'link.link_type_id', + operator: 'equals', + values: ['type_a'], }, }, { @@ -381,7 +354,7 @@ describe('joins-v2', () => { expect(sql).not.toContain("AND (link_type_id = 'type_a') AND"); }); - it('handles IS NULL condition (null operator)', async () => { + it('handles notSet condition (IS NULL)', async () => { const schemas = [ scalar('issue', ['id']), scalar('link', ['id', 'source_id', 'deleted_at']), @@ -393,17 +366,8 @@ describe('joins-v2', () => { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' }, condition: { - operator: 'and', - operands: [ - { - type: 'condition', - condition: { - key: 'deleted_at', - operator: 'null', - value_type: 'json_value', - }, - }, - ], + member: 'link.deleted_at', + operator: 'notSet', }, }, ], @@ -435,34 +399,18 @@ describe('joins-v2', () => { scalar('link', ['id', 'source_id', 'link_type_id']), ]; const sqlMap = sqlMapOf(schemas); + const condition: MeerkatQueryFilter = { + or: [ + { member: 'link.link_type_id', operator: 'equals', values: ['type_a'] }, + { member: 'link.link_type_id', operator: 'equals', values: ['type_b'] }, + ], + }; const paths: StructuredJoin[][] = [ [ { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' }, - condition: { - operator: 'or', - operands: [ - { - type: 'condition', - condition: { - key: 'link_type_id', - operator: 'equals', - json_value: 'type_a', - value_type: 'json_value', - }, - }, - { - type: 'condition', - condition: { - key: 'link_type_id', - operator: 'equals', - json_value: 'type_b', - value_type: 'json_value', - }, - }, - ], - }, + condition, }, ], ]; diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index f39113f7..0891bb2a 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -1,27 +1,16 @@ import { getUsedTableSchema } from '../../get-used-table-schema/get-used-table-schema'; -import { andDuckdbCondition } from '../../cube-filter-transformer/and/and'; -import { orDuckdbCondition } from '../../cube-filter-transformer/or/or'; -import { - baseDuckdbCondition, - createColumnRef, -} from '../../cube-filter-transformer/base-condition-builder/base-condition-builder'; +import { cubeFilterToDuckdbAST } from '../../cube-filter-transformer/factory'; import { memberKeyToSafeKey } from '../../member-formatters/member-key-to-safe-key'; import { - JoinFilterCondition, - JoinFilterExpression, - JoinFilterOperand, + MeerkatQueryFilter, Query, StructuredJoin, TableSchema, } from '../../types/cube-types'; -import { Dimension, Measure } from '../../types/cube-types/table'; -import { - ExpressionClass, - ExpressionType, -} from '../../types/duckdb-serialization-types/serialization/Expression'; import { ParsedExpression } from '../../types/duckdb-serialization-types/serialization/ParsedExpression'; import { serializeExpressions, GetQueryOutput } from '../../utils/duckdb-ast-parse-serialize'; -import { findInSchema } from '../../utils/find-in-table-schema'; +import { cubeFiltersEnrichment } from '../../utils/cube-filter-enrichment'; +import { getBaseAST } from '../../utils/base-ast'; import { Graph, quoteIdentifierIfNeeded } from '../v1/joins'; const UNNEST_ALIAS_PREFIX = '__mk_u_'; @@ -151,118 +140,15 @@ const wrapTableSqlForArrayFrom = ( )}`; }; -const getMemberInfo = ( - key: string, - tableSchema: TableSchema | undefined -): Measure | Dimension => { - if (tableSchema) { - const memberInfo = findInSchema(key, tableSchema); - if (memberInfo) return memberInfo; - } - return { name: key, sql: key, type: 'string' }; -}; - -const conditionToAST = ( - cond: JoinFilterCondition, - tableSchema: TableSchema | undefined -): ParsedExpression => { - const { key, operator, json_value } = cond; - const columnRef = createColumnRef(key, { isAlias: true }); - const memberInfo = getMemberInfo(key, tableSchema); - - switch (operator) { - case 'equals': - if (json_value === null || json_value === undefined) { - return { - class: ExpressionClass.OPERATOR, - type: ExpressionType.OPERATOR_IS_NULL, - alias: '', - children: [columnRef], - }; - } - return baseDuckdbCondition( - key, - ExpressionType.COMPARE_EQUAL, - String(json_value), - memberInfo, - { isAlias: true } - ); - case 'not_equals': - if (json_value === null || json_value === undefined) { - return { - class: ExpressionClass.OPERATOR, - type: ExpressionType.OPERATOR_IS_NOT_NULL, - alias: '', - children: [columnRef], - }; - } - return baseDuckdbCondition( - key, - ExpressionType.COMPARE_NOTEQUAL, - String(json_value), - memberInfo, - { isAlias: true } - ); - case 'null': - return { - class: ExpressionClass.OPERATOR, - type: ExpressionType.OPERATOR_IS_NULL, - alias: '', - children: [columnRef], - }; - case 'not_null': - return { - class: ExpressionClass.OPERATOR, - type: ExpressionType.OPERATOR_IS_NOT_NULL, - alias: '', - children: [columnRef], - }; - case 'empty': - return baseDuckdbCondition( - key, - ExpressionType.COMPARE_EQUAL, - '', - memberInfo, - { isAlias: true } - ); - case 'not_empty': - return baseDuckdbCondition( - key, - ExpressionType.COMPARE_NOTEQUAL, - '', - memberInfo, - { isAlias: true } - ); - default: - throw new Error(`Unsupported join condition operator: ${operator}`); - } -}; - -const operandToAST = ( - operand: JoinFilterOperand, - tableSchema: TableSchema | undefined -): ParsedExpression => { - if (operand.type === 'condition' && operand.condition) { - return conditionToAST(operand.condition, tableSchema); - } - if (operand.type === 'expression' && operand.expression) { - return expressionToAST(operand.expression, tableSchema); - } - throw new Error( - 'Invalid join filter operand: missing condition or expression' - ); -}; - -const expressionToAST = ( - expr: JoinFilterExpression, +const conditionFilterToAST = ( + condition: MeerkatQueryFilter, tableSchema: TableSchema | undefined -): ParsedExpression => { - const parts = expr.operands.map((op) => operandToAST(op, tableSchema)); - if (parts.length === 1) return parts[0]; - const conjunction = - expr.operator === 'and' ? andDuckdbCondition() : orDuckdbCondition(); - conjunction.children = parts; - return conjunction; +): ParsedExpression | null => { + if (!tableSchema) return null; + const filters = [JSON.parse(JSON.stringify(condition))]; + const enriched = cubeFiltersEnrichment(filters, tableSchema); + if (!enriched) return null; + return cubeFilterToDuckdbAST(enriched, getBaseAST(), { isAlias: false }) ?? null; }; const buildEquiJoinPredicate = ( @@ -416,8 +302,13 @@ export const generateSqlQueryV2 = async ( const toTableSchema = tableSchemas.find( (s) => s.name === edge.to.table || s.name === edge.to.table.replace(/__\d+$/, '') ); - conditionIndexByEdge.push(conditionASTs.length); - conditionASTs.push(expressionToAST(edge.condition, toTableSchema)); + const ast = conditionFilterToAST(edge.condition, toTableSchema); + if (ast) { + conditionIndexByEdge.push(conditionASTs.length); + conditionASTs.push(ast); + } else { + conditionIndexByEdge.push(-1); + } } else { conditionIndexByEdge.push(-1); } diff --git a/meerkat-core/src/types/cube-types/query.ts b/meerkat-core/src/types/cube-types/query.ts index 921f7cd3..9515259b 100644 --- a/meerkat-core/src/types/cube-types/query.ts +++ b/meerkat-core/src/types/cube-types/query.ts @@ -137,41 +137,11 @@ interface JoinNode { */ } -/** Operator for filter conditions on join edges. */ -export type JoinConditionOperator = - | 'equals' - | 'not_equals' - | 'empty' - | 'not_empty' - | 'not_null' - | 'null'; - -/** A filter condition on a join edge (e.g. link_type_id = '1234'). */ -export interface JoinFilterCondition { - key: string; - operator: JoinConditionOperator; - json_value?: unknown; - value_type: 'json_value' | 'part_value' | 'relative_date_value' | 'template_value'; -} - -/** An operand in a join filter expression — either a leaf condition or a nested expression. */ -export interface JoinFilterOperand { - type: 'condition' | 'expression'; - condition?: JoinFilterCondition; - expression?: JoinFilterExpression; -} - -/** A filter expression (AND/OR) applied to the intermediate table in a link-type join. */ -export interface JoinFilterExpression { - operator: 'and' | 'or'; - operands: JoinFilterOperand[]; -} - /** Structured join edge used by `joinPathsV2`. See `joins/v2/`. */ export type StructuredJoin = { from: { table: string; column: string }; to: { table: string; column: string }; - condition?: JoinFilterExpression; + condition?: MeerkatQueryFilter; }; /** From 8ba6473273de3102311cc7de77b6f8a9e0ed6091 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Wed, 8 Jul 2026 12:10:11 +0530 Subject: [PATCH 10/18] refactor: simplify generateSqlQueryV2 --- meerkat-core/src/joins/v2/joins.ts | 69 ++++++++++-------------------- 1 file changed, 22 insertions(+), 47 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index 0891bb2a..99770328 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -273,11 +273,6 @@ export const generateSqlQueryV2 = async ( const visited = new Map(); - // Collect edges in visitation order and their condition ASTs - const edgeOrder: { edge: StructuredJoin; equiJoin: string }[] = []; - const conditionASTs: ParsedExpression[] = []; - const conditionIndexByEdge: number[] = []; - for (const path of resolvedPaths) { for (const edge of path) { const prev = visited.get(edge.to.table); @@ -289,64 +284,44 @@ export const generateSqlQueryV2 = async ( } visited.set(edge.to.table, edge); - const equiJoin = + let onClause = directedGraph[edge.from.table]?.[edge.to.table]?.[edge.from.column] ?? buildEquiJoinPredicate( edge, isArrayColumn(tableSchemas, edge.from.table, edge.from.column) ); - edgeOrder.push({ edge, equiJoin }); - if (edge.condition) { + if (!getQueryOutput) { + throw new Error( + 'getQueryOutput is required when join edges have filter conditions' + ); + } const toTableSchema = tableSchemas.find( (s) => s.name === edge.to.table || s.name === edge.to.table.replace(/__\d+$/, '') ); const ast = conditionFilterToAST(edge.condition, toTableSchema); if (ast) { - conditionIndexByEdge.push(conditionASTs.length); - conditionASTs.push(ast); - } else { - conditionIndexByEdge.push(-1); + const [conditionSql] = await serializeExpressions([ast], getQueryOutput); + onClause = `${onClause} AND ${conditionSql}`; } - } else { - conditionIndexByEdge.push(-1); } - } - } - // Batch-serialize all condition ASTs to SQL in one DuckDB call - let conditionSqls: string[] = []; - if (conditionASTs.length > 0) { - if (!getQueryOutput) { - throw new Error( - 'getQueryOutput is required when join edges have filter conditions' - ); + const rightArrayCols = arraySourcesByTable.get(edge.to.table); + const rightSubquery = rightArrayCols?.size + ? wrapTableSqlForArrayFrom( + tableSchemaSqlMap[edge.to.table] ?? + tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')], + edge.to.table, + rightArrayCols, + tableSchemas + ) + : `(${ + tableSchemaSqlMap[edge.to.table] ?? + tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')] + }) AS ${quoteIdentifierIfNeeded(edge.to.table)}`; + query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; } - conditionSqls = await serializeExpressions(conditionASTs, getQueryOutput); - } - - // Build the final JOIN SQL - for (let i = 0; i < edgeOrder.length; i++) { - const { edge, equiJoin } = edgeOrder[i]; - const condIdx = conditionIndexByEdge[i]; - const onClause = - condIdx >= 0 ? `${equiJoin} AND ${conditionSqls[condIdx]}` : equiJoin; - - const rightArrayCols = arraySourcesByTable.get(edge.to.table); - const rightSubquery = rightArrayCols?.size - ? wrapTableSqlForArrayFrom( - tableSchemaSqlMap[edge.to.table] ?? - tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')], - edge.to.table, - rightArrayCols, - tableSchemas - ) - : `(${ - tableSchemaSqlMap[edge.to.table] ?? - tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')] - }) AS ${quoteIdentifierIfNeeded(edge.to.table)}`; - query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; } return query; From 7a0f4cc757d75510a66982a440c585d9277b27f4 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Wed, 8 Jul 2026 12:22:39 +0530 Subject: [PATCH 11/18] refactor: cleanup the code --- meerkat-core/src/joins/v2/joins.ts | 58 +++++++++++++++++------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index 99770328..bf503c82 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -1,5 +1,5 @@ -import { getUsedTableSchema } from '../../get-used-table-schema/get-used-table-schema'; import { cubeFilterToDuckdbAST } from '../../cube-filter-transformer/factory'; +import { getUsedTableSchema } from '../../get-used-table-schema/get-used-table-schema'; import { memberKeyToSafeKey } from '../../member-formatters/member-key-to-safe-key'; import { MeerkatQueryFilter, @@ -8,9 +8,12 @@ import { TableSchema, } from '../../types/cube-types'; import { ParsedExpression } from '../../types/duckdb-serialization-types/serialization/ParsedExpression'; -import { serializeExpressions, GetQueryOutput } from '../../utils/duckdb-ast-parse-serialize'; -import { cubeFiltersEnrichment } from '../../utils/cube-filter-enrichment'; import { getBaseAST } from '../../utils/base-ast'; +import { cubeFiltersEnrichment } from '../../utils/cube-filter-enrichment'; +import { + GetQueryOutput, + serializeExpressions, +} from '../../utils/duckdb-ast-parse-serialize'; import { Graph, quoteIdentifierIfNeeded } from '../v1/joins'; const UNNEST_ALIAS_PREFIX = '__mk_u_'; @@ -140,17 +143,6 @@ const wrapTableSqlForArrayFrom = ( )}`; }; -const conditionFilterToAST = ( - condition: MeerkatQueryFilter, - tableSchema: TableSchema | undefined -): ParsedExpression | null => { - if (!tableSchema) return null; - const filters = [JSON.parse(JSON.stringify(condition))]; - const enriched = cubeFiltersEnrichment(filters, tableSchema); - if (!enriched) return null; - return cubeFilterToDuckdbAST(enriched, getBaseAST(), { isAlias: false }) ?? null; -}; - const buildEquiJoinPredicate = ( edge: StructuredJoin, fromIsArray: boolean @@ -160,6 +152,18 @@ const buildEquiJoinPredicate = ( : edge.from.column; return `${edge.from.table}.${leftColumn} = ${edge.to.table}.${edge.to.column}`; }; +const conditionFilterToAST = ( + condition: MeerkatQueryFilter, + tableSchema: TableSchema | undefined +): ParsedExpression | null => { + if (!tableSchema) return null; + const filters = [JSON.parse(JSON.stringify(condition))]; + const enriched = cubeFiltersEnrichment(filters, tableSchema); + if (!enriched) return null; + return ( + cubeFilterToDuckdbAST(enriched, getBaseAST(), { isAlias: false }) ?? null + ); +}; const BRIDGE_TABLES = new Set(['link']); @@ -284,8 +288,13 @@ export const generateSqlQueryV2 = async ( } visited.set(edge.to.table, edge); + const toTable = edge.to.table; + const toTableOriginal = toTable.replace(/__\d+$/, ''); + const toTableSql = + tableSchemaSqlMap[toTable] ?? tableSchemaSqlMap[toTableOriginal]; + let onClause = - directedGraph[edge.from.table]?.[edge.to.table]?.[edge.from.column] ?? + directedGraph[edge.from.table]?.[toTable]?.[edge.from.column] ?? buildEquiJoinPredicate( edge, isArrayColumn(tableSchemas, edge.from.table, edge.from.column) @@ -298,28 +307,27 @@ export const generateSqlQueryV2 = async ( ); } const toTableSchema = tableSchemas.find( - (s) => s.name === edge.to.table || s.name === edge.to.table.replace(/__\d+$/, '') + (s) => s.name === toTable || s.name === toTableOriginal ); const ast = conditionFilterToAST(edge.condition, toTableSchema); if (ast) { - const [conditionSql] = await serializeExpressions([ast], getQueryOutput); + const [conditionSql] = await serializeExpressions( + [ast], + getQueryOutput + ); onClause = `${onClause} AND ${conditionSql}`; } } - const rightArrayCols = arraySourcesByTable.get(edge.to.table); + const rightArrayCols = arraySourcesByTable.get(toTable); const rightSubquery = rightArrayCols?.size ? wrapTableSqlForArrayFrom( - tableSchemaSqlMap[edge.to.table] ?? - tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')], - edge.to.table, + toTableSql, + toTable, rightArrayCols, tableSchemas ) - : `(${ - tableSchemaSqlMap[edge.to.table] ?? - tableSchemaSqlMap[edge.to.table.replace(/__\d+$/, '')] - }) AS ${quoteIdentifierIfNeeded(edge.to.table)}`; + : `(${toTableSql}) AS ${quoteIdentifierIfNeeded(toTable)}`; query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; } } From 840aecc326222e4eae0a69b830676b926b715376 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Wed, 8 Jul 2026 16:54:50 +0530 Subject: [PATCH 12/18] refactor: added isBridge to StructuredJoin --- meerkat-core/src/joins/v2/joins.spec.ts | 72 ++++++++++++++++++++++ meerkat-core/src/joins/v2/joins.ts | 15 +++-- meerkat-core/src/types/cube-types/query.ts | 1 + 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.spec.ts b/meerkat-core/src/joins/v2/joins.spec.ts index 34f3ca22..81bad6ae 100644 --- a/meerkat-core/src/joins/v2/joins.spec.ts +++ b/meerkat-core/src/joins/v2/joins.spec.ts @@ -236,6 +236,78 @@ describe('joins-v2', () => { ); }); + it('aliases bridge tables when isBridge is set and same table appears multiple times in a path', async () => { + const schemas = [ + scalar('issue', ['id']), + scalar('link', ['id', 'source_id', 'target_id', 'link_type_id']), + scalar('part', ['id']), + scalar('user', ['id']), + ]; + const sqlMap = sqlMapOf(schemas); + const paths: StructuredJoin[][] = [ + [ + { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' }, isBridge: true }, + { from: { table: 'link', column: 'target_id' }, to: { table: 'part', column: 'id' } }, + { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' }, isBridge: true }, + { from: { table: 'link', column: 'target_id' }, to: { table: 'user', column: 'id' } }, + ], + ]; + const graph = createDirectedGraphV2(schemas, sqlMap, paths); + const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas); + + expect(sql).toContain('issue.id = link.source_id'); + expect(sql).toContain('link.target_id = part.id'); + expect(sql).toContain('issue.id = link__1.source_id'); + expect(sql).toContain('link__1.target_id = user.id'); + }); + + it('aliases bridge tables across separate paths', async () => { + const schemas = [ + scalar('issue', ['id']), + scalar('link', ['id', 'source_id', 'target_id', 'link_type_id']), + scalar('part', ['id']), + scalar('user', ['id']), + ]; + const sqlMap = sqlMapOf(schemas); + const paths: StructuredJoin[][] = [ + [ + { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' }, isBridge: true }, + { from: { table: 'link', column: 'target_id' }, to: { table: 'part', column: 'id' } }, + ], + [ + { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' }, isBridge: true }, + { from: { table: 'link', column: 'target_id' }, to: { table: 'user', column: 'id' } }, + ], + ]; + const graph = createDirectedGraphV2(schemas, sqlMap, paths); + const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas); + + expect(sql).toContain('issue.id = link.source_id'); + expect(sql).toContain('link.target_id = part.id'); + expect(sql).toContain('issue.id = link__1.source_id'); + expect(sql).toContain('link__1.target_id = user.id'); + }); + + it('does not alias when isBridge is not set even if table repeats', async () => { + const schemas = [ + scalar('issue', ['id']), + scalar('link', ['id', 'source_id', 'target_id']), + scalar('part', ['id']), + ]; + const sqlMap = sqlMapOf(schemas); + const paths: StructuredJoin[][] = [ + [ + { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' } }, + { from: { table: 'link', column: 'target_id' }, to: { table: 'part', column: 'id' } }, + { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' } }, + ], + ]; + const graph = createDirectedGraphV2(schemas, sqlMap, paths); + const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas); + + expect(sql).not.toContain('link__1'); + }); + it('throws when condition is present but getQueryOutput is not provided', async () => { const schemas = [ scalar('issue', ['id']), diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index bf503c82..6f436b47 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -165,20 +165,18 @@ const conditionFilterToAST = ( ); }; -const BRIDGE_TABLES = new Set(['link']); - -const aliasBridgeTables = (paths: StructuredJoin[][]): StructuredJoin[][] => - paths.map((path) => { - const seen = new Map(); - if (path[0]) seen.set(path[0].from.table, 1); +const aliasBridgeTables = (paths: StructuredJoin[][]): StructuredJoin[][] => { + const seen = new Map(); + if (paths[0]?.[0]) seen.set(paths[0][0].from.table, 1); + return paths.map((path) => { const result: StructuredJoin[] = []; for (let i = 0; i < path.length; i++) { const edge = path[i]; const count = seen.get(edge.to.table) ?? 0; seen.set(edge.to.table, count + 1); - if (count === 0 || !BRIDGE_TABLES.has(edge.to.table)) { + if (count === 0 || !edge.isBridge) { result.push(edge); continue; } @@ -195,6 +193,7 @@ const aliasBridgeTables = (paths: StructuredJoin[][]): StructuredJoin[][] => } return result; }); +}; export const createDirectedGraphV2 = ( tableSchemas: TableSchema[], @@ -340,7 +339,7 @@ const hasLoop = (paths: StructuredJoin[][]): boolean => { const visited = new Set(); if (path[0]) visited.add(path[0].from.table); for (const edge of path) { - if (BRIDGE_TABLES.has(edge.to.table)) continue; + if (edge.isBridge) continue; if (visited.has(edge.to.table)) return true; visited.add(edge.to.table); } diff --git a/meerkat-core/src/types/cube-types/query.ts b/meerkat-core/src/types/cube-types/query.ts index 9515259b..52d78897 100644 --- a/meerkat-core/src/types/cube-types/query.ts +++ b/meerkat-core/src/types/cube-types/query.ts @@ -142,6 +142,7 @@ export type StructuredJoin = { from: { table: string; column: string }; to: { table: string; column: string }; condition?: MeerkatQueryFilter; + isBridge?: boolean; }; /** From f0ddfa5ad06dbbf0256c4765da34f7c73d566866 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Thu, 9 Jul 2026 12:15:16 +0530 Subject: [PATCH 13/18] bring back links to normal case while checking for Path ambiguity --- meerkat-core/src/joins/v2/joins.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index 6f436b47..a0869049 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -222,7 +222,7 @@ export const createDirectedGraphV2 = ( ); } if (graph[from.table]?.[to.table]?.[from.column]) { - continue; + throw new Error('An invalid path was detected.'); } graph[from.table] ??= {}; graph[from.table][to.table] ??= {}; @@ -279,8 +279,10 @@ export const generateSqlQueryV2 = async ( for (const path of resolvedPaths) { for (const edge of path) { const prev = visited.get(edge.to.table); - if (prev?.from.table === edge.from.table) continue; if (prev) { + const prevFrom = prev.from.table.replace(/__\d+$/, ''); + const currFrom = edge.from.table.replace(/__\d+$/, ''); + if (prevFrom === currFrom) continue; throw new Error( `Path ambiguity, node ${edge.to.table} visited from different sources` ); From cf9a178fdd93e10c2fd34bd65b06e5273826cf7c Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Thu, 9 Jul 2026 12:30:15 +0530 Subject: [PATCH 14/18] remove throw error for preivously visisted 'graph[from.table]?.[to.table]?.[from.column]' --- meerkat-core/src/joins/v2/joins.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index a0869049..fb5498c9 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -222,7 +222,7 @@ export const createDirectedGraphV2 = ( ); } if (graph[from.table]?.[to.table]?.[from.column]) { - throw new Error('An invalid path was detected.'); + continue; } graph[from.table] ??= {}; graph[from.table][to.table] ??= {}; From b9cc8eadf0e200436028dfccb787dba581e2feed Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Fri, 10 Jul 2026 17:02:11 +0530 Subject: [PATCH 15/18] get bridge tables through dimension, mesuares, filters only --- meerkat-core/src/joins/v2/joins.spec.ts | 42 ++++-- meerkat-core/src/joins/v2/joins.ts | 150 +++++++++++++++------ meerkat-core/src/types/cube-types/query.ts | 1 - 3 files changed, 141 insertions(+), 52 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.spec.ts b/meerkat-core/src/joins/v2/joins.spec.ts index 81bad6ae..04be0d9d 100644 --- a/meerkat-core/src/joins/v2/joins.spec.ts +++ b/meerkat-core/src/joins/v2/joins.spec.ts @@ -236,7 +236,7 @@ describe('joins-v2', () => { ); }); - it('aliases bridge tables when isBridge is set and same table appears multiple times in a path', async () => { + it('aliases bridge tables when same table appears multiple times in a path', async () => { const schemas = [ scalar('issue', ['id']), scalar('link', ['id', 'source_id', 'target_id', 'link_type_id']), @@ -244,16 +244,17 @@ describe('joins-v2', () => { scalar('user', ['id']), ]; const sqlMap = sqlMapOf(schemas); + const bridgeTables = new Set(['link']); const paths: StructuredJoin[][] = [ [ - { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' }, isBridge: true }, + { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' } }, { from: { table: 'link', column: 'target_id' }, to: { table: 'part', column: 'id' } }, - { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' }, isBridge: true }, + { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' } }, { from: { table: 'link', column: 'target_id' }, to: { table: 'user', column: 'id' } }, ], ]; const graph = createDirectedGraphV2(schemas, sqlMap, paths); - const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas); + const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas, undefined, bridgeTables); expect(sql).toContain('issue.id = link.source_id'); expect(sql).toContain('link.target_id = part.id'); @@ -269,18 +270,19 @@ describe('joins-v2', () => { scalar('user', ['id']), ]; const sqlMap = sqlMapOf(schemas); + const bridgeTables = new Set(['link']); const paths: StructuredJoin[][] = [ [ - { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' }, isBridge: true }, + { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' } }, { from: { table: 'link', column: 'target_id' }, to: { table: 'part', column: 'id' } }, ], [ - { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' }, isBridge: true }, + { from: { table: 'issue', column: 'id' }, to: { table: 'link', column: 'source_id' } }, { from: { table: 'link', column: 'target_id' }, to: { table: 'user', column: 'id' } }, ], ]; const graph = createDirectedGraphV2(schemas, sqlMap, paths); - const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas); + const sql = await generateSqlQueryV2(paths, sqlMap, graph, schemas, undefined, bridgeTables); expect(sql).toContain('issue.id = link.source_id'); expect(sql).toContain('link.target_id = part.id'); @@ -288,7 +290,7 @@ describe('joins-v2', () => { expect(sql).toContain('link__1.target_id = user.id'); }); - it('does not alias when isBridge is not set even if table repeats', async () => { + it('does not alias when table is not a bridge even if it repeats', async () => { const schemas = [ scalar('issue', ['id']), scalar('link', ['id', 'source_id', 'target_id']), @@ -381,8 +383,10 @@ describe('joins-v2', () => { scalar('issue', ['id']), scalar('link', ['id', 'source_id', 'target_id', 'link_type_id']), scalar('part', ['id']), + scalar('user', ['id']), ]; const sqlMap = sqlMapOf(schemas); + const bridgeTables = new Set(['link']); const paths: StructuredJoin[][] = [ [ { @@ -398,6 +402,19 @@ describe('joins-v2', () => { from: { table: 'link', column: 'target_id' }, to: { table: 'part', column: 'id' }, }, + { + from: { table: 'part', column: 'id' }, + to: { table: 'link', column: 'source_id' }, + condition: { + member: 'link.link_type_id', + operator: 'equals', + values: ['type_b'], + }, + }, + { + from: { table: 'link', column: 'target_id' }, + to: { table: 'user', column: 'id' }, + }, ], ]; const graph = createDirectedGraphV2(schemas, sqlMap, paths); @@ -407,7 +424,8 @@ describe('joins-v2', () => { callCount++; return [ { - result: "SELECT (link_type_id = 'type_a') AS __meerkat_batch_expr_0__;", + result: + "SELECT (link_type_id = 'type_a') AS __meerkat_batch_expr_0__, (link_type_id = 'type_b') AS __meerkat_batch_expr_1__;", }, ]; }; @@ -417,13 +435,15 @@ describe('joins-v2', () => { sqlMap, graph, schemas, - mockGetQueryOutput + mockGetQueryOutput, + bridgeTables ); expect(callCount).toBe(1); expect(sql).toContain("issue.id = link.source_id AND (link_type_id = 'type_a')"); expect(sql).toContain('link.target_id = part.id'); - expect(sql).not.toContain("AND (link_type_id = 'type_a') AND"); + expect(sql).toContain("part.id = link__1.source_id AND (link_type_id = 'type_b')"); + expect(sql).toContain('link__1.target_id = user.id'); }); it('handles notSet condition (IS NULL)', async () => { diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index fb5498c9..93b64a7c 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -1,4 +1,5 @@ import { cubeFilterToDuckdbAST } from '../../cube-filter-transformer/factory'; +import { traverseMeerkatQueryFilter } from '../../filter-params/filter-params-ast'; import { getUsedTableSchema } from '../../get-used-table-schema/get-used-table-schema'; import { memberKeyToSafeKey } from '../../member-formatters/member-key-to-safe-key'; import { @@ -165,18 +166,64 @@ const conditionFilterToAST = ( ); }; -const aliasBridgeTables = (paths: StructuredJoin[][]): StructuredJoin[][] => { +const getReferencedTables = (cubeQuery: Query): Set => { + const tables = new Set(); + const extractTable = (member: string) => { + const dotIndex = member.indexOf('.'); + if (dotIndex > 0) tables.add(member.slice(0, dotIndex)); + }; + + cubeQuery.measures.forEach(extractTable); + cubeQuery.dimensions?.forEach(extractTable); + if (cubeQuery.filters) { + traverseMeerkatQueryFilter(cubeQuery.filters, (filter) => { + extractTable(filter.member); + }); + } + if (cubeQuery.order) { + Object.keys(cubeQuery.order).forEach(extractTable); + } + return tables; +}; + +const inferBridgeTables = ( + paths: StructuredJoin[][], + cubeQuery: Query +): Set => { + const referenced = getReferencedTables(cubeQuery); + const bridges = new Set(); + for (const path of paths) { + for (const edge of path) { + if (!referenced.has(edge.to.table)) { + bridges.add(edge.to.table); + } + } + } + return bridges; +}; + +const aliasBridgeTables = ( + paths: StructuredJoin[][], + bridgeTables: Set +): StructuredJoin[][] => { const seen = new Map(); if (paths[0]?.[0]) seen.set(paths[0][0].from.table, 1); return paths.map((path) => { const result: StructuredJoin[] = []; + let nextFromAlias: string | undefined; + for (let i = 0; i < path.length; i++) { - const edge = path[i]; + let edge = path[i]; + if (nextFromAlias) { + edge = { ...edge, from: { ...edge.from, table: nextFromAlias } }; + nextFromAlias = undefined; + } + const count = seen.get(edge.to.table) ?? 0; seen.set(edge.to.table, count + 1); - if (count === 0 || !edge.isBridge) { + if (count === 0 || !bridgeTables.has(edge.to.table)) { result.push(edge); continue; } @@ -185,10 +232,7 @@ const aliasBridgeTables = (paths: StructuredJoin[][]): StructuredJoin[][] => { result.push({ ...edge, to: { ...edge.to, table: alias } }); if (i + 1 < path.length && path[i + 1].from.table === edge.to.table) { - path[i + 1] = { - ...path[i + 1], - from: { ...path[i + 1].from, table: alias }, - }; + nextFromAlias = alias; } } return result; @@ -247,7 +291,8 @@ export const generateSqlQueryV2 = async ( tableSchemaSqlMap: { [key: string]: string }, directedGraph: Graph, tableSchemas: TableSchema[], - getQueryOutput?: GetQueryOutput + getQueryOutput?: GetQueryOutput, + bridgeTables?: Set ): Promise => { if (paths.length === 0) { throw new Error( @@ -272,9 +317,12 @@ export const generateSqlQueryV2 = async ( tableSchemas ); - const resolvedPaths = aliasBridgeTables(paths); + const resolvedPaths = aliasBridgeTables(paths, bridgeTables ?? new Set()); const visited = new Map(); + const edgeOrder: { edge: StructuredJoin; equiJoin: string }[] = []; + const conditionASTs: ParsedExpression[] = []; + const conditionIndexByEdge: number[] = []; for (const path of resolvedPaths) { for (const edge of path) { @@ -289,59 +337,79 @@ export const generateSqlQueryV2 = async ( } visited.set(edge.to.table, edge); - const toTable = edge.to.table; - const toTableOriginal = toTable.replace(/__\d+$/, ''); - const toTableSql = - tableSchemaSqlMap[toTable] ?? tableSchemaSqlMap[toTableOriginal]; - - let onClause = - directedGraph[edge.from.table]?.[toTable]?.[edge.from.column] ?? + const equiJoin = + directedGraph[edge.from.table]?.[edge.to.table]?.[edge.from.column] ?? buildEquiJoinPredicate( edge, isArrayColumn(tableSchemas, edge.from.table, edge.from.column) ); + edgeOrder.push({ edge, equiJoin }); + if (edge.condition) { - if (!getQueryOutput) { - throw new Error( - 'getQueryOutput is required when join edges have filter conditions' - ); - } + const toTable = edge.to.table; + const toTableOriginal = toTable.replace(/__\d+$/, ''); const toTableSchema = tableSchemas.find( (s) => s.name === toTable || s.name === toTableOriginal ); const ast = conditionFilterToAST(edge.condition, toTableSchema); if (ast) { - const [conditionSql] = await serializeExpressions( - [ast], - getQueryOutput - ); - onClause = `${onClause} AND ${conditionSql}`; + conditionIndexByEdge.push(conditionASTs.length); + conditionASTs.push(ast); + } else { + conditionIndexByEdge.push(-1); } + } else { + conditionIndexByEdge.push(-1); } + } + } - const rightArrayCols = arraySourcesByTable.get(toTable); - const rightSubquery = rightArrayCols?.size - ? wrapTableSqlForArrayFrom( - toTableSql, - toTable, - rightArrayCols, - tableSchemas - ) - : `(${toTableSql}) AS ${quoteIdentifierIfNeeded(toTable)}`; - query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; + let conditionSqls: string[] = []; + if (conditionASTs.length > 0) { + if (!getQueryOutput) { + throw new Error( + 'getQueryOutput is required when join edges have filter conditions' + ); } + conditionSqls = await serializeExpressions(conditionASTs, getQueryOutput); + } + + for (let i = 0; i < edgeOrder.length; i++) { + const { edge, equiJoin } = edgeOrder[i]; + const condIdx = conditionIndexByEdge[i]; + const onClause = + condIdx >= 0 ? `${equiJoin} AND ${conditionSqls[condIdx]}` : equiJoin; + + const toTable = edge.to.table; + const toTableOriginal = toTable.replace(/__\d+$/, ''); + const toTableSql = + tableSchemaSqlMap[toTable] ?? tableSchemaSqlMap[toTableOriginal]; + + const rightArrayCols = arraySourcesByTable.get(toTable); + const rightSubquery = rightArrayCols?.size + ? wrapTableSqlForArrayFrom( + toTableSql, + toTable, + rightArrayCols, + tableSchemas + ) + : `(${toTableSql}) AS ${quoteIdentifierIfNeeded(toTable)}`; + query += ` LEFT JOIN ${rightSubquery} ON ${onClause}`; } return query; }; -const hasLoop = (paths: StructuredJoin[][]): boolean => { +const hasLoop = ( + paths: StructuredJoin[][], + bridgeTables: Set +): boolean => { for (const path of paths) { const visited = new Set(); if (path[0]) visited.add(path[0].from.table); for (const edge of path) { - if (edge.isBridge) continue; + if (bridgeTables.has(edge.to.table)) continue; if (visited.has(edge.to.table)) return true; visited.add(edge.to.table); } @@ -364,7 +432,8 @@ export const getCombinedTableSchemaV2 = async ( if (activeTables.length === 1) return activeTables[0]; const paths = cubeQuery.joinPathsV2 ?? []; - if (hasLoop(paths)) { + const bridgeTables = inferBridgeTables(paths, cubeQuery); + if (hasLoop(paths, bridgeTables)) { throw new Error( `A loop was detected in the joins. ${JSON.stringify(paths)}` ); @@ -379,7 +448,8 @@ export const getCombinedTableSchemaV2 = async ( tableSchemaSqlMap, graph, activeTables, - getQueryOutput + getQueryOutput, + bridgeTables ); return { diff --git a/meerkat-core/src/types/cube-types/query.ts b/meerkat-core/src/types/cube-types/query.ts index 52d78897..9515259b 100644 --- a/meerkat-core/src/types/cube-types/query.ts +++ b/meerkat-core/src/types/cube-types/query.ts @@ -142,7 +142,6 @@ export type StructuredJoin = { from: { table: string; column: string }; to: { table: string; column: string }; condition?: MeerkatQueryFilter; - isBridge?: boolean; }; /** From 3d9e118e4789a03ff69ce1ec81021fc7fb5e1a75 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Fri, 10 Jul 2026 17:49:36 +0530 Subject: [PATCH 16/18] revert chagnes in joinsv1 --- meerkat-core/src/joins/v1/joins.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meerkat-core/src/joins/v1/joins.ts b/meerkat-core/src/joins/v1/joins.ts index 7b7b782c..6d905189 100644 --- a/meerkat-core/src/joins/v1/joins.ts +++ b/meerkat-core/src/joins/v1/joins.ts @@ -259,7 +259,7 @@ export const createDirectedGraph = ( directedGraph[table1][table2] && directedGraph[table1][table2][joinOn]) ) { - return; + throw new Error('An invalid path was detected.'); } if (!directedGraph[table1]) directedGraph[table1] = {}; if (!directedGraph[table1][table2]) directedGraph[table1][table2] = {}; From 7456c763915f400869f3c953b015be5374c953b2 Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Fri, 10 Jul 2026 17:52:03 +0530 Subject: [PATCH 17/18] revert chagnes in joinsv1 --- meerkat-core/src/joins/v1/joins.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/meerkat-core/src/joins/v1/joins.ts b/meerkat-core/src/joins/v1/joins.ts index 6d905189..138cb186 100644 --- a/meerkat-core/src/joins/v1/joins.ts +++ b/meerkat-core/src/joins/v1/joins.ts @@ -1,10 +1,5 @@ import { getUsedTableSchema } from '../../get-used-table-schema/get-used-table-schema'; -import { - JoinPath, - Query, - TableSchema, - isJoinNode, -} from '../../types/cube-types'; +import { JoinPath, Query, TableSchema, isJoinNode } from '../../types/cube-types'; /** * Regex pattern to match a CONTAINS function call in join SQL. @@ -25,8 +20,7 @@ import { * - " contains( table1.items , table2.id ) " * - "CONTAINS(\"quoted.table\".col, other.col)" */ -const CONTAINS_FUNCTION_REGEX = - /^\s*CONTAINS\s*\(\s*(.+?)\s*,\s*(.+?)\s*\)\s*$/i; +const CONTAINS_FUNCTION_REGEX = /^\s*CONTAINS\s*\(\s*(.+?)\s*,\s*(.+?)\s*\)\s*$/i; /** * Regex pattern to validate table.column reference format. From 0bd4af060bf54f9c350c5abe7bf47bc16d4d716e Mon Sep 17 00:00:00 2001 From: Sree Vardhan Reddy Date: Fri, 10 Jul 2026 17:56:48 +0530 Subject: [PATCH 18/18] keep only dimensions, measures, filters in getReferencedTables --- meerkat-core/src/joins/v2/joins.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/meerkat-core/src/joins/v2/joins.ts b/meerkat-core/src/joins/v2/joins.ts index 93b64a7c..1699d2b9 100644 --- a/meerkat-core/src/joins/v2/joins.ts +++ b/meerkat-core/src/joins/v2/joins.ts @@ -180,9 +180,6 @@ const getReferencedTables = (cubeQuery: Query): Set => { extractTable(filter.member); }); } - if (cubeQuery.order) { - Object.keys(cubeQuery.order).forEach(extractTable); - } return tables; };