diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 897034c44..5e2e4785f 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -1894,7 +1894,10 @@ async function findExactSemanticDiagnosticTable(table: SqlTableReference): Promi if (!target) return null; const localMatches = connectionStore.lookupLocalCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema, target.catalog); const localExact = localMatches.find((item) => completionTablesMatch(item, table)); - if (localExact) return localExact; + if (localExact) { + cachedTables = mergeCompletionTables(cachedTables, [localExact]); + return localExact; + } const remoteMatches = await connectionStore.listCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema, false, props.schema, target.catalog); cachedTables = mergeCompletionTables(cachedTables, remoteMatches); diff --git a/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts b/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts index c981838fb..680ecc415 100644 --- a/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts +++ b/apps/desktop/src/lib/__tests__/metadata/completionTreeIndex.spec.ts @@ -100,4 +100,19 @@ describe("completionTreeIndex", () => { expect(completionTablesFromTree(tree, "doris", "sales")).toEqual([{ name: "orders", schema: undefined, type: "table" }]); expect(completionTablesFromTree(tree, "doris", "sales", undefined, "hive_catalog")).toEqual([{ name: "orders", catalog: "hive_catalog", schema: undefined, type: "table" }]); }); + + it("preserves TDengine stable metadata from the sidebar tree", () => { + const tree: TreeNode[] = [ + { + id: "tdengine-stable", + label: "test_tb", + type: "table", + tableType: "STABLE", + connectionId: "tdengine", + database: "issue_5685", + }, + ]; + + expect(completionTablesFromTree(tree, "tdengine", "issue_5685")).toEqual([{ name: "test_tb", schema: undefined, type: "table", tableType: "STABLE" }]); + }); }); diff --git a/apps/desktop/src/lib/metadata/completionTreeIndex.ts b/apps/desktop/src/lib/metadata/completionTreeIndex.ts index de82f4432..0438af78d 100644 --- a/apps/desktop/src/lib/metadata/completionTreeIndex.ts +++ b/apps/desktop/src/lib/metadata/completionTreeIndex.ts @@ -1,5 +1,6 @@ import type { TreeNode } from "@/types/database"; import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion"; +import { isTdengineStableTableType } from "@/lib/table/tableEditing"; const TABLE_NODE_TYPES = new Set(["table", "view", "materialized_view"]); @@ -31,6 +32,7 @@ export function completionTablesFromTree(nodes: readonly TreeNode[], connectionI catalog: node.catalog, schema: node.schema, type: node.type === "materialized_view" ? "materialized_view" : node.type === "view" ? "view" : "table", + ...(isTdengineStableTableType(node.tableType) ? { tableType: node.tableType } : {}), }); }); return dedupeCompletionTables(tables); diff --git a/apps/desktop/src/lib/sql/semantic/diagnostics.ts b/apps/desktop/src/lib/sql/semantic/diagnostics.ts index 4308f44a9..f17ffed40 100644 --- a/apps/desktop/src/lib/sql/semantic/diagnostics.ts +++ b/apps/desktop/src/lib/sql/semantic/diagnostics.ts @@ -1,6 +1,7 @@ import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import { getSqlCompletionContext, isOracleSystemValueName } from "@/lib/sql/sqlCompletion"; import { executableStatementRanges, isOraclePlSqlStatement, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; +import { DBX_TDENGINE_TBNAME_COLUMN, isTdengineStableTableType } from "@/lib/table/tableEditing"; import type { DatabaseType, SqlColumnReference, SqlReferenceAnalysis, SqlReferenceScope, SqlTableReference, SqlTextSpan } from "@/types/database"; export interface SqlSemanticDiagnostic { @@ -45,6 +46,7 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche const tables = analysis.tables.filter((table) => table.name.trim()); const knownTables = new Map(); const scopesById = scopesByIdMap(analysis.scopes); + let tdengineStableTables: Set | undefined; for (const table of tables) { knownTables.set(normalizeName(table.name), table); @@ -73,6 +75,10 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche const columnNames = new Set(columns.map((item) => normalizeName(item.name))); if (columnNames.has(normalizeName(column.name))) continue; + if (schema.databaseType === "tdengine" && normalizeName(column.name) === DBX_TDENGINE_TBNAME_COLUMN) { + tdengineStableTables ??= tdengineStableTableKeys(schema.tables); + if (tdengineStableTables.has(tableReferenceKey(table))) continue; + } const displayName = column.qualifier ? `${column.qualifier}.${column.name}` : column.name; diagnostics.push({ @@ -85,6 +91,20 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche return diagnostics; } +function tdengineStableTableKeys(tables: readonly SqlCompletionTable[]): Set { + const keys = new Set(); + for (const table of tables) { + if (!isTdengineStableTableType(table.tableType)) continue; + keys.add(completionTableReferenceKey(table)); + } + return keys; +} + +function completionTableReferenceKey(table: Pick): string { + if (table.schema) return normalizeName(`${table.database ? `${table.database}.` : ""}${table.schema}.${table.name}`); + return normalizeName(table.name); +} + function isUnquotedOracleSystemValueReference(column: SqlColumnReference, schema: SqlSemanticDiagnosticSchema): boolean { if (column.qualifier || !isOracleSystemValueName(column.name, schema.databaseType)) return false; if (!schema.sql) return false; diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index e497e704a..4b0bb0bec 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1160,6 +1160,7 @@ export interface SqlCompletionTable { database?: string; schema?: string; type?: SqlObjectNavigationType; + tableType?: string; detail?: string; applyName?: string; boost?: number; diff --git a/apps/desktop/src/lib/table/tableEditing.ts b/apps/desktop/src/lib/table/tableEditing.ts index 456b91c63..375f75c8e 100644 --- a/apps/desktop/src/lib/table/tableEditing.ts +++ b/apps/desktop/src/lib/table/tableEditing.ts @@ -9,7 +9,7 @@ function isViewTableType(tableType?: string): boolean { return tableType?.toUpperCase().includes("VIEW") === true; } -function isTdengineStableTableType(tableType?: string): boolean { +export function isTdengineStableTableType(tableType?: string): boolean { const normalized = tableType?.trim().toUpperCase(); return normalized === "STABLE" || normalized === "SUPER TABLE" || normalized === "SUPERTABLE"; } diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index 8c6412beb..ae1a87fe4 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -96,6 +96,18 @@ function dorisConnection(): ConnectionConfig { } as ConnectionConfig; } +function tdengineConnection(): ConnectionConfig { + return { + ...postgresConnection(), + id: "tdengine-1", + name: "TDengine", + db_type: "tdengine", + port: 6041, + username: "root", + database: "issue_5685", + } as ConnectionConfig; +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { @@ -141,6 +153,31 @@ describe("connectionStore completion assistant", () => { expect(tables).toEqual([{ name: "users", schema: "public", type: "table" }]); }); + it("preserves TDengine stable type in completion metadata", async () => { + const listTables = vi.fn().mockResolvedValue([ + { name: "test_tb", table_type: "STABLE", comment: null }, + { name: "ordinary_table", table_type: "TABLE", comment: null }, + ]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + listTables, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [tdengineConnection()]; + store.connectedIds.add("tdengine-1"); + + const tables = await store.listCompletionTables("tdengine-1", "issue_5685"); + + expect(tables).toEqual([ + { name: "test_tb", type: "table", tableType: "STABLE" }, + { name: "ordinary_table", type: "table" }, + ]); + }); + it("deduplicates in-flight assistant table requests", async () => { const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [{ name: "accounts", kind: "table", schema: "public" }], diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 980a791d2..41bf6631e 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -125,6 +125,7 @@ import { RABBITMQ_MQ_TENANT, resolveMqSystemKindFromConnection } from "@/lib/mq/ import { applySidebarDatabaseStorage, applySidebarTableStorage, sidebarDatabaseNames, supportsSidebarDatabaseStorage, supportsSidebarTableStorage, type SidebarTableStorageScope } from "@/lib/sidebar/sidebarDatabaseStorage"; import { connectionHasConfiguredSidebarVisibleFilter, nacosVisibleNamespaceSummary, sidebarVisibleFilterSummary } from "@/lib/sidebar/sidebarVisibleFilterSummary"; import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu"; +import { isTdengineStableTableType } from "@/lib/table/tableEditing"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection"; @@ -1602,9 +1603,15 @@ export const useConnectionStore = defineStore("connection", () => { name: table.name, schema, type: sqlObjectNavigationTypeFromTableType(table.table_type), + ...completionStableTableType(table.table_type), })); } + function completionStableTableType(tableType: string | null | undefined): Partial> { + if (!tableType || !isTdengineStableTableType(tableType)) return {}; + return { tableType: tableType.trim() }; + } + function sameSidebarObjectName(left: string | undefined, right: string | undefined): boolean { return (left || "").toLowerCase() === (right || "").toLowerCase(); } @@ -5542,6 +5549,7 @@ export const useConnectionStore = defineStore("connection", () => { name: candidate.name, schema: candidate.schema ?? undefined, type: sqlObjectNavigationTypeFromTableType(candidate.data_type || candidate.kind), + ...completionStableTableType(candidate.data_type), }; if (!withOracleMetadata) return table; return { @@ -6022,6 +6030,7 @@ export const useConnectionStore = defineStore("connection", () => { catalog, schema, type: sqlObjectNavigationTypeFromTableType(table.table_type), + ...completionStableTableType(table.table_type), })); } else { results = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog); @@ -6042,6 +6051,7 @@ export const useConnectionStore = defineStore("connection", () => { catalog, schema, type: sqlObjectNavigationTypeFromTableType(table.table_type), + ...completionStableTableType(table.table_type), })); } catch { results = []; @@ -6064,6 +6074,7 @@ export const useConnectionStore = defineStore("connection", () => { catalog, schema, type: sqlObjectNavigationTypeFromTableType(table.table_type), + ...completionStableTableType(table.table_type), })); } else { completionTablesCache.value[cacheKey] = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog); @@ -6082,6 +6093,7 @@ export const useConnectionStore = defineStore("connection", () => { name: table.name, catalog, type: sqlObjectNavigationTypeFromTableType(table.table_type), + ...completionStableTableType(table.table_type), })); completionTablesCache.value[cacheKey] = limit ? completionTablesCache.value[cacheKey].slice(0, limit) : completionTablesCache.value[cacheKey]; indexCompletionTables(connectionId, database, schema, completionTablesCache.value[cacheKey], catalog); diff --git a/packages/app-tests/sqlSemanticDiagnostics.test.ts b/packages/app-tests/sqlSemanticDiagnostics.test.ts index 2cdd18c8f..fd5b3a64d 100644 --- a/packages/app-tests/sqlSemanticDiagnostics.test.ts +++ b/packages/app-tests/sqlSemanticDiagnostics.test.ts @@ -119,6 +119,81 @@ test("flags missing columns when loaded column metadata is empty", () => { ); }); +test.each(["STABLE", "SUPER TABLE", "SUPERTABLE"])("recognizes TDengine tbname for %s metadata", (tableType) => { + const analysis: SqlReferenceAnalysis = { + tables: [{ name: "test_tb", span: span(53, 59) }], + columns: [{ name: "tbname", span: span(8, 13) }], + }; + + const diagnostics = buildSqlSemanticDiagnostics(analysis, { + tables: [{ name: "test_tb", type: "table", tableType }], + columnsByTable: new Map([ + [ + "test_tb", + [ + { name: "ts", table: "test_tb" }, + { name: "reading", table: "test_tb" }, + { name: "device_id", table: "test_tb" }, + ], + ], + ]), + databaseType: "tdengine", + }); + + assert.deepEqual(diagnostics, []); +}); + +test("still flags tbname for ordinary TDengine tables", () => { + const analysis: SqlReferenceAnalysis = { + tables: [{ name: "ordinary_table", span: span(20, 33) }], + columns: [{ name: "tbname", span: span(8, 13) }], + }; + + const diagnostics = buildSqlSemanticDiagnostics(analysis, { + tables: [{ name: "ordinary_table", type: "table", tableType: "TABLE" }], + columnsByTable: new Map([["ordinary_table", [{ name: "ts", table: "ordinary_table" }]]]), + databaseType: "tdengine", + }); + + assert.deepEqual( + diagnostics.map((diagnostic) => diagnostic.message), + ["Unknown column tbname"], + ); +}); + +test("recognizes qualified TDengine stable tbname references", () => { + const analysis: SqlReferenceAnalysis = { + tables: [{ name: "test_tb", span: span(27, 33) }], + columns: [{ name: "tbname", qualifier: "test_tb", span: span(8, 21) }], + }; + + const diagnostics = buildSqlSemanticDiagnostics(analysis, { + tables: [{ name: "test_tb", type: "table", tableType: "STABLE" }], + columnsByTable: new Map([["test_tb", [{ name: "ts", table: "test_tb" }]]]), + databaseType: "tdengine", + }); + + assert.deepEqual(diagnostics, []); +}); + +test("does not treat tbname as virtual outside TDengine", () => { + const analysis: SqlReferenceAnalysis = { + tables: [{ name: "test_tb", span: span(20, 26) }], + columns: [{ name: "tbname", span: span(8, 13) }], + }; + + const diagnostics = buildSqlSemanticDiagnostics(analysis, { + tables: [{ name: "test_tb", type: "table", tableType: "STABLE" }], + columnsByTable: new Map([["test_tb", [{ name: "ts", table: "test_tb" }]]]), + databaseType: "postgres", + }); + + assert.deepEqual( + diagnostics.map((diagnostic) => diagnostic.message), + ["Unknown column tbname"], + ); +}); + test("flags where-clause columns missing from a single referenced table", () => { const analysis: SqlReferenceAnalysis = { tables: [{ name: "t_0001", span: span(15, 22) }],