diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 145fedc..012f3da 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -65,6 +65,7 @@ const readSidebarSource = () => [ readSourceFile('./sidebar/SidebarConnectionRail.tsx'), readSourceFile('./sidebar/SidebarSearchPanel.tsx'), readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'), + readSourceFile('./sidebar/sidebarMetadataLoaders.ts'), readSourceFile('./sidebarV2Utils.ts'), ].join('\n'); const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5d72a4e..662bca4 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2,6 +2,30 @@ import SidebarConnectionRail from './sidebar/SidebarConnectionRail'; import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel'; import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu'; +import { + buildDuckDBMacroDDL, + buildQualifiedName, + buildSidebarObjectKeyName, + buildSidebarTableStatusSQL, + escapeSQLLiteral, + extractSqlServerDefinitionRows, + getCaseInsensitiveRawValue, + getCaseInsensitiveValue, + getMetadataDialect, + getMySQLShowTablesName, + getSidebarTableDisplayName, + isSphinxConnection, + loadDatabaseEvents, + loadDatabaseTriggers, + loadFunctions, + loadSchemas, + loadStarRocksMaterializedViews, + loadViews, + parseMetadataRowCount, + shouldHideSchemaPrefix, + splitQualifiedName, + supportsDatabaseEvents, +} from './sidebar/sidebarMetadataLoaders'; import { V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, formatSidebarRowCount, @@ -96,16 +120,10 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { - buildMySQLCompatibleViewMetadataSqls, - isSidebarViewTableType, - normalizeSidebarViewMetadataEntry, - resolveSidebarMetadataDialect, resolveSidebarRuntimeDatabase, type SidebarViewMetadataEntry, } from '../utils/sidebarMetadata'; -import { splitQualifiedNameLast } from '../utils/qualifiedName'; import { buildStarRocksMaterializedViewPreviewSql } from './tableDesignerSchemaSql'; -import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; import { resolveConnectionHostSummary, resolveConnectionHostTokens } from '../utils/tabDisplay'; import { findSidebarNodePathByKey, @@ -1333,708 +1351,6 @@ const Sidebar: React.FC<{ return null; }; - const SIDEBAR_SCHEMA_DB_TYPES = new Set([ - 'postgres', - 'kingbase', - 'highgo', - 'vastbase', - 'opengauss', - 'gaussdb', - 'open_gauss', - 'open-gauss', - 'sqlserver', - 'iris', - 'oracle', - 'dameng', - ]); - - const SIDEBAR_SCHEMA_CUSTOM_DRIVERS = new Set([ - 'postgres', - 'kingbase', - 'highgo', - 'vastbase', - 'opengauss', - 'gaussdb', - 'open_gauss', - 'open-gauss', - 'sqlserver', - 'iris', - 'oracle', - 'dm', - ]); - - const shouldHideSchemaPrefix = (conn: SavedConnection | undefined): boolean => { - const dbType = String(conn?.config?.type || '').trim().toLowerCase(); - if (SIDEBAR_SCHEMA_DB_TYPES.has(dbType)) return true; - if (dbType !== 'custom') return false; - - const customDriver = String(conn?.config?.driver || '').trim().toLowerCase(); - return SIDEBAR_SCHEMA_CUSTOM_DRIVERS.has(customDriver); - }; - - const getSidebarTableDisplayName = (conn: SavedConnection | undefined, tableName: string): string => { - const rawName = String(tableName || '').trim(); - if (!rawName) return rawName; - if (!shouldHideSchemaPrefix(conn)) return rawName; - const parsed = splitQualifiedName(rawName); - return parsed.objectName || rawName; - }; - - const getMetadataDialect = (conn: SavedConnection | undefined): string => { - return resolveSidebarMetadataDialect( - conn?.config?.type || '', - conn?.config?.driver || '', - conn?.config?.oceanBaseProtocol, - ); - }; - - const supportsDatabaseEvents = (conn: SavedConnection | undefined): boolean => { - return getMetadataDialect(conn) === 'mysql'; - }; - - const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); - const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`; - - type MetadataQuerySpec = { - sql: string; - inferredType?: 'FUNCTION' | 'PROCEDURE'; - }; - - type MetadataQueryResult = { - rows: Record[]; - inferredType?: 'FUNCTION' | 'PROCEDURE'; - }; - - const isSphinxConnection = (conn: SavedConnection | undefined): boolean => { - const type = String(conn?.config?.type || '').trim().toLowerCase(); - if (type === 'sphinx') return true; - if (type !== 'custom') return false; - const driver = String(conn?.config?.driver || '').trim().toLowerCase(); - return driver === 'sphinx' || driver === 'sphinxql'; - }; - - const normalizeMetadataQuerySpecs = (specs: MetadataQuerySpec[]): MetadataQuerySpec[] => { - const seen = new Set(); - const normalized: MetadataQuerySpec[] = []; - specs.forEach((spec) => { - const sql = String(spec.sql || '').trim(); - if (!sql) return; - const key = `${spec.inferredType || ''}@@${sql}`; - if (seen.has(key)) return; - seen.add(key); - normalized.push({ sql, inferredType: spec.inferredType }); - }); - return normalized; - }; - - const getCaseInsensitiveValue = (row: Record, candidateKeys: string[]): string => { - const keyMap = new Map(); - Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); - for (const key of candidateKeys) { - const value = keyMap.get(key.toLowerCase()); - if (value !== undefined && value !== null) { - const normalized = String(value).trim(); - if (normalized !== '') return normalized; - } - } - return ''; - }; - - const getCaseInsensitiveRawValue = (row: Record, candidateKeys: string[]): any => { - const keyMap = new Map(); - Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); - for (const key of candidateKeys) { - const value = keyMap.get(key.toLowerCase()); - if (value !== undefined && value !== null) { - return value; - } - } - return undefined; - }; - - const getFirstRowValue = (row: Record): string => { - for (const value of Object.values(row || {})) { - if (value !== undefined && value !== null) { - const normalized = String(value).trim(); - if (normalized !== '') return normalized; - } - } - return ''; - }; - - const extractSqlServerDefinitionRows = (rows: any[], definitionKeys: string[]): string => { - if (!Array.isArray(rows) || rows.length === 0) return ''; - const directDefinition = getCaseInsensitiveRawValue(rows[0] as Record, definitionKeys); - if (directDefinition !== undefined && directDefinition !== null && String(directDefinition).trim() !== '') { - return String(directDefinition); - } - return rows - .map((row) => getCaseInsensitiveRawValue(row as Record, ['Text', 'text'])) - .filter((value) => value !== undefined && value !== null) - .map((value) => String(value)) - .join(''); - }; - - const getMySQLShowTablesName = (row: Record): string => { - for (const key of Object.keys(row || {})) { - if (!key.toLowerCase().startsWith('tables_in_')) continue; - const value = row[key]; - if (value === undefined || value === null) continue; - const normalized = String(value).trim(); - if (normalized !== '') return normalized; - } - return ''; - }; - - const parseMetadataRowCount = (row: Record): number | undefined => { - const rawValue = getCaseInsensitiveRawValue(row, ['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']); - if (rawValue === undefined || rawValue === null || rawValue === '') { - return undefined; - } - const parsed = Number(String(rawValue).replace(/,/g, '')); - if (!Number.isFinite(parsed) || parsed < 0) { - return undefined; - } - return Math.round(parsed); - }; - - const buildSidebarTableStatusSQL = (conn: SavedConnection, dbName: string): string => { - const dialect = getMetadataDialect(conn); - const safeDbName = escapeSQLLiteral(dbName); - switch (dialect) { - case 'mysql': - case 'starrocks': - return [ - 'SELECT TABLE_NAME AS table_name, TABLE_ROWS AS table_rows', - 'FROM information_schema.tables', - `WHERE table_schema = '${safeDbName}'`, - "AND table_type = 'BASE TABLE'", - 'ORDER BY table_name', - ].join('\n'); - case 'postgres': - case 'kingbase': - case 'vastbase': - case 'highgo': - case 'opengauss': - case 'gaussdb': - return [ - "SELECT n.nspname || '.' || c.relname AS table_name, c.reltuples::bigint AS table_rows", - 'FROM pg_class c', - 'JOIN pg_namespace n ON n.oid = c.relnamespace', - "WHERE c.relkind = 'r'", - "AND n.nspname NOT IN ('information_schema', 'pg_catalog')", - "AND n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'", - 'ORDER BY n.nspname, c.relname', - ].join('\n'); - case 'sqlserver': { - const safeDb = quoteSqlServerIdentifier(dbName); - return [ - 'SELECT s.name + \'.\' + t.name AS table_name, SUM(p.rows) AS table_rows', - `FROM ${safeDb}.sys.tables t`, - `JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id`, - `LEFT JOIN ${safeDb}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)`, - 'WHERE t.type = \'U\'', - 'GROUP BY s.name, t.name', - 'ORDER BY s.name, t.name', - ].join('\n'); - } - case 'clickhouse': - return [ - 'SELECT name AS table_name, total_rows AS table_rows', - 'FROM system.tables', - `WHERE database = '${safeDbName}'`, - "AND engine NOT IN ('View', 'MaterializedView')", - 'ORDER BY name', - ].join('\n'); - case 'oracle': - case 'dm': { - const owner = escapeSQLLiteral(dbName).toUpperCase(); - return [ - 'SELECT table_name, num_rows AS table_rows', - 'FROM all_tables', - `WHERE owner = '${owner}'`, - 'ORDER BY table_name', - ].join('\n'); - } - default: - return ''; - } - }; - - const buildQualifiedName = (schemaName: string, objectName: string): string => { - const schema = String(schemaName || '').trim(); - const name = String(objectName || '').trim(); - if (!name) return ''; - if (!schema) return name; - if (name.includes('.')) return name; - return `${schema}.${name}`; - }; - - const buildSidebarObjectKeyName = (dbName: string, schemaName: string, objectName: string): string => { - const schema = String(schemaName || '').trim(); - const name = String(objectName || '').trim(); - if (!schema || !name || name.includes('.')) return name; - if (schema.toLowerCase() === String(dbName || '').trim().toLowerCase()) return name; - return `${schema}.${name}`; - }; - - const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { - const parsed = splitQualifiedNameLast(qualifiedName); - return { - schemaName: parsed.parentPath, - objectName: parsed.objectName, - }; - }; - - const parseDuckDBParameterNames = (raw: any): string[] => { - if (Array.isArray(raw)) { - return raw - .map((item) => String(item ?? '').trim()) - .filter((item) => item !== '' && item.toLowerCase() !== ''); - } - - const text = String(raw ?? '').trim(); - if (!text) return []; - const normalized = text.startsWith('[') && text.endsWith(']') - ? text.slice(1, -1) - : text; - return normalized - .split(',') - .map((part) => part.trim()) - .filter((part) => part !== '' && part.toLowerCase() !== ''); - }; - - const buildDuckDBMacroDDL = ( - schemaName: string, - functionName: string, - parametersRaw: any, - macroDefinitionRaw: any - ): string => { - const schema = String(schemaName || '').trim(); - const name = String(functionName || '').trim(); - const macroDefinition = String(macroDefinitionRaw || '').trim(); - if (!name || !macroDefinition) return ''; - - const parameters = parseDuckDBParameterNames(parametersRaw).join(', '); - const qualifiedName = schema ? `${schema}.${name}` : name; - const isTableMacro = !macroDefinition.startsWith('('); - if (isTableMacro) { - return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`; - } - return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`; - }; - - const buildViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { - const safeDbName = escapeSQLLiteral(dbName); - switch (dialect) { - case 'mysql': - case 'starrocks': { - return normalizeMetadataQuerySpecs( - buildMySQLCompatibleViewMetadataSqls(dbName).map((sql) => ({ sql })), - ); - } - case 'postgres': - case 'kingbase': - case 'highgo': - case 'vastbase': - case 'opengauss': - case 'gaussdb': - return [{ sql: `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY schemaname, viewname` }]; - case 'sqlserver': { - const safeDb = quoteSqlServerIdentifier(dbName || 'master'); - return [{ sql: `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name` }]; - } - case 'oracle': - case 'dm': - return normalizeMetadataQuerySpecs([ - { sql: `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME` }, - { sql: `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = USER ORDER BY VIEW_NAME` }, - { - sql: safeDbName - ? `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME` - : '', - }, - ]); - case 'sqlite': - return [{ sql: `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name` }]; - case 'duckdb': - return [{ sql: `SELECT table_schema AS schema_name, table_name AS view_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name` }]; - default: - return []; - } - }; - - const buildTriggersMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { - const safeDbName = escapeSQLLiteral(dbName); - switch (dialect) { - case 'mysql': - case 'starrocks': { - const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); - return normalizeMetadataQuerySpecs([ - { - sql: safeDbName - ? `SELECT TRIGGER_NAME AS trigger_name, EVENT_OBJECT_TABLE AS table_name, TRIGGER_SCHEMA AS schema_name FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME` - : '', - }, - { sql: dbIdent ? `SHOW TRIGGERS FROM \`${dbIdent}\`` : '' }, - { sql: `SHOW TRIGGERS` }, - ]); - } - case 'postgres': - case 'kingbase': - case 'highgo': - case 'vastbase': - case 'opengauss': - case 'gaussdb': - return [{ sql: `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY event_object_schema, event_object_table, trigger_name` }]; - case 'sqlserver': { - const safeDb = quoteSqlServerIdentifier(dbName || 'master'); - return [{ sql: `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name` }]; - } - case 'oracle': - case 'dm': - if (!safeDbName) { - return [{ sql: `SELECT TRIGGER_NAME AS trigger_name, TABLE_NAME AS table_name FROM USER_TRIGGERS ORDER BY TABLE_NAME, TRIGGER_NAME` }]; - } - return [{ sql: `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME` }]; - case 'sqlite': - return [{ sql: `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name` }]; - case 'duckdb': - return []; - default: - return []; - } - }; - - const buildFunctionsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { - const safeDbName = escapeSQLLiteral(dbName); - switch (dialect) { - case 'mysql': - case 'starrocks': - return normalizeMetadataQuerySpecs([ - { - sql: safeDbName - ? `SELECT ROUTINE_NAME AS routine_name, ROUTINE_TYPE AS routine_type, ROUTINE_SCHEMA AS schema_name FROM information_schema.routines WHERE routine_schema = '${safeDbName}' ORDER BY ROUTINE_TYPE, ROUTINE_NAME` - : '', - }, - { - sql: safeDbName - ? `SHOW FUNCTION STATUS WHERE Db = '${safeDbName}'` - : `SHOW FUNCTION STATUS`, - inferredType: 'FUNCTION', - }, - { - sql: safeDbName - ? `SHOW PROCEDURE STATUS WHERE Db = '${safeDbName}'` - : `SHOW PROCEDURE STATUS`, - inferredType: 'PROCEDURE', - }, - ]); - case 'postgres': - case 'kingbase': - case 'highgo': - case 'vastbase': - case 'opengauss': - case 'gaussdb': - return normalizeMetadataQuerySpecs([ - { - // PostgreSQL 11+ / 部分 PG-like:通过 prokind 区分 FUNCTION/PROCEDURE - sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, routine_type, p.proname`, - }, - { - // PostgreSQL 10 / 不支持 prokind 的兼容路径 - sql: `SELECT r.routine_schema AS schema_name, r.routine_name AS routine_name, COALESCE(NULLIF(UPPER(r.routine_type), ''), 'FUNCTION') AS routine_type FROM information_schema.routines r WHERE r.routine_schema NOT IN ('pg_catalog', 'information_schema') AND r.routine_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY r.routine_schema, routine_type, r.routine_name`, - }, - { - // 最后兜底:仅函数列表,确保 prokind/routines 视图异常时仍可展示 - sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, 'FUNCTION' AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, p.proname`, - }, - ]); - case 'sqlserver': { - const safeDb = quoteSqlServerIdentifier(dbName || 'master'); - return [{ sql: `SELECT s.name AS schema_name, o.name AS routine_name, CASE o.type WHEN 'P' THEN 'PROCEDURE' WHEN 'FN' THEN 'FUNCTION' WHEN 'IF' THEN 'FUNCTION' WHEN 'TF' THEN 'FUNCTION' END AS routine_type FROM ${safeDb}.sys.objects o JOIN ${safeDb}.sys.schemas s ON o.schema_id = s.schema_id WHERE o.type IN ('P','FN','IF','TF') ORDER BY o.type, s.name, o.name` }]; - } - case 'oracle': - case 'dm': - return normalizeMetadataQuerySpecs([ - { sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, - { sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = USER AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, - { - sql: safeDbName - ? `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` - : '', - }, - ]); - case 'duckdb': - return [{ - sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`, - inferredType: 'FUNCTION', - }]; - default: - return []; - } - }; - - const buildEventsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { - if (dialect !== 'mysql') { - return []; - } - const safeDbName = escapeSQLLiteral(dbName); - const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); - return normalizeMetadataQuerySpecs([ - { - sql: safeDbName - ? `SELECT EVENT_SCHEMA AS schema_name, EVENT_NAME AS event_name, EVENT_TYPE AS event_type, STATUS AS status FROM information_schema.events WHERE event_schema = '${safeDbName}' ORDER BY EVENT_NAME` - : '', - }, - { sql: dbIdent ? `SHOW EVENTS FROM \`${dbIdent}\`` : '' }, - { sql: `SHOW EVENTS` }, - ]); - }; - - const buildSchemasMetadataQuerySpecs = (dialect: string): MetadataQuerySpec[] => { - if (!isPostgresSchemaDialect(dialect)) { - return []; - } - return [{ - sql: `SELECT nspname AS schema_name FROM pg_namespace WHERE nspname NOT IN ('pg_catalog', 'information_schema') AND nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY nspname`, - }]; - }; - - const queryMetadataRowsBySpecs = async ( - conn: any, - dbName: string, - specs: MetadataQuerySpec[] - ): Promise<{ results: MetadataQueryResult[]; hasSuccessfulQuery: boolean }> => { - const normalizedSpecs = normalizeMetadataQuerySpecs(specs); - if (normalizedSpecs.length === 0) { - return { results: [], hasSuccessfulQuery: false }; - } - const config = buildRuntimeConfig(conn, dbName); - const results: MetadataQueryResult[] = []; - let hasSuccessfulQuery = false; - - for (const spec of normalizedSpecs) { - try { - const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, spec.sql); - if (!result.success || !Array.isArray(result.data)) { - continue; - } - hasSuccessfulQuery = true; - results.push({ - rows: result.data as Record[], - inferredType: spec.inferredType, - }); - } catch { - // 忽略单条查询失败,继续尝试后续回退语句 - } - } - return { results, hasSuccessfulQuery }; - }; - - const loadViews = async (conn: any, dbName: string): Promise<{ views: SidebarViewMetadataEntry[]; supported: boolean }> => { - const savedConn = conn as SavedConnection; - const dialect = getMetadataDialect(savedConn); - const querySpecs = buildViewsMetadataQuerySpecs(dialect, dbName); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const views: SidebarViewMetadataEntry[] = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const tableType = getCaseInsensitiveValue(row, ['table_type', 'table type', 'type']); - if (!isSidebarViewTableType(tableType)) return; - const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'table_schema', 'db']); - const viewName = - getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name']) - || getMySQLShowTablesName(row) - || getFirstRowValue(row); - const entry = normalizeSidebarViewMetadataEntry(dialect, dbName, schemaName, viewName); - if (!entry) return; - const uniqueKey = `${entry.schemaName.toLowerCase()}@@${entry.viewName.toLowerCase()}`; - if (seen.has(uniqueKey)) return; - seen.add(uniqueKey); - views.push(entry); - }); - }); - return { views, supported: hasSuccessfulQuery }; - }; - - const loadStarRocksMaterializedViews = async ( - conn: any, - dbName: string - ): Promise<{ views: SidebarViewMetadataEntry[]; supported: boolean }> => { - const dialect = getMetadataDialect(conn as SavedConnection); - if (dialect !== 'starrocks') { - return { views: [], supported: false }; - } - - const safeDbName = escapeSQLLiteral(dbName); - const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); - const querySpecs = normalizeMetadataQuerySpecs([ - { - sql: safeDbName - ? `SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name FROM information_schema.tables WHERE TABLE_SCHEMA = '${safeDbName}' AND UPPER(TABLE_TYPE) LIKE '%MATERIALIZED%' ORDER BY TABLE_NAME` - : '', - }, - { sql: dbIdent ? `SHOW MATERIALIZED VIEWS FROM \`${dbIdent}\`` : '' }, - { sql: `SHOW MATERIALIZED VIEWS` }, - ]); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const views: SidebarViewMetadataEntry[] = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'table_schema', 'db', 'database']); - const viewName = - getCaseInsensitiveValue(row, ['object_name', 'view_name', 'table_name', 'name', 'materialized_view_name', 'mv_name']) - || getFirstRowValue(row); - const entry = normalizeSidebarViewMetadataEntry(dialect, dbName, schemaName, viewName); - if (!entry) return; - const uniqueKey = `${entry.schemaName.toLowerCase()}@@${entry.viewName.toLowerCase()}`; - if (seen.has(uniqueKey)) return; - seen.add(uniqueKey); - views.push(entry); - }); - }); - - return { views, supported: hasSuccessfulQuery }; - }; - - const loadDatabaseTriggers = async ( - conn: any, - dbName: string - ): Promise<{ triggers: Array<{ displayName: string; triggerName: string; tableName: string }>; supported: boolean }> => { - const dialect = getMetadataDialect(conn as SavedConnection); - const querySpecs = buildTriggersMetadataQuerySpecs(dialect, dbName); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const triggers: Array<{ displayName: string; triggerName: string; tableName: string }> = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const rawTriggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row); - if (!rawTriggerName) return; - - const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']); - const rawTableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']); - - const triggerParts = splitQualifiedName(rawTriggerName); - const tableParts = splitQualifiedName(rawTableName); - - const resolvedSchema = ( - rawSchemaName - || tableParts.schemaName - || triggerParts.schemaName - || dbName - ).trim(); - const resolvedTriggerName = (triggerParts.objectName || rawTriggerName).trim(); - const resolvedTableName = (tableParts.objectName || rawTableName).trim(); - const fullTableName = buildQualifiedName(resolvedSchema, resolvedTableName); - - // MySQL 下 trigger 名在同 schema 内唯一,直接按 schema+trigger 去重可彻底规避多元数据查询导致的重复 - const uniqueKey = dialect === 'mysql' - ? `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}` - : `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}@@${resolvedTableName.toLowerCase()}`; - if (seen.has(uniqueKey)) return; - seen.add(uniqueKey); - const displayName = fullTableName ? `${resolvedTriggerName} (${fullTableName})` : resolvedTriggerName; - triggers.push({ displayName, triggerName: resolvedTriggerName, tableName: fullTableName || resolvedTableName }); - }); - }); - return { triggers, supported: hasSuccessfulQuery }; - }; - - const loadFunctions = async ( - conn: any, - dbName: string - ): Promise<{ routines: Array<{ displayName: string; routineName: string; routineType: string }>; supported: boolean }> => { - const dialect = getMetadataDialect(conn as SavedConnection); - const querySpecs = buildFunctionsMetadataQuerySpecs(dialect, dbName); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const routines: Array<{ displayName: string; routineName: string; routineType: string }> = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const routineName = getCaseInsensitiveValue(row, ['routine_name', 'object_name', 'proname', 'name']); - if (!routineName) return; - const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'owner', 'db', 'database']); - const rawType = getCaseInsensitiveValue(row, ['routine_type', 'object_type', 'type']) || queryResult.inferredType || 'FUNCTION'; - const normalizedType = rawType.toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION'; - const fullName = buildQualifiedName(schemaName, routineName); - const uniqueKey = `${fullName}@@${normalizedType}`; - if (!fullName || seen.has(uniqueKey)) return; - seen.add(uniqueKey); - const typeLabel = normalizedType === 'PROCEDURE' ? 'P' : 'F'; - routines.push({ displayName: `${fullName} [${typeLabel}]`, routineName: fullName, routineType: normalizedType }); - }); - }); - return { routines, supported: hasSuccessfulQuery }; - }; - - const loadDatabaseEvents = async ( - conn: any, - dbName: string - ): Promise<{ events: Array<{ displayName: string; eventName: string; schemaName: string; eventType: string; status: string }>; supported: boolean }> => { - const dialect = getMetadataDialect(conn as SavedConnection); - const querySpecs = buildEventsMetadataQuerySpecs(dialect, dbName); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const events: Array<{ displayName: string; eventName: string; schemaName: string; eventType: string; status: string }> = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const rawEventName = getCaseInsensitiveValue(row, ['event_name', 'eventname', 'name', 'event']); - if (!rawEventName) return; - - const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'event_schema', 'db', 'database']); - const parsed = splitQualifiedName(rawEventName); - const schemaName = (rawSchemaName || parsed.schemaName || dbName).trim(); - const eventName = (parsed.objectName || rawEventName).trim(); - if (!eventName) return; - - const uniqueKey = `${schemaName.toLowerCase()}@@${eventName.toLowerCase()}`; - if (seen.has(uniqueKey)) return; - seen.add(uniqueKey); - - const eventType = getCaseInsensitiveValue(row, ['event_type', 'type']); - const status = getCaseInsensitiveValue(row, ['status']); - events.push({ - displayName: eventName, - eventName, - schemaName, - eventType, - status, - }); - }); - }); - - return { events, supported: hasSuccessfulQuery }; - }; - - const loadSchemas = async (conn: any, dbName: string): Promise<{ schemas: string[]; supported: boolean }> => { - const dialect = getMetadataDialect(conn as SavedConnection); - const querySpecs = buildSchemasMetadataQuerySpecs(dialect); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const schemas: string[] = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'schemaname']) || getFirstRowValue(row); - if (!schemaName) return; - const key = schemaName.toLowerCase(); - if (seen.has(key)) return; - seen.add(key); - schemas.push(schemaName); - }); - }); - - return { schemas, supported: hasSuccessfulQuery }; - }; - const fetchDriverStatusMap = async (): Promise> => { const cached = driverStatusCacheRef.current; if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) { diff --git a/frontend/src/components/sidebar/sidebarMetadataLoaders.ts b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts new file mode 100644 index 0000000..7d24823 --- /dev/null +++ b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts @@ -0,0 +1,763 @@ +import { DBQuery } from '../../../wailsjs/go/app/App'; +import type { SavedConnection } from '../../types'; +import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; +import { normalizeOceanBaseProtocol } from '../../utils/oceanBaseProtocol'; +import { splitQualifiedNameLast } from '../../utils/qualifiedName'; +import { + buildMySQLCompatibleViewMetadataSqls, + isSidebarViewTableType, + normalizeSidebarViewMetadataEntry, + resolveSidebarMetadataDialect, + resolveSidebarRuntimeDatabase, + type SidebarViewMetadataEntry, +} from '../../utils/sidebarMetadata'; +import { isPostgresSchemaDialect } from '../sidebarCoreUtils'; + +export const buildSidebarRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => { + return buildRpcConnectionConfig(conn.config, { + database: resolveSidebarRuntimeDatabase( + conn?.config?.type, + conn?.config?.driver, + conn?.config?.database, + overrideDatabase, + clearDatabase, + conn?.config?.oceanBaseProtocol, + ), + }); +}; + + const SIDEBAR_SCHEMA_DB_TYPES = new Set([ + 'postgres', + 'kingbase', + 'highgo', + 'vastbase', + 'opengauss', + 'gaussdb', + 'open_gauss', + 'open-gauss', + 'sqlserver', + 'iris', + 'oracle', + 'dameng', + ]); + + const SIDEBAR_SCHEMA_CUSTOM_DRIVERS = new Set([ + 'postgres', + 'kingbase', + 'highgo', + 'vastbase', + 'opengauss', + 'gaussdb', + 'open_gauss', + 'open-gauss', + 'sqlserver', + 'iris', + 'oracle', + 'dm', + ]); + + const shouldHideSchemaPrefix = (conn: SavedConnection | undefined): boolean => { + const dbType = String(conn?.config?.type || '').trim().toLowerCase(); + if (SIDEBAR_SCHEMA_DB_TYPES.has(dbType)) return true; + if (dbType !== 'custom') return false; + + const customDriver = String(conn?.config?.driver || '').trim().toLowerCase(); + return SIDEBAR_SCHEMA_CUSTOM_DRIVERS.has(customDriver); + }; + + const getSidebarTableDisplayName = (conn: SavedConnection | undefined, tableName: string): string => { + const rawName = String(tableName || '').trim(); + if (!rawName) return rawName; + if (!shouldHideSchemaPrefix(conn)) return rawName; + const parsed = splitQualifiedName(rawName); + return parsed.objectName || rawName; + }; + + const getMetadataDialect = (conn: SavedConnection | undefined): string => { + return resolveSidebarMetadataDialect( + conn?.config?.type || '', + conn?.config?.driver || '', + conn?.config?.oceanBaseProtocol, + ); + }; + + const supportsDatabaseEvents = (conn: SavedConnection | undefined): boolean => { + return getMetadataDialect(conn) === 'mysql'; + }; + + const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); + const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`; + + type MetadataQuerySpec = { + sql: string; + inferredType?: 'FUNCTION' | 'PROCEDURE'; + }; + + type MetadataQueryResult = { + rows: Record[]; + inferredType?: 'FUNCTION' | 'PROCEDURE'; + }; + + const isSphinxConnection = (conn: SavedConnection | undefined): boolean => { + const type = String(conn?.config?.type || '').trim().toLowerCase(); + if (type === 'sphinx') return true; + if (type !== 'custom') return false; + const driver = String(conn?.config?.driver || '').trim().toLowerCase(); + return driver === 'sphinx' || driver === 'sphinxql'; + }; + + const normalizeMetadataQuerySpecs = (specs: MetadataQuerySpec[]): MetadataQuerySpec[] => { + const seen = new Set(); + const normalized: MetadataQuerySpec[] = []; + specs.forEach((spec) => { + const sql = String(spec.sql || '').trim(); + if (!sql) return; + const key = `${spec.inferredType || ''}@@${sql}`; + if (seen.has(key)) return; + seen.add(key); + normalized.push({ sql, inferredType: spec.inferredType }); + }); + return normalized; + }; + + const getCaseInsensitiveValue = (row: Record, candidateKeys: string[]): string => { + const keyMap = new Map(); + Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); + for (const key of candidateKeys) { + const value = keyMap.get(key.toLowerCase()); + if (value !== undefined && value !== null) { + const normalized = String(value).trim(); + if (normalized !== '') return normalized; + } + } + return ''; + }; + + const getCaseInsensitiveRawValue = (row: Record, candidateKeys: string[]): any => { + const keyMap = new Map(); + Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); + for (const key of candidateKeys) { + const value = keyMap.get(key.toLowerCase()); + if (value !== undefined && value !== null) { + return value; + } + } + return undefined; + }; + + const getFirstRowValue = (row: Record): string => { + for (const value of Object.values(row || {})) { + if (value !== undefined && value !== null) { + const normalized = String(value).trim(); + if (normalized !== '') return normalized; + } + } + return ''; + }; + + const extractSqlServerDefinitionRows = (rows: any[], definitionKeys: string[]): string => { + if (!Array.isArray(rows) || rows.length === 0) return ''; + const directDefinition = getCaseInsensitiveRawValue(rows[0] as Record, definitionKeys); + if (directDefinition !== undefined && directDefinition !== null && String(directDefinition).trim() !== '') { + return String(directDefinition); + } + return rows + .map((row) => getCaseInsensitiveRawValue(row as Record, ['Text', 'text'])) + .filter((value) => value !== undefined && value !== null) + .map((value) => String(value)) + .join(''); + }; + + const getMySQLShowTablesName = (row: Record): string => { + for (const key of Object.keys(row || {})) { + if (!key.toLowerCase().startsWith('tables_in_')) continue; + const value = row[key]; + if (value === undefined || value === null) continue; + const normalized = String(value).trim(); + if (normalized !== '') return normalized; + } + return ''; + }; + + const parseMetadataRowCount = (row: Record): number | undefined => { + const rawValue = getCaseInsensitiveRawValue(row, ['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']); + if (rawValue === undefined || rawValue === null || rawValue === '') { + return undefined; + } + const parsed = Number(String(rawValue).replace(/,/g, '')); + if (!Number.isFinite(parsed) || parsed < 0) { + return undefined; + } + return Math.round(parsed); + }; + + const buildSidebarTableStatusSQL = (conn: SavedConnection, dbName: string): string => { + const dialect = getMetadataDialect(conn); + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': + return [ + 'SELECT TABLE_NAME AS table_name, TABLE_ROWS AS table_rows', + 'FROM information_schema.tables', + `WHERE table_schema = '${safeDbName}'`, + "AND table_type = 'BASE TABLE'", + 'ORDER BY table_name', + ].join('\n'); + case 'postgres': + case 'kingbase': + case 'vastbase': + case 'highgo': + case 'opengauss': + case 'gaussdb': + return [ + "SELECT n.nspname || '.' || c.relname AS table_name, c.reltuples::bigint AS table_rows", + 'FROM pg_class c', + 'JOIN pg_namespace n ON n.oid = c.relnamespace', + "WHERE c.relkind = 'r'", + "AND n.nspname NOT IN ('information_schema', 'pg_catalog')", + "AND n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'", + 'ORDER BY n.nspname, c.relname', + ].join('\n'); + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName); + return [ + 'SELECT s.name + \'.\' + t.name AS table_name, SUM(p.rows) AS table_rows', + `FROM ${safeDb}.sys.tables t`, + `JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id`, + `LEFT JOIN ${safeDb}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)`, + 'WHERE t.type = \'U\'', + 'GROUP BY s.name, t.name', + 'ORDER BY s.name, t.name', + ].join('\n'); + } + case 'clickhouse': + return [ + 'SELECT name AS table_name, total_rows AS table_rows', + 'FROM system.tables', + `WHERE database = '${safeDbName}'`, + "AND engine NOT IN ('View', 'MaterializedView')", + 'ORDER BY name', + ].join('\n'); + case 'oracle': + case 'dm': { + const owner = escapeSQLLiteral(dbName).toUpperCase(); + return [ + 'SELECT table_name, num_rows AS table_rows', + 'FROM all_tables', + `WHERE owner = '${owner}'`, + 'ORDER BY table_name', + ].join('\n'); + } + default: + return ''; + } + }; + + const buildQualifiedName = (schemaName: string, objectName: string): string => { + const schema = String(schemaName || '').trim(); + const name = String(objectName || '').trim(); + if (!name) return ''; + if (!schema) return name; + if (name.includes('.')) return name; + return `${schema}.${name}`; + }; + + const buildSidebarObjectKeyName = (dbName: string, schemaName: string, objectName: string): string => { + const schema = String(schemaName || '').trim(); + const name = String(objectName || '').trim(); + if (!schema || !name || name.includes('.')) return name; + if (schema.toLowerCase() === String(dbName || '').trim().toLowerCase()) return name; + return `${schema}.${name}`; + }; + + const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { + const parsed = splitQualifiedNameLast(qualifiedName); + return { + schemaName: parsed.parentPath, + objectName: parsed.objectName, + }; + }; + + const parseDuckDBParameterNames = (raw: any): string[] => { + if (Array.isArray(raw)) { + return raw + .map((item) => String(item ?? '').trim()) + .filter((item) => item !== '' && item.toLowerCase() !== ''); + } + + const text = String(raw ?? '').trim(); + if (!text) return []; + const normalized = text.startsWith('[') && text.endsWith(']') + ? text.slice(1, -1) + : text; + return normalized + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== '' && part.toLowerCase() !== ''); + }; + + const buildDuckDBMacroDDL = ( + schemaName: string, + functionName: string, + parametersRaw: any, + macroDefinitionRaw: any + ): string => { + const schema = String(schemaName || '').trim(); + const name = String(functionName || '').trim(); + const macroDefinition = String(macroDefinitionRaw || '').trim(); + if (!name || !macroDefinition) return ''; + + const parameters = parseDuckDBParameterNames(parametersRaw).join(', '); + const qualifiedName = schema ? `${schema}.${name}` : name; + const isTableMacro = !macroDefinition.startsWith('('); + if (isTableMacro) { + return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`; + } + return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`; + }; + + const buildViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': { + return normalizeMetadataQuerySpecs( + buildMySQLCompatibleViewMetadataSqls(dbName).map((sql) => ({ sql })), + ); + } + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + case 'gaussdb': + return [{ sql: `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY schemaname, viewname` }]; + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName || 'master'); + return [{ sql: `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name` }]; + } + case 'oracle': + case 'dm': + return normalizeMetadataQuerySpecs([ + { sql: `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME` }, + { sql: `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = USER ORDER BY VIEW_NAME` }, + { + sql: safeDbName + ? `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME` + : '', + }, + ]); + case 'sqlite': + return [{ sql: `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name` }]; + case 'duckdb': + return [{ sql: `SELECT table_schema AS schema_name, table_name AS view_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name` }]; + default: + return []; + } + }; + + const buildTriggersMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': { + const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); + return normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT TRIGGER_NAME AS trigger_name, EVENT_OBJECT_TABLE AS table_name, TRIGGER_SCHEMA AS schema_name FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME` + : '', + }, + { sql: dbIdent ? `SHOW TRIGGERS FROM \`${dbIdent}\`` : '' }, + { sql: `SHOW TRIGGERS` }, + ]); + } + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + case 'gaussdb': + return [{ sql: `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY event_object_schema, event_object_table, trigger_name` }]; + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName || 'master'); + return [{ sql: `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name` }]; + } + case 'oracle': + case 'dm': + if (!safeDbName) { + return [{ sql: `SELECT TRIGGER_NAME AS trigger_name, TABLE_NAME AS table_name FROM USER_TRIGGERS ORDER BY TABLE_NAME, TRIGGER_NAME` }]; + } + return [{ sql: `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME` }]; + case 'sqlite': + return [{ sql: `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name` }]; + case 'duckdb': + return []; + default: + return []; + } + }; + + const buildFunctionsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': + return normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT ROUTINE_NAME AS routine_name, ROUTINE_TYPE AS routine_type, ROUTINE_SCHEMA AS schema_name FROM information_schema.routines WHERE routine_schema = '${safeDbName}' ORDER BY ROUTINE_TYPE, ROUTINE_NAME` + : '', + }, + { + sql: safeDbName + ? `SHOW FUNCTION STATUS WHERE Db = '${safeDbName}'` + : `SHOW FUNCTION STATUS`, + inferredType: 'FUNCTION', + }, + { + sql: safeDbName + ? `SHOW PROCEDURE STATUS WHERE Db = '${safeDbName}'` + : `SHOW PROCEDURE STATUS`, + inferredType: 'PROCEDURE', + }, + ]); + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + case 'gaussdb': + return normalizeMetadataQuerySpecs([ + { + // PostgreSQL 11+ / 部分 PG-like:通过 prokind 区分 FUNCTION/PROCEDURE + sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, routine_type, p.proname`, + }, + { + // PostgreSQL 10 / 不支持 prokind 的兼容路径 + sql: `SELECT r.routine_schema AS schema_name, r.routine_name AS routine_name, COALESCE(NULLIF(UPPER(r.routine_type), ''), 'FUNCTION') AS routine_type FROM information_schema.routines r WHERE r.routine_schema NOT IN ('pg_catalog', 'information_schema') AND r.routine_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY r.routine_schema, routine_type, r.routine_name`, + }, + { + // 最后兜底:仅函数列表,确保 prokind/routines 视图异常时仍可展示 + sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, 'FUNCTION' AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, p.proname`, + }, + ]); + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName || 'master'); + return [{ sql: `SELECT s.name AS schema_name, o.name AS routine_name, CASE o.type WHEN 'P' THEN 'PROCEDURE' WHEN 'FN' THEN 'FUNCTION' WHEN 'IF' THEN 'FUNCTION' WHEN 'TF' THEN 'FUNCTION' END AS routine_type FROM ${safeDb}.sys.objects o JOIN ${safeDb}.sys.schemas s ON o.schema_id = s.schema_id WHERE o.type IN ('P','FN','IF','TF') ORDER BY o.type, s.name, o.name` }]; + } + case 'oracle': + case 'dm': + return normalizeMetadataQuerySpecs([ + { sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, + { sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = USER AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, + { + sql: safeDbName + ? `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` + : '', + }, + ]); + case 'duckdb': + return [{ + sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`, + inferredType: 'FUNCTION', + }]; + default: + return []; + } + }; + + const buildEventsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + if (dialect !== 'mysql') { + return []; + } + const safeDbName = escapeSQLLiteral(dbName); + const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); + return normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT EVENT_SCHEMA AS schema_name, EVENT_NAME AS event_name, EVENT_TYPE AS event_type, STATUS AS status FROM information_schema.events WHERE event_schema = '${safeDbName}' ORDER BY EVENT_NAME` + : '', + }, + { sql: dbIdent ? `SHOW EVENTS FROM \`${dbIdent}\`` : '' }, + { sql: `SHOW EVENTS` }, + ]); + }; + + const buildSchemasMetadataQuerySpecs = (dialect: string): MetadataQuerySpec[] => { + if (!isPostgresSchemaDialect(dialect)) { + return []; + } + return [{ + sql: `SELECT nspname AS schema_name FROM pg_namespace WHERE nspname NOT IN ('pg_catalog', 'information_schema') AND nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY nspname`, + }]; + }; + + const queryMetadataRowsBySpecs = async ( + conn: any, + dbName: string, + specs: MetadataQuerySpec[] + ): Promise<{ results: MetadataQueryResult[]; hasSuccessfulQuery: boolean }> => { + const normalizedSpecs = normalizeMetadataQuerySpecs(specs); + if (normalizedSpecs.length === 0) { + return { results: [], hasSuccessfulQuery: false }; + } + const config = buildSidebarRuntimeConfig(conn, dbName); + const results: MetadataQueryResult[] = []; + let hasSuccessfulQuery = false; + + for (const spec of normalizedSpecs) { + try { + const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, spec.sql); + if (!result.success || !Array.isArray(result.data)) { + continue; + } + hasSuccessfulQuery = true; + results.push({ + rows: result.data as Record[], + inferredType: spec.inferredType, + }); + } catch { + // 忽略单条查询失败,继续尝试后续回退语句 + } + } + return { results, hasSuccessfulQuery }; + }; + + const loadViews = async (conn: any, dbName: string): Promise<{ views: SidebarViewMetadataEntry[]; supported: boolean }> => { + const savedConn = conn as SavedConnection; + const dialect = getMetadataDialect(savedConn); + const querySpecs = buildViewsMetadataQuerySpecs(dialect, dbName); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const views: SidebarViewMetadataEntry[] = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const tableType = getCaseInsensitiveValue(row, ['table_type', 'table type', 'type']); + if (!isSidebarViewTableType(tableType)) return; + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'table_schema', 'db']); + const viewName = + getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name']) + || getMySQLShowTablesName(row) + || getFirstRowValue(row); + const entry = normalizeSidebarViewMetadataEntry(dialect, dbName, schemaName, viewName); + if (!entry) return; + const uniqueKey = `${entry.schemaName.toLowerCase()}@@${entry.viewName.toLowerCase()}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + views.push(entry); + }); + }); + return { views, supported: hasSuccessfulQuery }; + }; + + const loadStarRocksMaterializedViews = async ( + conn: any, + dbName: string + ): Promise<{ views: SidebarViewMetadataEntry[]; supported: boolean }> => { + const dialect = getMetadataDialect(conn as SavedConnection); + if (dialect !== 'starrocks') { + return { views: [], supported: false }; + } + + const safeDbName = escapeSQLLiteral(dbName); + const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); + const querySpecs = normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name FROM information_schema.tables WHERE TABLE_SCHEMA = '${safeDbName}' AND UPPER(TABLE_TYPE) LIKE '%MATERIALIZED%' ORDER BY TABLE_NAME` + : '', + }, + { sql: dbIdent ? `SHOW MATERIALIZED VIEWS FROM \`${dbIdent}\`` : '' }, + { sql: `SHOW MATERIALIZED VIEWS` }, + ]); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const views: SidebarViewMetadataEntry[] = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'table_schema', 'db', 'database']); + const viewName = + getCaseInsensitiveValue(row, ['object_name', 'view_name', 'table_name', 'name', 'materialized_view_name', 'mv_name']) + || getFirstRowValue(row); + const entry = normalizeSidebarViewMetadataEntry(dialect, dbName, schemaName, viewName); + if (!entry) return; + const uniqueKey = `${entry.schemaName.toLowerCase()}@@${entry.viewName.toLowerCase()}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + views.push(entry); + }); + }); + + return { views, supported: hasSuccessfulQuery }; + }; + + const loadDatabaseTriggers = async ( + conn: any, + dbName: string + ): Promise<{ triggers: Array<{ displayName: string; triggerName: string; tableName: string }>; supported: boolean }> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const querySpecs = buildTriggersMetadataQuerySpecs(dialect, dbName); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const triggers: Array<{ displayName: string; triggerName: string; tableName: string }> = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const rawTriggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row); + if (!rawTriggerName) return; + + const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']); + const rawTableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']); + + const triggerParts = splitQualifiedName(rawTriggerName); + const tableParts = splitQualifiedName(rawTableName); + + const resolvedSchema = ( + rawSchemaName + || tableParts.schemaName + || triggerParts.schemaName + || dbName + ).trim(); + const resolvedTriggerName = (triggerParts.objectName || rawTriggerName).trim(); + const resolvedTableName = (tableParts.objectName || rawTableName).trim(); + const fullTableName = buildQualifiedName(resolvedSchema, resolvedTableName); + + // MySQL 下 trigger 名在同 schema 内唯一,直接按 schema+trigger 去重可彻底规避多元数据查询导致的重复 + const uniqueKey = dialect === 'mysql' + ? `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}` + : `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}@@${resolvedTableName.toLowerCase()}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + const displayName = fullTableName ? `${resolvedTriggerName} (${fullTableName})` : resolvedTriggerName; + triggers.push({ displayName, triggerName: resolvedTriggerName, tableName: fullTableName || resolvedTableName }); + }); + }); + return { triggers, supported: hasSuccessfulQuery }; + }; + + const loadFunctions = async ( + conn: any, + dbName: string + ): Promise<{ routines: Array<{ displayName: string; routineName: string; routineType: string }>; supported: boolean }> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const querySpecs = buildFunctionsMetadataQuerySpecs(dialect, dbName); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const routines: Array<{ displayName: string; routineName: string; routineType: string }> = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const routineName = getCaseInsensitiveValue(row, ['routine_name', 'object_name', 'proname', 'name']); + if (!routineName) return; + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'owner', 'db', 'database']); + const rawType = getCaseInsensitiveValue(row, ['routine_type', 'object_type', 'type']) || queryResult.inferredType || 'FUNCTION'; + const normalizedType = rawType.toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION'; + const fullName = buildQualifiedName(schemaName, routineName); + const uniqueKey = `${fullName}@@${normalizedType}`; + if (!fullName || seen.has(uniqueKey)) return; + seen.add(uniqueKey); + const typeLabel = normalizedType === 'PROCEDURE' ? 'P' : 'F'; + routines.push({ displayName: `${fullName} [${typeLabel}]`, routineName: fullName, routineType: normalizedType }); + }); + }); + return { routines, supported: hasSuccessfulQuery }; + }; + + const loadDatabaseEvents = async ( + conn: any, + dbName: string + ): Promise<{ events: Array<{ displayName: string; eventName: string; schemaName: string; eventType: string; status: string }>; supported: boolean }> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const querySpecs = buildEventsMetadataQuerySpecs(dialect, dbName); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const events: Array<{ displayName: string; eventName: string; schemaName: string; eventType: string; status: string }> = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const rawEventName = getCaseInsensitiveValue(row, ['event_name', 'eventname', 'name', 'event']); + if (!rawEventName) return; + + const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'event_schema', 'db', 'database']); + const parsed = splitQualifiedName(rawEventName); + const schemaName = (rawSchemaName || parsed.schemaName || dbName).trim(); + const eventName = (parsed.objectName || rawEventName).trim(); + if (!eventName) return; + + const uniqueKey = `${schemaName.toLowerCase()}@@${eventName.toLowerCase()}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + + const eventType = getCaseInsensitiveValue(row, ['event_type', 'type']); + const status = getCaseInsensitiveValue(row, ['status']); + events.push({ + displayName: eventName, + eventName, + schemaName, + eventType, + status, + }); + }); + }); + + return { events, supported: hasSuccessfulQuery }; + }; + + const loadSchemas = async (conn: any, dbName: string): Promise<{ schemas: string[]; supported: boolean }> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const querySpecs = buildSchemasMetadataQuerySpecs(dialect); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const schemas: string[] = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'schemaname']) || getFirstRowValue(row); + if (!schemaName) return; + const key = schemaName.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + schemas.push(schemaName); + }); + }); + + return { schemas, supported: hasSuccessfulQuery }; + }; + +export { + buildDuckDBMacroDDL, + buildEventsMetadataQuerySpecs, + buildFunctionsMetadataQuerySpecs, + buildQualifiedName, + buildSchemasMetadataQuerySpecs, + buildSidebarObjectKeyName, + buildSidebarTableStatusSQL, + buildTriggersMetadataQuerySpecs, + buildViewsMetadataQuerySpecs, + escapeSQLLiteral, + extractSqlServerDefinitionRows, + getCaseInsensitiveRawValue, + getCaseInsensitiveValue, + getFirstRowValue, + getMetadataDialect, + getMySQLShowTablesName, + getSidebarTableDisplayName, + isSphinxConnection, + loadDatabaseEvents, + loadDatabaseTriggers, + loadFunctions, + loadSchemas, + loadStarRocksMaterializedViews, + loadViews, + normalizeMetadataQuerySpecs, + parseDuckDBParameterNames, + parseMetadataRowCount, + quoteSqlServerIdentifier, + shouldHideSchemaPrefix, + splitQualifiedName, + supportsDatabaseEvents, +};