fix(editor): refresh diagnostics after structure saves
This commit is contained in:
8 files changed
+139
-1
No files matched your search
@@ -2399,6 +2399,7 @@ onUnmounted(() => {
|
|||||||
connectionId: activeTab.connectionId,
|
connectionId: activeTab.connectionId,
|
||||||
database: activeTab.database,
|
database: activeTab.database,
|
||||||
schema: activeTab.schema,
|
schema: activeTab.schema,
|
||||||
|
catalog: activeTab.catalog,
|
||||||
tableName: activeTab.structureTableName || '',
|
tableName: activeTab.structureTableName || '',
|
||||||
},
|
},
|
||||||
commentChanged,
|
commentChanged,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
ensureConnected: vi.fn(),
|
ensureConnected: vi.fn(),
|
||||||
executeTabSql: vi.fn(),
|
executeTabSql: vi.fn(),
|
||||||
getColumns: vi.fn(),
|
getColumns: vi.fn(),
|
||||||
|
invalidateCompletionTableCache: vi.fn(),
|
||||||
listIndexes: vi.fn(),
|
listIndexes: vi.fn(),
|
||||||
setTableMeta: vi.fn(),
|
setTableMeta: vi.fn(),
|
||||||
updateSql: vi.fn(),
|
updateSql: vi.fn(),
|
||||||
@@ -27,6 +28,7 @@ vi.mock("@/stores/connectionStore", () => ({
|
|||||||
ensureConnected: mocks.ensureConnected,
|
ensureConnected: mocks.ensureConnected,
|
||||||
connectionIdentifierQuote: () => undefined,
|
connectionIdentifierQuote: () => undefined,
|
||||||
refreshObjectListTreeNode: vi.fn(),
|
refreshObjectListTreeNode: vi.fn(),
|
||||||
|
invalidateCompletionTableCache: mocks.invalidateCompletionTableCache,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -142,9 +144,11 @@ describe("useNavigationTargets openTableTarget", () => {
|
|||||||
connectionId: target.connectionId,
|
connectionId: target.connectionId,
|
||||||
database: target.database,
|
database: target.database,
|
||||||
schema: target.schema,
|
schema: target.schema,
|
||||||
|
catalog: target.catalog,
|
||||||
tableName: target.tableName,
|
tableName: target.tableName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(mocks.invalidateCompletionTableCache).toHaveBeenCalledWith("connection-1", "app", "users", "public", "catalog-1");
|
||||||
expect(mocks.getColumns).toHaveBeenCalledTimes(2);
|
expect(mocks.getColumns).toHaveBeenCalledTimes(2);
|
||||||
expect(mocks.tabs.map((tab) => tab.tableMeta?.primaryKeys)).toEqual([["fresh_id"], ["fresh_id"]]);
|
expect(mocks.tabs.map((tab) => tab.tableMeta?.primaryKeys)).toEqual([["fresh_id"], ["fresh_id"]]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
ensureConnected: vi.fn(),
|
ensureConnected: vi.fn(),
|
||||||
connectionIdentifierQuote: vi.fn(() => undefined),
|
connectionIdentifierQuote: vi.fn(() => undefined),
|
||||||
refreshObjectListTreeNode: vi.fn(),
|
refreshObjectListTreeNode: vi.fn(),
|
||||||
|
invalidateCompletionTableCache: vi.fn(),
|
||||||
},
|
},
|
||||||
settingsStore: {
|
settingsStore: {
|
||||||
editorSettings: {
|
editorSettings: {
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value:
|
|||||||
await openTableTarget(target);
|
await openTableTarget(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onStructureEditorSaved(reloadData: () => Promise<void>, toast: (msg: string, duration?: number) => void, context: { connectionId: string; database: string; schema?: string; tableName: string }, commentChanged?: boolean) {
|
async function onStructureEditorSaved(reloadData: () => Promise<void>, toast: (msg: string, duration?: number) => void, context: { connectionId: string; database: string; schema?: string; catalog?: string; tableName: string }, commentChanged?: boolean) {
|
||||||
if (!context.tableName) {
|
if (!context.tableName) {
|
||||||
try {
|
try {
|
||||||
await connectionStore.refreshObjectListTreeNode(context.connectionId, context.database, context.schema || undefined);
|
await connectionStore.refreshObjectListTreeNode(context.connectionId, context.database, context.schema || undefined);
|
||||||
@@ -309,6 +309,7 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value:
|
|||||||
await connectionStore.refreshObjectListTreeNode(context.connectionId, context.database, context.schema || undefined);
|
await connectionStore.refreshObjectListTreeNode(context.connectionId, context.database, context.schema || undefined);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
connectionStore.invalidateCompletionTableCache(context.connectionId, context.database, context.tableName, context.schema, context.catalog);
|
||||||
queryStore.invalidateTableStructure(context.connectionId, context.database, context.schema, context.tableName);
|
queryStore.invalidateTableStructure(context.connectionId, context.database, context.schema, context.tableName);
|
||||||
// 结构已变更:无论是否有打开的 data tab 都必须作废共享元数据缓存,否则
|
// 结构已变更:无论是否有打开的 data tab 都必须作废共享元数据缓存,否则
|
||||||
// 其它 loadTableMetadata 消费者最长 30 秒拿到旧列。不带 schema/catalog
|
// 其它 loadTableMetadata 消费者最长 30 秒拿到旧列。不带 schema/catalog
|
||||||
|
|||||||
@@ -391,6 +391,78 @@ describe("connectionStore completion assistant", () => {
|
|||||||
expect(quoted).toEqual([expect.objectContaining({ name: "MixedColumn", table: "MixedTable", schema: "MixedSchema" })]);
|
expect(quoted).toEqual([expect.objectContaining({ name: "MixedColumn", table: "MixedTable", schema: "MixedSchema" })]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("invalidates only the changed table completion metadata", async () => {
|
||||||
|
const getColumns = vi.fn(async (_connectionId: string, _database: string, _schema: string, table: string) => [
|
||||||
|
{
|
||||||
|
name: `${table}_column_${getColumns.mock.calls.length}`,
|
||||||
|
data_type: "integer",
|
||||||
|
is_nullable: false,
|
||||||
|
column_default: null,
|
||||||
|
is_primary_key: false,
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||||
|
vi.doMock("@/lib/backend/api", () => ({
|
||||||
|
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||||
|
completionAssistantSearch: vi.fn().mockRejectedValue(new Error("assistant unavailable")),
|
||||||
|
getColumns,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||||
|
const store = useConnectionStore();
|
||||||
|
store.connections = [sqlServerConnection()];
|
||||||
|
store.connectedIds.add("sqlserver-1");
|
||||||
|
|
||||||
|
await store.listCompletionColumns("sqlserver-1", "app", "users", "dbo");
|
||||||
|
await store.listCompletionColumns("sqlserver-1", "app", "orders", "dbo");
|
||||||
|
await store.listCompletionColumns("sqlserver-1", "app", "users", "dbo");
|
||||||
|
await store.listCompletionColumns("sqlserver-1", "app", "orders", "dbo");
|
||||||
|
expect(getColumns.mock.calls.map((call) => call[3])).toEqual(["users", "orders"]);
|
||||||
|
|
||||||
|
expect(store.invalidateCompletionTableCache("sqlserver-1", "app", "users", "dbo")).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await store.listCompletionColumns("sqlserver-1", "app", "users", "dbo");
|
||||||
|
await store.listCompletionColumns("sqlserver-1", "app", "orders", "dbo");
|
||||||
|
expect(getColumns.mock.calls.map((call) => call[3])).toEqual(["users", "orders", "users"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the same table cached in other catalogs", async () => {
|
||||||
|
const getColumns = vi.fn(async (_connectionId: string, _database: string, _schema: string, table: string, catalog?: string) => [
|
||||||
|
{
|
||||||
|
name: `${catalog}_${table}`,
|
||||||
|
data_type: "integer",
|
||||||
|
is_nullable: false,
|
||||||
|
column_default: null,
|
||||||
|
is_primary_key: false,
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||||
|
vi.doMock("@/lib/backend/api", () => ({
|
||||||
|
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||||
|
completionAssistantSearch: vi.fn(),
|
||||||
|
getColumns,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||||
|
const store = useConnectionStore();
|
||||||
|
store.connections = [dorisConnection()];
|
||||||
|
store.connectedIds.add("doris-1");
|
||||||
|
|
||||||
|
await store.listCompletionColumns("doris-1", "sales", "users", undefined, undefined, "internal");
|
||||||
|
await store.listCompletionColumns("doris-1", "sales", "users", undefined, undefined, "hive");
|
||||||
|
expect(getColumns.mock.calls.map((call) => call[4])).toEqual(["internal", "hive"]);
|
||||||
|
|
||||||
|
expect(store.invalidateCompletionTableCache("doris-1", "sales", "users", undefined, "internal")).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await store.listCompletionColumns("doris-1", "sales", "users", undefined, undefined, "internal");
|
||||||
|
await store.listCompletionColumns("doris-1", "sales", "users", undefined, undefined, "hive");
|
||||||
|
expect(getColumns.mock.calls.map((call) => call[4])).toEqual(["internal", "hive", "internal"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps quoted and unquoted Oracle objects separate in the local column index", async () => {
|
it("keeps quoted and unquoted Oracle objects separate in the local column index", async () => {
|
||||||
const completionAssistantSearch = vi.fn(async (request: { parent_name?: string | null }) => ({
|
const completionAssistantSearch = vi.fn(async (request: { parent_name?: string | null }) => ({
|
||||||
candidates: [
|
candidates: [
|
||||||
|
|||||||
@@ -5293,6 +5293,46 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
return `${completionScopeKey(connectionId, database, schema)}:${table.toLowerCase()}:fkeys`;
|
return `${completionScopeKey(connectionId, database, schema)}:${table.toLowerCase()}:fkeys`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function completionTableCacheKeyMatches(key: string, connectionId: string, database: string, tableName: string, schema?: string, catalog?: string): boolean {
|
||||||
|
const normalizedKey = key.toLowerCase();
|
||||||
|
const prefix = `${connectionId}:${database}:`.toLowerCase();
|
||||||
|
if (!normalizedKey.startsWith(prefix)) return false;
|
||||||
|
const tableToken = `:${tableName.toLowerCase()}`;
|
||||||
|
const tableOffset = normalizedKey.lastIndexOf(tableToken);
|
||||||
|
if (tableOffset < prefix.length) return false;
|
||||||
|
const trailing = normalizedKey.slice(tableOffset + tableToken.length);
|
||||||
|
if (trailing && !trailing.startsWith(":")) return false;
|
||||||
|
const normalizedSchema = schema?.trim().toLowerCase();
|
||||||
|
const normalizedCatalog = catalog?.trim().toLowerCase();
|
||||||
|
const scope = normalizedKey.slice(prefix.length, tableOffset);
|
||||||
|
if (normalizedCatalog) {
|
||||||
|
const catalogScope = `${normalizedCatalog}:${normalizedSchema ?? ""}`;
|
||||||
|
return scope === catalogScope || (!!normalizedSchema && scope === normalizedSchema);
|
||||||
|
}
|
||||||
|
if (!normalizedSchema) return true;
|
||||||
|
return scope === normalizedSchema || scope.endsWith(`:${normalizedSchema}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateCompletionTableCache(connectionId: string, database: string, tableName: string, schema?: string, catalog?: string): number {
|
||||||
|
const matches = (key: string) => completionTableCacheKeyMatches(key, connectionId, database, tableName, schema, catalog);
|
||||||
|
let removed = 0;
|
||||||
|
for (const cache of [completionColumnsCache.value, completionForeignKeysCache.value]) {
|
||||||
|
for (const key of Object.keys(cache)) {
|
||||||
|
if (!matches(key)) continue;
|
||||||
|
delete cache[key];
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const cache of [completionColumnIndex, completionForeignKeyIndex, completionInFlight]) {
|
||||||
|
for (const key of cache.keys()) {
|
||||||
|
if (!matches(key)) continue;
|
||||||
|
cache.delete(key);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
function touchCompletionIndex<T>(index: Map<string, { touched: number } & T>, key: string, value: T, max = COMPLETION_CACHE_MAX) {
|
function touchCompletionIndex<T>(index: Map<string, { touched: number } & T>, key: string, value: T, max = COMPLETION_CACHE_MAX) {
|
||||||
index.set(key, { ...value, touched: Date.now() });
|
index.set(key, { ...value, touched: Date.now() });
|
||||||
if (index.size <= max) return;
|
if (index.size <= max) return;
|
||||||
@@ -6854,6 +6894,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
listMongoCompletionCollections,
|
listMongoCompletionCollections,
|
||||||
listMongoCompletionFields,
|
listMongoCompletionFields,
|
||||||
invalidateCompletionCache,
|
invalidateCompletionCache,
|
||||||
|
invalidateCompletionTableCache,
|
||||||
invalidateMetadataCache,
|
invalidateMetadataCache,
|
||||||
exportConnectionsToFile,
|
exportConnectionsToFile,
|
||||||
readImportFile,
|
readImportFile,
|
||||||
|
|||||||
@@ -668,6 +668,10 @@ export const useQueryStore = defineStore("query", () => {
|
|||||||
...tableStructureRefreshVersions.value,
|
...tableStructureRefreshVersions.value,
|
||||||
[key]: (tableStructureRefreshVersions.value[key] ?? 0) + 1,
|
[key]: (tableStructureRefreshVersions.value[key] ?? 0) + 1,
|
||||||
};
|
};
|
||||||
|
for (const tab of tabs.value) {
|
||||||
|
if (tab.mode !== "query" || tab.connectionId !== connectionId || tab.database !== database) continue;
|
||||||
|
tab.completionContextVersion = (tab.completionContextVersion ?? 0) + 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function tableStructureRefreshVersion(connectionId: string, database: string, schema: string | undefined, tableName: string): number {
|
function tableStructureRefreshVersion(connectionId: string, database: string, schema: string | undefined, tableName: string): number {
|
||||||
|
|||||||
+14
@@ -6940,6 +6940,20 @@ test("table structure refresh versions are scoped by table target", () => {
|
|||||||
assert.equal(store.tableStructureRefreshVersion("conn-1", "db", "public", "orders"), 0);
|
assert.equal(store.tableStructureRefreshVersion("conn-1", "db", "public", "orders"), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("table structure invalidation refreshes matching query completion contexts", () => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
const store = useQueryStore();
|
||||||
|
const matchingTabId = store.createTab("conn-1", "db", "Query A", "query", "public");
|
||||||
|
const sameDatabaseTabId = store.createTab("conn-1", "db", "Query B", "query", "audit");
|
||||||
|
const otherDatabaseTabId = store.createTab("conn-1", "analytics", "Query C", "query", "public");
|
||||||
|
|
||||||
|
store.invalidateTableStructure("conn-1", "db", "public", "users");
|
||||||
|
|
||||||
|
assert.equal(store.tabs.find((tab) => tab.id === matchingTabId)?.completionContextVersion, 1);
|
||||||
|
assert.equal(store.tabs.find((tab) => tab.id === sameDatabaseTabId)?.completionContextVersion, 1);
|
||||||
|
assert.equal(store.tabs.find((tab) => tab.id === otherDatabaseTabId)?.completionContextVersion, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test("duplicating a table structure tab clones its unsaved draft", () => {
|
test("duplicating a table structure tab clones its unsaved draft", () => {
|
||||||
setActivePinia(createPinia());
|
setActivePinia(createPinia());
|
||||||
const store = useQueryStore();
|
const store = useQueryStore();
|
||||||
|
|||||||
Reference in new issue
Block a user