Skip to content

Commit aaa769b

Browse files
authored
Feature/compress mbql (#300)
* Get SQL for MBQl & underlying cards * Generalize approach to all cards
1 parent 2e5b3af commit aaa769b

File tree

1 file changed

+212
-1
lines changed

1 file changed

+212
-1
lines changed

apps/src/metabase/helpers/dashboard/appState.ts

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getTablesWithFields } from '../getDatabaseSchema';
55
import { getAllRelevantModelsForSelectedDb, getDatabaseInfo, getFieldResolvedName } from '../metabaseAPIHelpers';
66
import { getDashboardState, getSelectedDbId } from '../metabaseStateAPI';
77
import { getParsedIframeInfo, RPCs } from 'web';
8-
import { getSQLFromMBQL } from '../metabaseAPI';
8+
import { getSQLFromMBQL, fetchCard } from '../metabaseAPI';
99
import { metabaseToMarkdownTable } from '../operations';
1010
import { find, get } from 'lodash';
1111
import { getTablesFromSqlRegex, TableAndSchema } from '../parseSql';
@@ -19,6 +19,126 @@ import { getLimitedEntitiesFromQueries, getLimitedEntitiesFromMBQLQueries } from
1919

2020
// Removed: const { getMetabaseState } = RPCs - using centralized state functions instead
2121

22+
// Helper function to generate clean slugs from card names
23+
function generateCardSlug(cardName: string, cardId: number): string {
24+
if (!cardName) return `card-${cardId}`;
25+
26+
// Convert to lowercase, replace spaces and special characters with hyphens
27+
const slug = cardName
28+
.toLowerCase()
29+
.replace(/[^\w\s-]/g, '') // Remove special characters except hyphens and spaces
30+
.replace(/\s+/g, '-') // Replace spaces with hyphens
31+
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
32+
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
33+
34+
return `${cardId}-${slug}`;
35+
}
36+
37+
// Helper function to generate UUID-like strings for template tags
38+
function generateUUID(): string {
39+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
40+
const r = Math.random() * 16 | 0;
41+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
42+
return v.toString(16);
43+
});
44+
}
45+
46+
// Helper function to create template tag object
47+
function createTemplateTag(cardId: number, cardName: string, cardType?: string): any {
48+
const slug = generateCardSlug(cardName, cardId);
49+
const templateTagName = `#${slug}`;
50+
const entityType = cardType === 'model' ? 'Model' : 'Card';
51+
const displayName = `#${cardId} ${cardName || `${entityType} ${cardId}`}`;
52+
53+
return {
54+
type: "card", // Metabase uses 'card' type for both cards and models in template tags
55+
name: templateTagName,
56+
id: generateUUID(),
57+
"display-name": displayName,
58+
"card-id": cardId
59+
};
60+
}
61+
62+
// Helper function to process MBQL cards and convert them to native SQL with template tags
63+
async function processMBQLCard(
64+
card: SavedCard,
65+
dbId: number,
66+
cardDetailsMap: Map<number, any>
67+
): Promise<SavedCard> {
68+
// Only process MBQL cards
69+
if (card.dataset_query?.type !== 'query') {
70+
return card;
71+
}
72+
73+
try {
74+
// Get source table IDs from the MBQL query
75+
const sourceTableIds = getSourceTableIdsFromObject(card.dataset_query.query);
76+
77+
// Get SQL for the main query and child queries
78+
const [mainSQLRaw, childSQLsRaw] = await Promise.all([
79+
getSQLFromMBQL({
80+
database: dbId,
81+
type: 'query',
82+
query: card.dataset_query.query,
83+
}),
84+
Promise.all(sourceTableIds.map(id => getSQLFromMBQL({
85+
database: dbId,
86+
type: 'query',
87+
query: {
88+
'source-table': id
89+
},
90+
})))
91+
]);
92+
93+
// Process SQL strings
94+
const mainSQL = splitAndTrimSQL(mainSQLRaw.query);
95+
const childSQLs = childSQLsRaw.map(i => splitAndTrimSQL(i.query)).map(getOutermostParenthesesContent);
96+
97+
// Create template tags map
98+
const templateTags: Record<string, any> = {};
99+
100+
// Replace child SQL queries with template tag references
101+
const mainSQLWithCards = childSQLs.reduce((sql, childSql, index) => {
102+
let cardId = sourceTableIds[index];
103+
if (typeof cardId === 'string' && cardId.startsWith('card__')) {
104+
cardId = parseInt(cardId.slice(6));
105+
}
106+
107+
// Get the referenced card details from the fetched card info
108+
const referencedCard = cardDetailsMap.get(cardId);
109+
const cardName = referencedCard?.name || `Card ${cardId}`;
110+
const cardType = referencedCard?.type;
111+
112+
// Create template tag
113+
const templateTag = createTemplateTag(cardId, cardName, cardType);
114+
templateTags[templateTag.name] = templateTag;
115+
116+
// Replace SQL with template tag reference
117+
if (sql.includes(childSql)) {
118+
return sql.replace(childSql, `{{${templateTag.name}}}`);
119+
}
120+
return sql;
121+
}, mainSQL);
122+
123+
// Create the processed card with native SQL
124+
const processedCard: SavedCard = {
125+
...card,
126+
dataset_query: {
127+
...card.dataset_query,
128+
native: {
129+
query: mainSQLWithCards,
130+
'template-tags': templateTags
131+
}
132+
}
133+
};
134+
135+
return processedCard;
136+
} catch (error) {
137+
console.error(`Error processing MBQL card ${card.id}:`, error);
138+
return card;
139+
}
140+
}
141+
22142
function getSelectedTabDashcardIds(dashboardMetabaseState: DashboardMetabaseState) {
23143
const currentDashboardData = dashboardMetabaseState.dashboards?.[dashboardMetabaseState.dashboardId];
24144
if (!currentDashboardData) {
@@ -262,12 +382,103 @@ export async function getDashboardAppState(): Promise<MetabaseAppStateDashboard
262382
metabaseUrl: fullUrl,
263383
isEmbedded: getParsedIframeInfo().isEmbedded,
264384
};
385+
// Process all MBQL cards and convert them to native SQL with template tags
386+
if (dbId) {
387+
try {
388+
// Collect all card IDs from MBQL queries
389+
const allCardIds = new Set<number>();
390+
for (const card of filteredCards) {
391+
if (card.dataset_query?.type === 'query') {
392+
const sourceTableIds = getSourceTableIdsFromObject(card.dataset_query.query);
393+
sourceTableIds.forEach(id => {
394+
// Convert string card IDs (like "card__123") to numbers
395+
if (typeof id === 'string' && id.startsWith('card__')) {
396+
allCardIds.add(parseInt(id.slice(6)));
397+
} else if (typeof id === 'number' && id > 0) {
398+
// Assuming card IDs are positive numbers (tables are usually negative)
399+
allCardIds.add(id);
400+
}
401+
});
402+
}
403+
}
404+
405+
// Fetch card details for all referenced cards
406+
const cardDetailsMap = new Map<number, any>();
407+
if (allCardIds.size > 0) {
408+
const cardDetails = await Promise.all(
409+
Array.from(allCardIds).map(async cardId => {
410+
try {
411+
const cardDetail = await fetchCard({ card_id: cardId });
412+
return { id: cardId, detail: cardDetail };
413+
} catch (error) {
414+
console.warn(`Failed to fetch card ${cardId}:`, error);
415+
return { id: cardId, detail: null };
416+
}
417+
})
418+
);
419+
420+
cardDetails.forEach(({ id, detail }) => {
421+
if (detail) cardDetailsMap.set(id, detail);
422+
});
423+
}
424+
425+
filteredCards = await Promise.all(
426+
filteredCards.map(card => processMBQLCard(card, dbId, cardDetailsMap))
427+
);
428+
429+
} catch (error) {
430+
console.error('Error processing MBQL cards:', error);
431+
}
432+
}
265433
dashboardAppState.cards = filteredCards as SavedCard[];
266434
dashboardAppState.limitedEntities = uniqueEntities;
267435
dashboardAppState.parameterValues = dashboardMetabaseState.parameterValues || {};
268436
return dashboardAppState;
269437
}
270438

439+
function splitAndTrimSQL(sql: string): string {
440+
return sql.split('\n').map(part => part.trim()).join('\n')
441+
}
442+
443+
// This function extracts the contents of the outermost round brackets in a SQL string. i.e: The contents within the outermost ()
444+
function getOutermostParenthesesContent(sql: string): string {
445+
let depth = 0;
446+
let start = -1;
447+
448+
for (let i = 0; i < sql.length; i++) {
449+
if (sql[i] === '(') {
450+
if (depth === 0) start = i + 1; // mark after '('
451+
depth++;
452+
} else if (sql[i] === ')') {
453+
depth--;
454+
if (depth === 0 && start !== -1) {
455+
return sql.slice(start, i).trim(); // return without outer ()
456+
}
457+
}
458+
}
459+
return sql
460+
}
461+
462+
// function that goes through a nested object and returns all values for key "source-table"
463+
function getSourceTableIdsFromObject(obj: any): any[] {
464+
let ids: number[] = [];
465+
if (Array.isArray(obj)) {
466+
for (const item of obj) {
467+
ids = ids.concat(getSourceTableIdsFromObject(item));
468+
}
469+
} else if (typeof obj === 'object' && obj !== null) {
470+
if (obj.hasOwnProperty('source-table')) {
471+
ids.push(obj['source-table']);
472+
}
473+
for (const key in obj) {
474+
if (obj.hasOwnProperty(key)) {
475+
ids = ids.concat(getSourceTableIdsFromObject(obj[key]));
476+
}
477+
}
478+
}
479+
return ids;
480+
}
481+
271482

272483
// export async function getDashboardInfoForModelling(): Promise<DashboardInfoForModelling | undefined> {
273484
// const dashboardMetabaseState: DashboardMetabaseState = await getDashboardState() as DashboardMetabaseState;

0 commit comments

Comments
 (0)