feat(sidebar): auto-load all tables per connection when expanded

Closes #9834
This commit is contained in:
t8y2
2026-09-27 09:38:55 +00:00
parent 725b1ee252
commit 5ba0d435f0
44 changed files with 332 additions and 20 deletions
@@ -148,7 +148,7 @@ import {
} from "@lucide/vue";
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleObjectFiltersNeedReset } from "@/lib/connection/connectionVisibleDatabases";
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, filterSchemaNamesForVisiblePicker, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
import { isSchemaAware, isSingleDatabase, supportsDataDictionary } from "@/lib/database/databaseFeatureSupport";
import { normalizeConnectionScope, normalizeConnectionTimeouts } from "@/lib/connection/connectionSubmitNormalization";
import { databaseConnectionFormKind } from "@/lib/database/databaseDriverManifest";
import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
@@ -231,6 +231,7 @@ const DREMIO_ARROW_FLIGHT_SQL_JDBC_DRIVER_CLASS = "org.apache.arrow.driver.jdbc.
const DREMIO_LEGACY_JDBC_URL = "jdbc:dremio:direct=127.0.0.1:31010";
const DREMIO_LEGACY_JDBC_DRIVER_CLASS = "com.dremio.jdbc.Driver";
const DEFAULT_SSH_USER = "root";
const DIRECT_SIDEBAR_OBJECT_TYPES = new Set<DatabaseType>(["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "milvus", "qdrant", "weaviate", "chromadb", "mq", "mqtt", "nacos", "plugin"]);
const ETCD_GRPC_MAX_INBOUND_DEFAULT_MIB = 32;
const ETCD_GRPC_MAX_INBOUND_MIN_MIB = 1;
const ETCD_GRPC_MAX_INBOUND_MAX_MIB = 256;
@@ -441,6 +442,7 @@ const defaultForm = (): ConnectionForm => ({
docs_notes_path: undefined,
read_only: false,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
is_production: false,
production_databases: [],
visible_databases: undefined,
@@ -632,6 +634,7 @@ function sshLayersForConfig(config: LegacyConnectionConfig): SshTunnelConfig[] {
}
const form = ref(defaultForm());
const supportsAutomaticTableLoading = computed(() => supportsDataDictionary(form.value.db_type) && !DIRECT_SIDEBAR_OBJECT_TYPES.has(form.value.db_type));
const redisKeyTemplatesText = ref("");
const noteTextareaRef = ref<HTMLTextAreaElement | null>(null);
const showGaussdbConnectionMode = computed(() => form.value.db_type === "gaussdb");
@@ -3110,6 +3113,7 @@ watch(
docs_notes_path: config.docs_notes_path,
read_only: config.read_only || false,
show_system_schemas: config.show_system_schemas || false,
sidebar_auto_load_all_tables: config.sidebar_auto_load_all_tables === true,
is_production: config.is_production || false,
production_databases: config.production_databases || [],
visible_databases: config.visible_databases,
@@ -5141,6 +5145,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
config.visible_databases = Array.isArray(config.visible_databases) && config.visible_databases.length > 0 ? config.visible_databases : undefined;
}
if (!config.show_system_schemas) config.show_system_schemas = undefined;
if (!config.sidebar_auto_load_all_tables) config.sidebar_auto_load_all_tables = undefined;
if (config.visible_schemas && Object.keys(config.visible_schemas).length === 0) config.visible_schemas = undefined;
if (config.agent_java_options && config.agent_java_options.length === 0) config.agent_java_options = undefined;
// Pasted credentials may carry invisible characters that trim() keeps (#9043).
@@ -9878,6 +9883,16 @@ function openExternalUrl(url: string) {
<span class="text-xs text-muted-foreground">{{ t("connection.showSystemSchemasHint") }}</span>
</label>
</div>
<div v-if="supportsAutomaticTableLoading" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.tableLoading") }}</Label>
<div class="col-span-3 grid gap-1.5">
<label class="flex cursor-pointer items-center gap-2">
<Switch v-model="form.sidebar_auto_load_all_tables" />
<span class="text-sm font-medium">{{ t("connection.autoLoadAllTables") }}</span>
</label>
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.autoLoadAllTablesHint") }}</p>
</div>
</div>
<!-- Documentation notes are a relational-only feature, so this
follows the same isSchemaAware gate as the row above. -->
<div v-if="isSchemaAware(form.db_type)" class="grid grid-cols-4 items-start gap-4">
@@ -234,7 +234,7 @@ describe("ConnectionDialog deep-link edit confirmation", () => {
});
it("updates the existing ID only after Save and preserves omitted fields", async () => {
await mountDialog(savedConnection(), "dbx://connection/new?id=saved-production&password=new-password");
await mountDialog(savedConnection({ sidebar_auto_load_all_tables: true }), "dbx://connection/new?id=saved-production&password=new-password");
const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save");
expect(save).toBeTruthy();
save!.click();
@@ -255,6 +255,7 @@ describe("ConnectionDialog deep-link edit confirmation", () => {
is_production: true,
production_databases: ["orders"],
visible_databases: ["orders"],
sidebar_auto_load_all_tables: true,
url_params: "connectTimeout=25",
}),
);
@@ -87,7 +87,18 @@ import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTr
import { sidebarConnectionVisibleFilterMenu } from "@/lib/sidebar/sidebarVisibleFilterMenu";
import { supportsSidebarObjectNameFilter } from "@/lib/sidebar/sidebarObjectNameFilter";
import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout";
import { hasTableVGroupEntries, isTableVGroupContainerNode, isTableVGroupGroupableRowType, resolveTableVGroupScopeFromNode, selectedTableVGroupMoveTargets, tableVGroupDestinationRows, tableVGroupPathForTable, tableVGroupScopeKey, tableVGroupsEnabled } from "@/lib/table/tableVGroup";
import {
hasTableTreeLoadMore,
hasTableVGroupEntries,
isTableVGroupContainerNode,
isTableVGroupGroupableRowType,
resolveTableVGroupScopeFromNode,
selectedTableVGroupMoveTargets,
tableVGroupDestinationRows,
tableVGroupPathForTable,
tableVGroupScopeKey,
tableVGroupsEnabled,
} from "@/lib/table/tableVGroup";
import { objectTypesForGroupNode } from "@/lib/table/tableTree";
import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting";
import { requestObjectBrowserSearchFocus } from "@/lib/tabs/objectBrowserSearchFocus";
@@ -883,7 +894,8 @@ async function toggle(requestId = beginNavigationRequest()) {
// particular, schema-level trigger/type groups have no tableName, so they
// must use the generic object loader rather than the table-trigger loader.
const databaseObjectGroup = !!objectTypesForGroupNode(node.type);
if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node)) {
const needsConfiguredTablePageDrain = node.type === "group-tables" && connectionStore.getConfig(node.connectionId || "")?.sidebar_auto_load_all_tables === true && hasTableTreeLoadMore(node.children ?? []);
if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node) && !needsConfiguredTablePageDrain) {
node.isExpanded = !node.isExpanded;
if (wasExpanded && shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded);
@@ -259,6 +259,42 @@ describe("SidebarTreeRuntimeHost expansion", () => {
expect(connectionStore.loadObjectGroupChildren).not.toHaveBeenCalled();
});
it("routes an opted-in partial table group back through the loader", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "mysql",
database: "basic",
isExpanded: false,
children: [
{ id: "mysql:basic:orders", label: "orders", type: "table", connectionId: "mysql", database: "basic" },
{
id: "mysql:basic:__tables:__load_more:1000",
label: "tree.loadMore",
type: "load-more",
connectionId: "mysql",
database: "basic",
loadMore: { parentId: "mysql:basic:__tables", offset: 1000, pageSize: 1000 },
},
],
};
connectionStore.getConfig.mockReturnValue({ db_type: "mysql", name: "connection", sidebar_auto_load_all_tables: true });
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(defineComponent({ setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: group, depth: 0 }) }));
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
host.value?.toggleNode(group);
await vi.waitFor(() => expect(connectionStore.loadObjectGroupChildren).toHaveBeenCalledWith(group, undefined));
expect(group.isExpanded).toBe(true);
});
it("publishes a rendered group collapse and synchronizes the live tree", async () => {
const liveGroup: TreeNode = {
id: "connection:database:__tables",
+3
View File
@@ -1615,6 +1615,9 @@ export default {
doltShowSystemTablesHint: "Show Dolt's built-in version-control tables in table lists for this connection.",
showSystemSchemas: "Show System Schemas",
showSystemSchemasHint: "Show built-in and metadata schemas in the sidebar and schema pickers for this connection.",
tableLoading: "Table loading",
autoLoadAllTables: "Load all tables automatically",
autoLoadAllTablesHint: "In Advanced sidebar view, opening a Tables group loads every page for this connection so table-name filtering covers all tables. Large schemas may take longer.",
docsNotesPath: "Notes file",
docsNotesPathPlaceholder: "docs/dbx-docs.json",
docsNotesPathHint: "Where documentation notes are stored. Leave empty to keep them in the app data directory, or point this at a file in your repository to review schema documentation in pull requests.",
+3
View File
@@ -1532,6 +1532,9 @@ export default withEnglishFallback({
doltShowSystemTablesHint: "在当前连接的表列表中显示 Dolt 内置的版本控制表。",
showSystemSchemas: "显示系统 Schema",
showSystemSchemasHint: "为当前连接在侧边栏和 Schema 选择器中显示内置/元数据 Schema。",
tableLoading: "表加载策略",
autoLoadAllTables: "自动加载全部表",
autoLoadAllTablesHint: "使用侧边栏高级视图时,展开“表”分组会自动加载该连接的全部分页,使表名筛选覆盖所有表。表很多时可能需要更长时间。",
docsNotesPath: "笔记文件",
docsNotesPathPlaceholder: "docs/dbx-docs.json",
docsNotesPathHint: "文档笔记的存储位置。留空则保存在应用数据目录中;指向仓库中的文件后,即可在 Pull Request 中审阅 Schema 文档。",
@@ -65,7 +65,7 @@ describe("connectionDatabaseInfo", () => {
});
});
it("fingerprints the complete submitted config without depending on object key order", () => {
it("fingerprints connection-affecting config without depending on object key order", () => {
const original = config({ transport_layers: [{ id: "ssh", type: "ssh", host: "jump", port: 22, user: "root", password: "hop-secret" }] });
const reordered = Object.fromEntries(Object.entries(original).reverse()) as unknown as ConnectionConfig;
expect(connectionConfigFingerprint(reordered)).toBe(connectionConfigFingerprint(original));
@@ -76,6 +76,7 @@ describe("connectionDatabaseInfo", () => {
expect(connectionConfigFingerprint({ ...original, database_info: { productName: "MySQL", productVersion: "8.4.0" } })).toBe(connectionConfigFingerprint(original));
expect(connectionConfigFingerprint({ ...original, note: "Production reporting" })).toBe(connectionConfigFingerprint(original));
expect(connectionConfigFingerprint({ ...original, default_schema: "archive" })).toBe(connectionConfigFingerprint(original));
expect(connectionConfigFingerprint({ ...original, sidebar_auto_load_all_tables: true })).toBe(connectionConfigFingerprint(original));
});
it("formats only database metadata for rows and copied text", () => {
@@ -83,6 +83,16 @@ describe("connectionConfigTransfer", () => {
});
});
it("preserves the per-connection automatic table loading opt-in", () => {
const source = conn("large-schema", "Large schema", { sidebar_auto_load_all_tables: true });
const serialized = JSON.parse(JSON.stringify(buildConnectionConfigBundle([source], null, [])));
const parsed = parseConnectionConfigObject(serialized);
const imported = prepareConnectionConfigImport(parsed, [], [], () => "imported-id");
expect(parsed.connections[0].sidebar_auto_load_all_tables).toBe(true);
expect(imported.connections[0]).toMatchObject({ id: "imported-id", sidebar_auto_load_all_tables: true });
});
it("keeps stable ids and removes secrets from plaintext exports", () => {
const exported = scrubConnectionForPlaintextExport(
conn("stable", "A", {
@@ -94,7 +94,7 @@ function fnv1a(value: string, seed: number): number {
}
export function connectionConfigFingerprint(config: ConnectionConfig, sourceName = config.name): string {
const { database_info: _databaseInfo, default_schema: _defaultSchema, note: _note, ...submittedConfig } = config;
const { database_info: _databaseInfo, default_schema: _defaultSchema, note: _note, sidebar_auto_load_all_tables: _sidebarAutoLoadAllTables, ...submittedConfig } = config;
const serialized = JSON.stringify(stableValue({ config: submittedConfig, sourceName }));
const first = fnv1a(serialized, 0x811c9dc5).toString(16).padStart(8, "0");
const second = fnv1a(serialized, 0x9e3779b9).toString(16).padStart(8, "0");
@@ -1,6 +1,7 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout";
import { filterLocallySearchedTables } from "@/lib/sidebar/sidebarSearchTree";
import type { ConnectionConfig, ObjectInfo, TableInfo, TreeNode } from "@/types/database";
function installLocalStorage() {
@@ -3170,6 +3171,129 @@ describe("connectionStore metadata loading", () => {
expect(tablesGroup.children?.some((child) => child.label?.startsWith("fresh_"))).toBe(true);
});
it("automatically drains only opted-in table groups", async () => {
const rows = Array.from({ length: 5 }, (_, index) => ({
name: `object_${index + 1}`,
table_type: "TABLE" as const,
comment: null,
}));
const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, searchFilter?: string, limit?: number, offset?: number, objectTypes?: string[]) => {
const tableType = objectTypes?.includes("VIEW") ? "VIEW" : "TABLE";
const matches = searchFilter ? rows.filter((row) => row.name.includes(searchFilter)) : rows;
return Promise.resolve(matches.slice(offset ?? 0, (offset ?? 0) + (limit ?? matches.length)).map((row) => ({ ...row, table_type: tableType })));
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listTables,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
const settingsStore = useSettingsStore();
settingsStore.editorSettings.sidebarObjectDisplay = "grouped";
settingsStore.desktopSettings.sidebar_table_page_size = 2;
const connection = { ...mysqlConnection(), sidebar_auto_load_all_tables: true };
const tablesGroup: TreeNode = {
id: `${connection.id}:app:__tables`,
label: "tree.tables",
type: "group-tables",
connectionId: connection.id,
database: "app",
isExpanded: false,
children: [],
};
const viewsGroup: TreeNode = {
...tablesGroup,
id: `${connection.id}:app:__views`,
label: "tree.views",
type: "group-views",
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [tablesGroup, viewsGroup] }];
await store.loadObjectGroupChildren(tablesGroup);
expect(listTables.mock.calls.map((call) => call[5])).toEqual([0, 2, 4]);
expect(tablesGroup.children?.map((child) => child.label)).toEqual(rows.map((row) => row.name));
expect(tablesGroup.children?.some((child) => child.type === "load-more")).toBe(false);
const filteredTree = filterLocallySearchedTables(store.treeNodes, {
enabled: true,
queries: { [tablesGroup.id]: "object_5" },
indexedResults: {},
});
expect(filteredTree[0].children?.[0]?.children?.map((child) => child.label)).toEqual(["object_5"]);
await store.loadObjectGroupChildren(viewsGroup);
expect(listTables).toHaveBeenCalledTimes(4);
expect(viewsGroup.children?.map((child) => child.label)).toEqual(["object_1", "object_2", "tree.loadMore"]);
const searchedTablesGroup: TreeNode = { ...tablesGroup, id: `${connection.id}:search:__tables`, database: "search", children: [] };
store.treeNodes[0].children!.push(searchedTablesGroup);
store.sidebarSearchQuery = "object_5";
await store.loadObjectGroupChildren(searchedTablesGroup, { force: true });
expect(listTables).toHaveBeenCalledTimes(5);
expect(listTables.mock.calls.at(-1)?.slice(3, 6)).toEqual(["object_5", SIDEBAR_SEARCH_RESULT_BUDGET, undefined]);
expect(searchedTablesGroup.children?.map((child) => child.label)).toEqual(["object_5"]);
});
it("keeps legacy connections on the first table page", async () => {
const rows = Array.from({ length: 3 }, (_, index) => ({
name: `table_${index + 1}`,
table_type: "TABLE" as const,
comment: null,
}));
const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, _searchFilter?: string, limit?: number, offset?: number) => Promise.resolve(rows.slice(offset ?? 0, (offset ?? 0) + (limit ?? rows.length))));
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listTables,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
const settingsStore = useSettingsStore();
settingsStore.editorSettings.sidebarObjectDisplay = "grouped";
settingsStore.desktopSettings.sidebar_table_page_size = 2;
const connection = mysqlConnection();
const tablesGroup: TreeNode = {
id: `${connection.id}:app:__tables`,
label: "tree.tables",
type: "group-tables",
connectionId: connection.id,
database: "app",
isExpanded: false,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [tablesGroup] }];
await store.loadObjectGroupChildren(tablesGroup);
expect(listTables).toHaveBeenCalledTimes(1);
expect(tablesGroup.children?.map((child) => child.label)).toEqual(["table_1", "table_2", "tree.loadMore"]);
});
it("restores and drains unfiltered pages when expand-all races a search clear reload", async () => {
const tables = Array.from({ length: 5 }, (_, index) => ({
name: `t_${String(index + 1).padStart(4, "0")}`,
+25 -6
View File
@@ -1721,6 +1721,7 @@ export const useConnectionStore = defineStore("connection", () => {
docs_notes_path: config.docs_notes_path?.trim() ? config.docs_notes_path.trim() : undefined,
transport_layers: Array.isArray(config.transport_layers) ? config.transport_layers : [],
show_system_schemas: config.show_system_schemas === true,
sidebar_auto_load_all_tables: config.sidebar_auto_load_all_tables === true,
connect_timeout_secs: connectTimeoutInherit ? settingsStore.editorSettings.globalConnectTimeoutSecs : config.connect_timeout_secs || 10,
connect_timeout_inherit: connectTimeoutInherit,
query_timeout_secs: queryTimeoutInherit ? settingsStore.editorSettings.globalQueryTimeoutSecs : (config.query_timeout_secs ?? DEFAULT_QUERY_TIMEOUT_SECS),
@@ -6471,9 +6472,9 @@ export const useConnectionStore = defineStore("connection", () => {
);
}
async function loadObjectGroupChildren(node: TreeNode, options?: LoadTreeOptions): Promise<void> {
async function loadObjectGroupFirstPage(node: TreeNode, options?: LoadTreeOptions): Promise<void> {
if (options?.sidebarSearch) {
return withSidebarSearchLoad(node.connectionId, () => loadObjectGroupChildren(node, { ...options, sidebarSearch: false }));
return withSidebarSearchLoad(node.connectionId, () => loadObjectGroupFirstPage(node, { ...options, sidebarSearch: false }));
}
// Queued search/refresh tasks can outlive disconnect, which removes their nodes.
if (!treeNodeInSidebarTree(node)) return;
@@ -6501,7 +6502,7 @@ export const useConnectionStore = defineStore("connection", () => {
if (!options?.force && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) {
if (await hydrateTreeNodeFromCache(node, objectGroupCacheKey(node))) {
forgetFilteredObjectGroupChildren(node.id);
void loadObjectGroupChildren(node, { ...options, force: true }).catch(() => undefined);
void loadObjectGroupFirstPage(node, { ...options, force: true }).catch(() => undefined);
return;
}
}
@@ -6624,6 +6625,23 @@ export const useConnectionStore = defineStore("connection", () => {
);
}
function shouldAutoLoadAllTableGroupChildren(node: TreeNode, options?: LoadTreeOptions): boolean {
if (node.type !== "group-tables" || !node.connectionId) return false;
if (getConfig(node.connectionId)?.sidebar_auto_load_all_tables !== true) return false;
// Search projections have their own bounded/paged lifecycle. Automatically
// draining those pages would turn a filter operation into an unbounded load
// and could replace the unfiltered snapshot that clearing search restores.
return !options?.sidebarSearch && !options?.sidebarTableSearchParentId && !activeTreeLoadSearchFilter(options);
}
async function loadObjectGroupChildren(node: TreeNode, options?: LoadTreeOptions): Promise<void> {
if (shouldAutoLoadAllTableGroupChildren(node, options)) {
await loadAllObjectGroupChildren(node, options);
return;
}
await loadObjectGroupFirstPage(node, options);
}
async function loadCustomTypeChildren(node: TreeNode, options?: LoadTreeOptions) {
if (node.type !== "type" || !node.connectionId || !hasTreeNodeDatabaseContext(node)) return;
let load = beginTreeNodeLoad(node);
@@ -6971,14 +6989,15 @@ export const useConnectionStore = defineStore("connection", () => {
);
}
async function loadAllObjectGroupChildren(parent: TreeNode) {
async function loadAllObjectGroupChildren(parent: TreeNode, initialLoadOptions?: LoadTreeOptions) {
if (!parent.connectionId || !hasTreeNodeDatabaseContext(parent)) return;
if (!objectTypesForGroupNode(parent.type)) return;
const liveParent = treeNodeInSidebarTree(parent);
if (!liveParent) return;
if (!isTreeNodeChildrenLoaded(liveParent.id) || filteredObjectGroupChildrenIds.has(liveParent.id)) {
if (initialLoadOptions?.force || !isTreeNodeChildrenLoaded(liveParent.id) || filteredObjectGroupChildrenIds.has(liveParent.id)) {
let isPreparedPageCurrent: (() => boolean) | undefined;
await loadObjectGroupChildren(liveParent, {
await loadObjectGroupFirstPage(liveParent, {
...initialLoadOptions,
force: true,
searchFilter: "",
allowGlobalSearchMismatch: true,
+2
View File
@@ -105,6 +105,8 @@ export interface ConnectionConfig {
visible_database_patterns?: string[];
visible_schemas?: Record<string, string[]>;
show_system_schemas?: boolean;
/** Load every page when the sidebar's Tables group is opened for this connection. */
sidebar_auto_load_all_tables?: boolean;
attached_databases?: AttachedDatabaseConfig[];
init_script?: string;
color?: string;
@@ -173,6 +173,7 @@ fn connection_config(id: &str, database: BenchDatabase) -> Result<ConnectionConf
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
+1
View File
@@ -198,6 +198,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
+1
View File
@@ -940,6 +940,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -385,6 +385,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -661,6 +661,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -743,6 +744,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
+1
View File
@@ -1657,6 +1657,7 @@ for line in sys.stdin:
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -1049,6 +1049,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
+24 -8
View File
@@ -6358,22 +6358,26 @@ fn session_scoped_pool_key_for(
/// 两个运行态连接配置是否视为同一连接(用于决定是否销毁连接池)。
///
/// 仅当双方都是 `save_password=false` 时才忽略 `password` 字段的差异:这类连接
/// 在 connect 时运行态配置可能携带会话密码,持久化同步后为空,这种空值差异不应
/// 触发池重建。若任一方 `save_password=true`,密码是真实的连接参数,任何密码变更
/// (包括用户保存了新密码)都必须销毁旧池,否则旧池会继续用旧密码认证。
/// 始终忽略只影响前端导航的表加载策略。仅当双方都是 `save_password=false` 时才忽略
/// `password` 字段的差异:这类连接在 connect 时运行态配置可能携带会话密码,持久化
/// 同步后为空,这种空值差异不应触发池重建。若任一方 `save_password=true`,密码是
/// 真实的连接参数,任何密码变更(包括用户保存了新密码)都必须销毁旧池,否则旧池会
/// 继续用旧密码认证。
pub fn connection_configs_pool_equivalent(a: &ConnectionConfig, b: &ConnectionConfig) -> bool {
if a == b {
return true;
}
let mut a = a.clone();
let mut b = b.clone();
// Sidebar paging is a presentation preference and cannot change an
// established database session.
a.sidebar_auto_load_all_tables = false;
b.sidebar_auto_load_all_tables = false;
if !a.save_password && !b.save_password {
let mut a = a.clone();
a.password.clear();
let mut b = b.clone();
b.password.clear();
return a == b;
}
false
a == b
}
/// Whether transient credentials can safely survive a persisted config update.
@@ -6393,6 +6397,7 @@ pub fn connection_configs_session_credentials_compatible(a: &ConnectionConfig, b
config.visible_databases = None;
config.visible_schemas = None;
config.show_system_schemas = false;
config.sidebar_auto_load_all_tables = false;
config.color = None;
config.docs_notes_path = None;
config.connect_timeout_secs = 0;
@@ -6953,6 +6958,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -7048,6 +7054,16 @@ mod tests {
assert!(connection_configs_pool_equivalent(&a, &c));
}
#[test]
fn connection_configs_pool_equivalent_ignores_sidebar_table_loading_preference() {
let a = mysql_config(None);
let mut b = a.clone();
b.sidebar_auto_load_all_tables = true;
assert!(connection_configs_pool_equivalent(&a, &b));
assert!(connection_configs_pool_equivalent(&b, &a));
}
#[test]
fn connection_configs_pool_equivalent_detects_saved_password_change() {
let mut a = mysql_config(None);
+2
View File
@@ -10855,6 +10855,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -16854,6 +16855,7 @@ SELECT 1 FROM dual"#
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -2480,6 +2480,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -2563,6 +2564,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -2801,6 +2803,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: Some("CREATE SECRET (TYPE quack, TOKEN 'token-value');".to_string()),
color: None,
@@ -10258,6 +10258,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -10332,6 +10333,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
+2
View File
@@ -8060,6 +8060,7 @@ for line in sys.stdin:
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -10901,6 +10902,7 @@ for line in sys.stdin:
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -777,6 +777,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: vec![],
init_script: None,
color: None,
+1
View File
@@ -3349,6 +3349,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -162,6 +162,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -25,6 +25,7 @@ fn mysql_config(username: &str, password: &str, database: Option<&str>) -> Conne
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
@@ -82,6 +82,7 @@ fn live_postgres_config(
is_production: false,
production_databases: vec![],
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
database_info: None,
}
}
@@ -78,6 +78,7 @@ fn live_postgres_config(
is_production: false,
production_databases: vec![],
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
database_info: None,
}
}
@@ -73,6 +73,7 @@ fn live_postgres_config(
is_production: false,
production_databases: vec![],
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
database_info: None,
}
}
@@ -227,6 +227,7 @@ fn live_connection_config(
is_production: false,
production_databases: vec![],
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
database_info: None,
}
}
@@ -78,6 +78,7 @@ fn live_postgres_config(
is_production: false,
production_databases: vec![],
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
database_info: None,
}
}
@@ -72,6 +72,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
is_production: false,
production_databases: vec![],
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
database_info: None,
}
}
@@ -79,6 +79,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
is_production: false,
production_databases: vec![],
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
database_info: None,
}
}
@@ -68,6 +68,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
is_production: false,
production_databases: vec![],
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
database_info: None,
}
}
@@ -71,6 +71,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> ConnectionConfig {
is_production: false,
production_databases: vec![],
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
database_info: None,
}
}
+1
View File
@@ -193,6 +193,7 @@ pub fn postgres_test_config(id: &str, port: u16) -> ConnectionConfig {
is_production: false,
production_databases: vec![],
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
database_info: None,
}
}
@@ -752,6 +752,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
+1
View File
@@ -7486,6 +7486,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
+34
View File
@@ -123,6 +123,9 @@ pub struct ConnectionConfig {
pub visible_schemas: Option<HashMap<String, Vec<String>>>,
#[serde(default, skip_serializing_if = "is_false")]
pub show_system_schemas: bool,
/// Frontend navigation preference: exhaust the paginated Tables group when opened.
#[serde(default, skip_serializing_if = "is_false")]
pub sidebar_auto_load_all_tables: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attached_databases: Vec<AttachedDatabaseConfig>,
/// SQL statements executed right after the connection is established
@@ -581,6 +584,8 @@ struct ConnectionConfigData {
#[serde(default)]
pub show_system_schemas: bool,
#[serde(default)]
pub sidebar_auto_load_all_tables: bool,
#[serde(default)]
pub attached_databases: Vec<AttachedDatabaseConfig>,
#[serde(default)]
pub init_script: Option<String>,
@@ -691,6 +696,7 @@ impl From<ConnectionConfigData> for ConnectionConfig {
visible_database_patterns: data.visible_database_patterns,
visible_schemas: data.visible_schemas,
show_system_schemas: data.show_system_schemas,
sidebar_auto_load_all_tables: data.sidebar_auto_load_all_tables,
attached_databases: data.attached_databases,
init_script: data.init_script,
color: data.color,
@@ -2758,6 +2764,33 @@ mod tests {
assert_eq!(serde_json::to_value(parsed).unwrap()["default_schema"], "archive");
}
#[test]
fn sidebar_auto_load_all_tables_defaults_off_and_round_trips() {
let mut value = serde_json::json!({
"id": "id",
"name": "MariaDB",
"db_type": "mysql",
"host": "localhost",
"port": 3306,
"username": "root",
"password": "",
"database": "app"
});
let legacy: ConnectionConfig = serde_json::from_value(value.clone()).unwrap();
assert!(!legacy.sidebar_auto_load_all_tables);
let serialized_legacy = serde_json::to_value(legacy).unwrap();
assert!(serialized_legacy.get("sidebar_auto_load_all_tables").is_none());
value["sidebar_auto_load_all_tables"] = serde_json::json!(true);
let configured: ConnectionConfig = serde_json::from_value(value).unwrap();
assert!(configured.sidebar_auto_load_all_tables);
assert!(
serde_json::to_value(configured).unwrap()["sidebar_auto_load_all_tables"]
.as_bool()
.unwrap()
);
}
fn mysql_config(username: &str, password: &str, database: Option<&str>) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
@@ -2779,6 +2812,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
+1
View File
@@ -1010,6 +1010,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
+1
View File
@@ -692,6 +692,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,
+1
View File
@@ -242,6 +242,7 @@ mod tests {
visible_database_patterns: None,
visible_schemas: None,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
attached_databases: Vec::new(),
init_script: None,
color: None,