fix(oracle): apply visible schema filter

This commit is contained in:
t8y2 2026-06-25 15:18:46 +08:00
parent 1ded7e8332
commit b2edf7cad6
20 changed files with 469 additions and 102 deletions

View File

@ -3,7 +3,10 @@ package com.dbx.agent;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public interface DatabaseAgent {
void connect(ConnectParams params);
@ -14,6 +17,14 @@ public interface DatabaseAgent {
List<String> listSchemas();
default List<String> listSchemas(List<String> visibleSchemas) {
if (visibleSchemas == null) {
return listSchemas();
}
Set<String> visible = new HashSet<>(visibleSchemas);
return listSchemas().stream().filter(visible::contains).collect(Collectors.toList());
}
List<TableInfo> listTables(String schema);
default List<ObjectInfo> listObjects(String schema) {

View File

@ -105,7 +105,7 @@ public final class JsonRpcServer {
}
if (AgentProtocol.METHOD_LIST_SCHEMAS.equals(method)) {
switchCatalog(params);
return agent.listSchemas();
return agent.listSchemas(stringListOrNull(params, "visible_schemas"));
}
if (AgentProtocol.METHOD_LIST_TABLES.equals(method)) {
switchCatalog(params);
@ -289,6 +289,15 @@ public final class JsonRpcServer {
return element.getAsString();
}
private List<String> stringListOrNull(JsonObject object, String key) {
JsonElement element = object.get(key);
if (element == null || element instanceof JsonNull) {
return null;
}
Type listType = new TypeToken<List<String>>() {}.getType();
return gson.fromJson(element, listType);
}
private static Integer intOrNull(JsonObject object, String key) {
JsonElement element = object.get(key);
if (element == null || element instanceof JsonNull) {

View File

@ -24,6 +24,7 @@ import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
@ -107,19 +108,7 @@ public final class Oracle10gAgent extends BaseDatabaseAgent {
public List<DatabaseInfo> listDatabases() {
return unchecked(() -> {
List<DatabaseInfo> result = new ArrayList<>();
String placeholders = quotedSystemSchemas();
String sql = "SELECT owner FROM ("
+ " SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS owner FROM DUAL"
+ " UNION"
+ " SELECT DISTINCT owner FROM all_tables"
+ " UNION"
+ " SELECT DISTINCT owner FROM all_views"
+ " )"
+ " WHERE owner IS NOT NULL"
+ " AND owner NOT IN (" + placeholders + ")"
+ " AND owner NOT LIKE 'APEX_%'"
+ " AND owner NOT LIKE 'FLOWS_%'"
+ " ORDER BY owner";
String sql = listDatabasesSql(0);
try (Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
@ -130,6 +119,31 @@ public final class Oracle10gAgent extends BaseDatabaseAgent {
});
}
static String listDatabasesSql(int visibleSchemaCount) {
StringBuilder sql = new StringBuilder()
.append("SELECT owner FROM (")
.append(" SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS owner FROM DUAL")
.append(" UNION")
.append(" SELECT DISTINCT owner FROM all_tables")
.append(" UNION")
.append(" SELECT DISTINCT owner FROM all_views")
.append(" )")
.append(" WHERE owner IS NOT NULL")
.append(" AND owner NOT IN (").append(quotedSystemSchemas()).append(")")
.append(" AND owner NOT LIKE 'APEX_%'")
.append(" AND owner NOT LIKE 'FLOWS_%'");
if (visibleSchemaCount > 0) {
sql.append(" AND owner IN (");
for (int i = 0; i < visibleSchemaCount; i++) {
if (i > 0) sql.append(",");
sql.append("?");
}
sql.append(")");
}
sql.append(" ORDER BY owner");
return sql.toString();
}
@Override
public List<String> listSchemas() {
return unchecked(() -> {
@ -141,6 +155,30 @@ public final class Oracle10gAgent extends BaseDatabaseAgent {
});
}
@Override
public List<String> listSchemas(List<String> visibleSchemas) {
if (visibleSchemas == null) {
return listSchemas();
}
if (visibleSchemas.isEmpty()) {
return Collections.emptyList();
}
return unchecked(() -> {
List<String> result = new ArrayList<>();
try (PreparedStatement stmt = requireConnected().prepareStatement(listDatabasesSql(visibleSchemas.size()))) {
for (int i = 0; i < visibleSchemas.size(); i++) {
stmt.setString(i + 1, visibleSchemas.get(i));
}
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(rs.getString(1));
}
}
}
return result;
});
}
@Override
public List<TableInfo> listTables(String schema) {
return unchecked(() -> {

View File

@ -100,6 +100,15 @@ class Oracle10gAgentTest extends JdbcFakeExecutionBehaviorTest {
Assertions.assertFalse(sql.contains("ALL_TAB_COMMENTS"), sql);
}
@Test
void listDatabasesSqlCanApplyVisibleSchemaFilter() {
String sql = Oracle10gAgent.listDatabasesSql(2).toUpperCase(Locale.ROOT);
Assertions.assertTrue(sql.contains("ALL_TABLES"), sql);
Assertions.assertTrue(sql.contains("ALL_VIEWS"), sql);
Assertions.assertTrue(sql.contains("OWNER IN (?,?)"), sql);
}
@Test
void listObjectsSqlUsesSplitDictionaryQuery() {
String sql = Oracle10gAgent.listObjectsSql().toUpperCase(Locale.ROOT);

View File

@ -323,7 +323,7 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
result, err := s.listDatabases()
return result, false, err
case "list_schemas":
result, err := s.listSchemas()
result, err := s.listSchemas(stringSliceParam(params, "visible_schemas"))
return result, false, err
case "list_tables":
schema := stringParam(params, "schema")
@ -570,8 +570,11 @@ func (s *server) currentSchemaDatabase() ([]databaseInfo, error) {
return []databaseInfo{{Name: schema}}, nil
}
func (s *server) listSchemas() ([]string, error) {
databases, err := s.listDatabases()
func (s *server) listSchemas(visibleSchemas []string) ([]string, error) {
if visibleSchemas != nil && len(visibleSchemas) == 0 {
return []string{}, nil
}
databases, err := s.listDatabasesFiltered(visibleSchemas)
if err != nil {
return nil, err
}
@ -582,6 +585,55 @@ func (s *server) listSchemas() ([]string, error) {
return emptyIfNil(result), nil
}
func (s *server) listDatabasesFiltered(visibleSchemas []string) ([]databaseInfo, error) {
if visibleSchemas == nil {
return s.listDatabases()
}
sqlText, args := oracleListDatabasesSQLWithVisibleSchemas(visibleSchemas)
rows, err := s.queryRows(sqlText, args)
if err != nil {
if isOraclePGALimitError(err) {
return s.currentSchemaDatabase()
}
return nil, err
}
defer rows.Close()
var result []databaseInfo
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
result = append(result, databaseInfo{Name: name})
}
if err := rows.Err(); err != nil {
if isOraclePGALimitError(err) {
return s.currentSchemaDatabase()
}
return nil, err
}
return emptyIfNil(result), nil
}
func oracleListDatabasesSQLWithVisibleSchemas(visibleSchemas []string) (string, []any) {
if len(visibleSchemas) == 0 {
return oracleListDatabasesSQL, nil
}
placeholders := make([]string, 0, len(visibleSchemas))
args := make([]any, 0, len(visibleSchemas))
for i, schema := range visibleSchemas {
placeholders = append(placeholders, fmt.Sprintf(":%d", i+1))
args = append(args, schema)
}
sqlText := strings.Replace(
oracleListDatabasesSQL,
"\nORDER BY CASE",
"\n AND username IN ("+strings.Join(placeholders, ",")+")\nORDER BY CASE",
1,
)
return sqlText, args
}
func (s *server) currentSchema() (string, error) {
db, err := s.requireDB()
if err != nil {
@ -1352,6 +1404,17 @@ func stringParam(params map[string]json.RawMessage, key string) string {
return value
}
func stringSliceParam(params map[string]json.RawMessage, key string) []string {
if params == nil || len(params[key]) == 0 {
return nil
}
var value []string
if err := json.Unmarshal(params[key], &value); err != nil {
return nil
}
return value
}
func intParam(params map[string]json.RawMessage, key string) int {
if params == nil || len(params[key]) == 0 {
return 0

View File

@ -265,6 +265,24 @@ func TestListDatabasesSQLUsesUserDictionaryInsteadOfObjectDictionary(t *testing.
}
}
func TestListDatabasesSQLCanApplyVisibleSchemaFilter(t *testing.T) {
sqlText, args := oracleListDatabasesSQLWithVisibleSchemas([]string{"APP", "REPORTING"})
upperSQL := strings.ToUpper(sqlText)
if !strings.Contains(upperSQL, "ALL_USERS") {
t.Fatalf("schema listing should query ALL_USERS, got: %s", sqlText)
}
if !strings.Contains(upperSQL, "USERNAME IN (:1,:2)") {
t.Fatalf("schema listing should apply visible schema filter, got: %s", sqlText)
}
if len(args) != 2 || args[0] != "APP" || args[1] != "REPORTING" {
t.Fatalf("visible schema args were not preserved: %#v", args)
}
if strings.Contains(upperSQL, "ALL_TABLES") || strings.Contains(upperSQL, "ALL_VIEWS") {
t.Fatalf("schema listing should not scan object dictionaries, got: %s", sqlText)
}
}
func TestListTablesSQLUsesSplitDictionaryQuery(t *testing.T) {
sqlText := strings.ToUpper(oracleListTablesSQL)

View File

@ -140,9 +140,14 @@ public class OracleAgent extends BaseDatabaseAgent {
}
static String listDatabasesSql() {
return listDatabasesSql(0);
}
static String listDatabasesSql(int visibleSchemaCount) {
String placeholders = SYSTEM_SCHEMAS.stream()
.map(schema -> "'" + schema + "'")
.collect(Collectors.joining(","));
String visibleSchemaClause = visibleSchemaCount <= 0 ? "" : " AND username IN (" + "?,".repeat(visibleSchemaCount).replaceAll(",$", "") + ")\n";
return """
SELECT username AS owner
FROM all_users
@ -151,12 +156,13 @@ public class OracleAgent extends BaseDatabaseAgent {
AND username NOT LIKE 'APEX_%%'
AND username NOT LIKE 'FLOWS_%%'
AND username NOT LIKE '%%$%%'
%s
ORDER BY CASE
WHEN username = SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') THEN 0
WHEN username = SYS_CONTEXT('USERENV', 'SESSION_USER') THEN 1
ELSE 2
END, username
""".formatted(placeholders).stripIndent().trim();
""".formatted(placeholders, visibleSchemaClause).stripIndent().trim();
}
static boolean isPgaLimitError(SQLException error) {
@ -212,6 +218,30 @@ public class OracleAgent extends BaseDatabaseAgent {
return result;
}
@Override
public List<String> listSchemas(List<String> visibleSchemas) {
if (visibleSchemas == null) {
return listSchemas();
}
if (visibleSchemas.isEmpty()) {
return List.of();
}
return unchecked(() -> {
List<String> result = new ArrayList<>();
try (var stmt = requireConnected().prepareStatement(listDatabasesSql(visibleSchemas.size()))) {
for (int i = 0; i < visibleSchemas.size(); i++) {
stmt.setString(i + 1, visibleSchemas.get(i));
}
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(rs.getString(1));
}
}
}
return result;
});
}
@Override
public List<TableInfo> listTables(String schema) {
return unchecked(() -> {

View File

@ -7,6 +7,7 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import java.util.List;
import java.util.Locale;
class OracleAgentTest extends JdbcFakeExecutionBehaviorTest {
@ -65,6 +66,22 @@ class OracleAgentTest extends JdbcFakeExecutionBehaviorTest {
Assertions.assertFalse(sql.contains("ALL_VIEWS"));
}
@Test
void listDatabasesSqlCanApplyVisibleSchemaFilter() {
String sql = OracleAgent.listDatabasesSql(2).toUpperCase(Locale.ROOT);
Assertions.assertTrue(sql.contains("ALL_USERS"), sql);
Assertions.assertTrue(sql.contains("USERNAME IN (?,?)"), sql);
Assertions.assertFalse(sql.contains("ALL_TABLES"), sql);
}
@Test
void listSchemasWithEmptyVisibleFilterSkipsMetadataQuery() {
OracleAgent agent = new OracleAgent();
Assertions.assertEquals(List.of(), agent.listSchemas(List.of()));
}
@Test
void listTablesSqlUsesSplitDictionaryQuery() {
String sql = OracleAgent.listTablesSql().toUpperCase(Locale.ROOT);

View File

@ -36,7 +36,7 @@ import { prestoSqlBuiltinDriverPaths } from "@/lib/prestoSqlBuiltinDriver";
import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/databaseFileDetection";
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue";
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connectionVisibleDatabases";
import { canSaveVisibleDatabaseSelection, filterDatabaseNamesForConnection, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId } from "@/lib/visibleDatabases";
import { canSaveVisibleDatabaseSelection, filterDatabaseNamesForConnection, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/visibleDatabases";
import { isSchemaAware } from "@/lib/databaseFeatureSupport";
import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
@ -1281,6 +1281,7 @@ const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" &&
const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile));
const h2DriverMissing = computed(() => form.value.db_type === "h2" && isH2FileMode.value && agentDrivers.value.find((d) => d.db_type === "h2")?.installed !== true);
const canChooseVisibleDatabases = computed(() => connectionCanChooseVisibleDatabases(form.value));
const visibleFilterUsesSchemas = computed(() => form.value.db_type === "oracle" || form.value.db_type === "dameng");
const hasVisibleDatabaseFilter = computed(() => Array.isArray(form.value.visible_databases));
const visibleDatabaseSummary = computed(() => {
const configured = form.value.visible_databases;
@ -1288,6 +1289,7 @@ const visibleDatabaseSummary = computed(() => {
return t("visibleDatabases.selectedCount", { selected: configured.length, total: visibleDatabaseNames.value.length });
});
const listedVisibleDatabaseNames = computed(() => {
if (visibleFilterUsesSchemas.value) return visibleDatabaseNames.value;
const connection = connectionConfigSnapshotForVisibleDatabases();
if (visibleDatabaseShowSystem.value) return visibleDatabaseNames.value;
return filterDatabaseNamesForConnection(visibleDatabaseNames.value, connection);
@ -1301,6 +1303,7 @@ const visibleDatabaseSelectedCount = computed(() => visibleDatabaseSelection.val
const visibleDatabaseTotalCount = computed(() => listedVisibleDatabaseNames.value.length);
const visibleDatabaseCanSave = computed(() => canSaveVisibleDatabaseSelection([...visibleDatabaseSelection.value]));
const visibleDatabaseHasSystemDatabases = computed(() => {
if (visibleFilterUsesSchemas.value) return false;
const connection = connectionConfigSnapshotForVisibleDatabases();
return visibleDatabaseNames.value.some((database) => isSystemDatabaseName(connection.db_type, database));
});
@ -1310,12 +1313,32 @@ const hasVisibleSchemaFilter = computed(() => {
const key = visibleSchemasDatabaseKey.value;
return Array.isArray(form.value.visible_schemas?.[key]);
});
const visibleSchemaObjectSelection = computed(() => {
const configured = form.value.visible_schemas?.[visibleSchemasDatabaseKey.value];
if (Array.isArray(configured)) return configured;
if (visibleFilterUsesSchemas.value && Array.isArray(form.value.visible_databases)) return form.value.visible_databases;
return undefined;
});
const visibleSchemaSummary = computed(() => {
const key = visibleSchemasDatabaseKey.value;
const configured = form.value.visible_schemas?.[key];
if (!configured?.length) return t("visibleSchemas.showAll");
return t("visibleSchemas.selectedCount", { selected: configured.length, total: visibleSchemaNames.value.length });
});
const hasVisibleObjectFilter = computed(() => (visibleFilterUsesSchemas.value ? Array.isArray(visibleSchemaObjectSelection.value) : hasVisibleDatabaseFilter.value));
const visibleObjectSummary = computed(() => {
if (!visibleFilterUsesSchemas.value) return visibleDatabaseSummary.value;
const configured = visibleSchemaObjectSelection.value;
if (!Array.isArray(configured)) return t("visibleSchemas.showAll");
return t("visibleSchemas.selectedCount", { selected: configured.length, total: visibleDatabaseNames.value.length });
});
const visibleObjectTitleKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.title" : "visibleDatabases.title"));
const visibleObjectDescriptionKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.description" : "visibleDatabases.description"));
const visibleObjectSearchPlaceholderKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.searchPlaceholder" : "visibleDatabases.searchPlaceholder"));
const visibleObjectSelectedCountKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.selectedCount" : "visibleDatabases.selectedCount"));
const visibleObjectEmptySelectionKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.emptySelection" : "visibleDatabases.emptySelection"));
const visibleObjectLoadFailedKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.loadFailed" : "visibleDatabases.loadFailed"));
const visibleObjectSaveKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.save" : "visibleDatabases.save"));
const testResultMessage = computed(() => {
if (!testResult.value) return "";
return testResult.value.ok ? t("connection.testSuccess") : testResult.value.message;
@ -1626,7 +1649,11 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
delete legacy.proxy_port;
delete legacy.proxy_username;
delete legacy.proxy_password;
config.visible_databases = Array.isArray(config.visible_databases) && config.visible_databases.length > 0 ? config.visible_databases : undefined;
if (config.db_type === "oracle" || config.db_type === "dameng") {
config.visible_databases = undefined;
} else {
config.visible_databases = Array.isArray(config.visible_databases) && config.visible_databases.length > 0 ? config.visible_databases : undefined;
}
if (config.visible_schemas && Object.keys(config.visible_schemas).length === 0) config.visible_schemas = undefined;
return config as ConnectionConfig;
}
@ -1894,9 +1921,10 @@ async function openVisibleDatabasesPicker() {
await api.connectDb(draftConfig);
const names = await loadVisibleDatabaseNames(draftId, draftConfig);
visibleDatabaseNames.value = names;
const initialSelection = initialVisibleDatabaseSelection(names, form.value.visible_databases, draftConfig);
const configuredSchemas = visibleSchemaObjectSelection.value;
const initialSelection = visibleFilterUsesSchemas.value ? (Array.isArray(configuredSchemas) ? normalizeVisibleSchemaSelection(configuredSchemas, names) : names) : initialVisibleDatabaseSelection(names, form.value.visible_databases, draftConfig);
visibleDatabaseSelection.value = new Set(initialSelection);
visibleDatabaseShowSystem.value = initialSelection.some((database) => isSystemDatabaseName(draftConfig.db_type, database));
visibleDatabaseShowSystem.value = !visibleFilterUsesSchemas.value && initialSelection.some((database) => isSystemDatabaseName(draftConfig.db_type, database));
showVisibleDatabasesDialog.value = true;
} catch (e: any) {
visibleDatabaseNames.value = [];
@ -1938,7 +1966,12 @@ function clearVisibleDatabaseSelection() {
}
function showAllVisibleDatabases() {
form.value.visible_databases = undefined;
if (visibleFilterUsesSchemas.value) {
handleDraftSchemasShowAll();
form.value.visible_databases = undefined;
} else {
form.value.visible_databases = undefined;
}
visibleDatabaseSelection.value = new Set();
visibleDatabaseNames.value = [];
showVisibleDatabasesDialog.value = false;
@ -1946,7 +1979,16 @@ function showAllVisibleDatabases() {
function saveVisibleDatabaseSelection() {
if (!visibleDatabaseCanSave.value) return;
form.value.visible_databases = normalizeVisibleDatabaseSelection([...visibleDatabaseSelection.value], visibleDatabaseNames.value);
if (visibleFilterUsesSchemas.value) {
const key = visibleSchemasDatabaseKey.value;
form.value.visible_databases = undefined;
form.value.visible_schemas = {
...form.value.visible_schemas,
[key]: normalizeVisibleSchemaSelection([...visibleDatabaseSelection.value], visibleDatabaseNames.value),
};
} else {
form.value.visible_databases = normalizeVisibleDatabaseSelection([...visibleDatabaseSelection.value], visibleDatabaseNames.value);
}
showVisibleDatabasesDialog.value = false;
}
@ -1989,7 +2031,7 @@ async function openVisibleSchemasPicker() {
function handleDraftSchemasSave(selectedNames: string[]) {
const key = visibleSchemasDatabaseKey.value;
form.value.visible_schemas = { ...(form.value.visible_schemas || {}), [key]: selectedNames };
form.value.visible_schemas = { ...form.value.visible_schemas, [key]: selectedNames };
}
function handleDraftSchemasShowAll() {
@ -3967,9 +4009,9 @@ function openExternalUrl(url: string) {
<Button v-if="canChooseVisibleDatabases" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleDatabases || !hasRequiredConnectionTarget" @click="openVisibleDatabasesPicker">
<Loader2 v-if="isLoadingVisibleDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ hasVisibleDatabaseFilter ? visibleDatabaseSummary : t("contextMenu.selectVisibleDatabases") }}
{{ hasVisibleObjectFilter ? visibleObjectSummary : visibleFilterUsesSchemas ? t("contextMenu.configureVisibleObjects") : t("contextMenu.selectVisibleDatabases") }}
</Button>
<Button v-if="canChooseVisibleSchemas" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleSchemas || !hasRequiredConnectionTarget" @click="openVisibleSchemasPicker">
<Button v-if="canChooseVisibleSchemas && !visibleFilterUsesSchemas" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleSchemas || !hasRequiredConnectionTarget" @click="openVisibleSchemasPicker">
<Loader2 v-if="isLoadingVisibleSchemas" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ hasVisibleSchemaFilter ? visibleSchemaSummary : t("visibleSchemas.showAll") }}
@ -3988,21 +4030,21 @@ function openExternalUrl(url: string) {
<Dialog v-model:open="showVisibleDatabasesDialog">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("visibleDatabases.title") }}</DialogTitle>
<DialogTitle>{{ t(visibleObjectTitleKey) }}</DialogTitle>
<p class="text-sm text-muted-foreground">
{{ t("visibleDatabases.description", { connection: form.name || selectedProfile().label }) }}
{{ t(visibleObjectDescriptionKey, { connection: form.name || selectedProfile().label }) }}
</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="visibleDatabaseSearchText" :placeholder="t('visibleDatabases.searchPlaceholder')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingVisibleDatabases || !!visibleDatabaseError" />
<Input v-model="visibleDatabaseSearchText" :placeholder="t(visibleObjectSearchPlaceholderKey)" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingVisibleDatabases || !!visibleDatabaseError" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>
{{
t("visibleDatabases.selectedCount", {
t(visibleObjectSelectedCountKey, {
selected: visibleDatabaseSelectedCount,
total: visibleDatabaseTotalCount,
})
@ -4021,7 +4063,7 @@ function openExternalUrl(url: string) {
</div>
</div>
<p v-if="!isLoadingVisibleDatabases && !visibleDatabaseError && !visibleDatabaseCanSave" class="text-xs text-destructive">
{{ t("visibleDatabases.emptySelection") }}
{{ t(visibleObjectEmptySelectionKey) }}
</p>
<label v-if="visibleDatabaseHasSystemDatabases" class="flex h-8 items-center gap-2 rounded-md px-1 text-xs text-muted-foreground">
@ -4035,7 +4077,7 @@ function openExternalUrl(url: string) {
{{ t("common.loading") }}
</div>
<div v-else-if="visibleDatabaseError" class="p-3 text-sm text-destructive">
{{ t("visibleDatabases.loadFailed", { message: visibleDatabaseError }) }}
{{ t(visibleObjectLoadFailedKey, { message: visibleDatabaseError }) }}
</div>
<div v-else-if="!filteredVisibleDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
{{ t("grid.noSearchResults") }}
@ -4058,7 +4100,7 @@ function openExternalUrl(url: string) {
<DialogFooter>
<Button variant="outline" @click="showVisibleDatabasesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoadingVisibleDatabases || !!visibleDatabaseError || !visibleDatabaseCanSave" @click="saveVisibleDatabaseSelection">
{{ t("visibleDatabases.save") }}
{{ t(visibleObjectSaveKey) }}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -3561,12 +3561,11 @@ function treeItemMenuItems(): ContextMenuItem[] {
});
if (canConfigureVisibleDatabases.value) {
items.push({
label: t("contextMenu.selectVisibleDatabases"),
label: t("contextMenu.configureVisibleObjects"),
action: openVisibleDatabasesDialog,
icon: ListFilter,
});
}
if (canConfigureVisibleSchemas.value) {
} else if (canConfigureVisibleSchemas.value) {
items.push({
label: t("visibleSchemas.title"),
action: openVisibleSchemasDialog,

View File

@ -22,7 +22,9 @@ const emit = defineEmits<{
const { t } = useI18n();
const connectionStore = useConnectionStore();
const databaseNames = ref<string[]>([]);
type FilterMode = "database" | "schema";
const objectNames = ref<string[]>([]);
const selectedNames = ref<Set<string>>(new Set());
const searchText = ref("");
const showSystemDatabases = ref(false);
@ -30,19 +32,34 @@ const isLoading = ref(false);
const errorMessage = ref("");
const connection = computed(() => connectionStore.getConfig(props.connectionId));
const listedDatabaseNames = computed(() => {
if (showSystemDatabases.value) return databaseNames.value;
return filterDatabaseNamesForConnection(databaseNames.value, connection.value);
const filterMode = computed<FilterMode>(() => (connection.value?.db_type === "oracle" || connection.value?.db_type === "dameng" ? "schema" : "database"));
const databaseKey = computed(() => connection.value?.database || "");
const isSchemaFilterMode = computed(() => filterMode.value === "schema");
const titleKey = computed(() => (isSchemaFilterMode.value ? "visibleSchemas.title" : "visibleDatabases.title"));
const descriptionKey = computed(() => (isSchemaFilterMode.value ? "visibleSchemas.description" : "visibleDatabases.description"));
const searchPlaceholderKey = computed(() => (isSchemaFilterMode.value ? "visibleSchemas.searchPlaceholder" : "visibleDatabases.searchPlaceholder"));
const emptySelectionKey = computed(() => (isSchemaFilterMode.value ? "visibleSchemas.emptySelection" : "visibleDatabases.emptySelection"));
const loadFailedKey = computed(() => (isSchemaFilterMode.value ? "visibleSchemas.loadFailed" : "visibleDatabases.loadFailed"));
const listedObjectNames = computed(() => {
if (isSchemaFilterMode.value) return objectNames.value;
if (showSystemDatabases.value) return objectNames.value;
return filterDatabaseNamesForConnection(objectNames.value, connection.value);
});
const filteredDatabaseNames = computed(() => {
const filteredObjectNames = computed(() => {
const query = searchText.value.trim().toLowerCase();
if (!query) return listedDatabaseNames.value;
return listedDatabaseNames.value.filter((name) => name.toLowerCase().includes(query));
if (!query) return listedObjectNames.value;
return listedObjectNames.value.filter((name) => name.toLowerCase().includes(query));
});
const selectedCount = computed(() => selectedNames.value.size);
const totalCount = computed(() => listedDatabaseNames.value.length);
const totalCount = computed(() => listedObjectNames.value.length);
const canSaveSelection = computed(() => canSaveVisibleDatabaseSelection([...selectedNames.value]));
const hasSystemDatabases = computed(() => databaseNames.value.some((database) => isSystemDatabaseName(connection.value?.db_type, database)));
const hasSystemDatabases = computed(() => !isSchemaFilterMode.value && objectNames.value.some((database) => isSystemDatabaseName(connection.value?.db_type, database)));
const showAllDisabled = computed(() => {
if (isSchemaFilterMode.value) {
return !connection.value?.visible_schemas?.[databaseKey.value];
}
return !Array.isArray(connection.value?.visible_databases);
});
watch(
() => props.open,
@ -62,14 +79,14 @@ async function loadDatabases() {
errorMessage.value = "";
searchText.value = "";
try {
const names = await loadDatabaseNames();
databaseNames.value = names;
const configured = connection.value?.visible_databases;
const initialSelection = Array.isArray(configured) ? normalizeVisibleDatabaseSelection(configured, names) : filterDatabaseNamesForConnection(names, connection.value);
const names = await loadObjectNames();
objectNames.value = names;
const configured = isSchemaFilterMode.value ? connection.value?.visible_schemas?.[databaseKey.value] : connection.value?.visible_databases;
const initialSelection = Array.isArray(configured) ? normalizeVisibleDatabaseSelection(configured, names) : listedObjectNames.value;
selectedNames.value = new Set(initialSelection);
showSystemDatabases.value = initialSelection.some((database) => isSystemDatabaseName(connection.value?.db_type, database));
showSystemDatabases.value = !isSchemaFilterMode.value && initialSelection.some((database) => isSystemDatabaseName(connection.value?.db_type, database));
} catch (e: any) {
databaseNames.value = [];
objectNames.value = [];
selectedNames.value = new Set();
showSystemDatabases.value = false;
errorMessage.value = String(e?.message || e);
@ -78,11 +95,11 @@ async function loadDatabases() {
}
}
async function loadDatabaseNames(): Promise<string[]> {
async function loadObjectNames(): Promise<string[]> {
const config = connection.value;
if (config?.db_type === "oracle" || config?.db_type === "dameng") {
if (isSchemaFilterMode.value) {
await connectionStore.ensureConnected(props.connectionId);
return api.listSchemas(props.connectionId, config.database || "");
return api.listSchemas(props.connectionId, config?.database || "");
}
await connectionStore.ensureConnected(props.connectionId);
if (config?.db_type === "redis") {
@ -94,21 +111,21 @@ async function loadDatabaseNames(): Promise<string[]> {
return (await api.listDatabases(props.connectionId)).map((database) => database.name);
}
function toggleDatabase(database: string) {
function toggleObject(name: string) {
const next = new Set(selectedNames.value);
if (next.has(database)) next.delete(database);
else next.add(database);
if (next.has(name)) next.delete(name);
else next.add(name);
selectedNames.value = next;
}
const isSearching = computed(() => searchText.value.trim().length > 0);
function selectAll() {
selectedNames.value = new Set(listedDatabaseNames.value);
selectedNames.value = new Set(listedObjectNames.value);
}
function selectFiltered() {
selectedNames.value = new Set(filteredDatabaseNames.value);
selectedNames.value = new Set(filteredObjectNames.value);
}
function clearSelection() {
@ -116,13 +133,21 @@ function clearSelection() {
}
async function showAllDatabases() {
await connectionStore.clearVisibleDatabases(props.connectionId);
if (isSchemaFilterMode.value) {
await connectionStore.clearVisibleSchemas(props.connectionId, databaseKey.value);
} else {
await connectionStore.clearVisibleDatabases(props.connectionId);
}
emit("update:open", false);
}
async function saveSelection() {
if (!canSaveSelection.value) return;
await connectionStore.setVisibleDatabases(props.connectionId, [...selectedNames.value]);
if (isSchemaFilterMode.value) {
await connectionStore.setVisibleSchemas(props.connectionId, databaseKey.value, normalizeVisibleDatabaseSelection([...selectedNames.value], objectNames.value));
} else {
await connectionStore.setVisibleDatabases(props.connectionId, [...selectedNames.value]);
}
emit("update:open", false);
}
</script>
@ -131,15 +156,15 @@ async function saveSelection() {
<Dialog :open="open" @update:open="(value: boolean) => emit('update:open', value)">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("visibleDatabases.title") }}</DialogTitle>
<DialogTitle>{{ t(titleKey) }}</DialogTitle>
<p class="text-sm text-muted-foreground">
{{ t("visibleDatabases.description", { connection: connectionName }) }}
{{ t(descriptionKey, { connection: connectionName }) }}
</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="searchText" :placeholder="t('visibleDatabases.searchPlaceholder')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoading || !!errorMessage" />
<Input v-model="searchText" :placeholder="t(searchPlaceholderKey)" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoading || !!errorMessage" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
@ -154,13 +179,13 @@ async function saveSelection() {
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="clearSelection">
{{ t("visibleDatabases.clear") }}
</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading || !Array.isArray(connection?.visible_databases)" @click="showAllDatabases">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading || showAllDisabled" @click="showAllDatabases">
{{ t("visibleDatabases.showAll") }}
</button>
</div>
</div>
<p v-if="!isLoading && !errorMessage && !canSaveSelection" class="text-xs text-destructive">
{{ t("visibleDatabases.emptySelection") }}
{{ t(emptySelectionKey) }}
</p>
<label v-if="hasSystemDatabases" class="flex h-8 items-center gap-2 rounded-md px-1 text-xs text-muted-foreground">
@ -174,22 +199,22 @@ async function saveSelection() {
{{ t("common.loading") }}
</div>
<div v-else-if="errorMessage" class="p-3 text-sm text-destructive">
{{ t("visibleDatabases.loadFailed", { message: errorMessage }) }}
{{ t(loadFailedKey, { message: errorMessage }) }}
</div>
<div v-else-if="!filteredDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
<div v-else-if="!filteredObjectNames.length" class="p-3 text-sm text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
<template v-else>
<button
v-for="database in filteredDatabaseNames"
:key="database"
v-for="name in filteredObjectNames"
:key="name"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleDatabase(database)"
@click="toggleObject(name)"
>
<CheckSquare v-if="selectedNames.has(database)" class="h-4 w-4 shrink-0 text-primary" />
<CheckSquare v-if="selectedNames.has(name)" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate">{{ database }}</span>
<span class="truncate">{{ name }}</span>
</button>
</template>
</div>

View File

@ -1148,6 +1148,7 @@ export default {
editConnection: "Edit Connection",
revealDatabaseFile: "Reveal in File Manager",
selectVisibleDatabases: "Select Visible Databases",
configureVisibleObjects: "Visible Object Filter",
userAdmin: "Users & Privileges",
openUserAdmin: "Open Users & Privileges",
duplicateConnection: "Duplicate Connection",

View File

@ -1147,6 +1147,7 @@ export default {
editConnection: "编辑连接",
revealDatabaseFile: "在文件管理器中显示",
selectVisibleDatabases: "选择显示数据库",
configureVisibleObjects: "可见对象过滤器",
duplicateConnection: "复制连接",
userAdmin: "用户与权限",
openUserAdmin: "打开用户与权限",

View File

@ -478,8 +478,8 @@ export async function deleteSchemaCachePrefix(prefix: string): Promise<void> {
return del(`/api/schema/cache-prefix?${qs({ prefix })}`);
}
export async function listSchemas(connectionId: string, database: string): Promise<string[]> {
return get(`/api/schema/schemas?${qs({ connection_id: connectionId, database })}`);
export async function listSchemas(connectionId: string, database: string, applyVisibleFilter = false): Promise<string[]> {
return get(`/api/schema/schemas?${qs({ connection_id: connectionId, database, apply_visible_filter: applyVisibleFilter || undefined })}`);
}
export async function listSchemaInfos(connectionId: string, database: string): Promise<SchemaInfo[]> {

View File

@ -549,8 +549,8 @@ export async function getObjectSource(connectionId: string, database: string, sc
return invoke("get_object_source", { connectionId, database, schema, name, objectType });
}
export async function listSchemas(connectionId: string, database: string): Promise<string[]> {
return invoke("list_schemas", { connectionId, database });
export async function listSchemas(connectionId: string, database: string, applyVisibleFilter = false): Promise<string[]> {
return invoke("list_schemas", { connectionId, database, applyVisibleFilter });
}
export async function listSchemaInfos(connectionId: string, database: string): Promise<SchemaInfo[]> {

View File

@ -1193,7 +1193,7 @@ export const useConnectionStore = defineStore("connection", () => {
return;
}
}
const schemas = await withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, effectiveDb), "schemas");
const schemas = await withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, effectiveDb, true), "schemas");
const visibleSchemas = filterSchemaNamesForConnection(schemas, config, effectiveDb || "");
const schemaNodes: TreeNode[] = sortSidebarNames(visibleSchemas).map((s) => ({
id: `${connectionId}:${s}:${s}`,

View File

@ -544,12 +544,20 @@ impl AgentDriverClient {
database: &str,
timeout_duration: Option<Duration>,
) -> Result<T, String> {
self.call_method_with_timeout(
AgentMethod::ListSchemas,
serde_json::json!({ "database": database }),
timeout_duration,
)
.await
self.list_schemas_filtered(database, None, timeout_duration).await
}
pub async fn list_schemas_filtered<T: DeserializeOwned + Send + 'static>(
&mut self,
database: &str,
visible_schemas: Option<&[String]>,
timeout_duration: Option<Duration>,
) -> Result<T, String> {
let mut params = serde_json::json!({ "database": database });
if let Some(visible_schemas) = visible_schemas {
params["visible_schemas"] = serde_json::json!(visible_schemas);
}
self.call_method_with_timeout(AgentMethod::ListSchemas, params, timeout_duration).await
}
pub async fn list_tables<T: DeserializeOwned + Send + 'static>(

View File

@ -584,8 +584,17 @@ async fn list_databases_once(state: &AppState, connection_id: &str) -> Result<Ve
}
pub async fn list_schemas_core(state: &AppState, connection_id: &str, database: &str) -> Result<Vec<String>, String> {
list_schemas_core_with_visible_filter(state, connection_id, database, false).await
}
pub async fn list_schemas_core_with_visible_filter(
state: &AppState,
connection_id: &str,
database: &str,
apply_visible_filter: bool,
) -> Result<Vec<String>, String> {
retry_metadata_connection(state, connection_id, Some(database), || {
list_schemas_once(state, connection_id, database)
list_schemas_once(state, connection_id, database, apply_visible_filter)
})
.await
}
@ -614,13 +623,19 @@ async fn list_schema_infos_once(
}
}
let schemas = list_schemas_once(state, connection_id, database).await?;
let schemas = list_schemas_once(state, connection_id, database, false).await?;
Ok(schemas.into_iter().map(|name| db::SchemaInfo { name, comment: None }).collect())
}
async fn list_schemas_once(state: &AppState, connection_id: &str, database: &str) -> Result<Vec<String>, String> {
async fn list_schemas_once(
state: &AppState,
connection_id: &str,
database: &str,
apply_visible_filter: bool,
) -> Result<Vec<String>, String> {
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
let db_config = connection_config(state, connection_id).await;
let visible_schema_filter = visible_schema_filter(db_config.as_ref(), database, apply_visible_filter);
{
let connections = state.connections.read().await;
@ -640,13 +655,28 @@ async fn list_schemas_once(state: &AppState, connection_id: &str, database: &str
let fallback_config = db_config.clone();
drop(connections);
let mut client = client.lock().await;
match client.list_schemas::<Vec<String>>(database, agent_metadata_timeout(db_config.as_ref())).await {
Ok(schemas) if !schemas.is_empty() => return Ok(schemas),
match client
.list_schemas_filtered::<Vec<String>>(
database,
visible_schema_filter.as_deref(),
agent_metadata_timeout(db_config.as_ref()),
)
.await
{
Ok(schemas) if !schemas.is_empty() => {
return Ok(filter_visible_schema_names(schemas, visible_schema_filter.as_deref()))
}
Ok(schemas) => {
if let Some(config) = fallback_config.as_ref() {
match native_postgres_metadata_pool(state, connection_id, database, config).await {
Ok(Some(pool)) => return db::postgres::list_schemas(&pool).await,
Ok(None) => return Ok(schemas),
Ok(Some(pool)) => {
return db::postgres::list_schemas(&pool).await.map(|schemas| {
filter_visible_schema_names(schemas, visible_schema_filter.as_deref())
})
}
Ok(None) => {
return Ok(filter_visible_schema_names(schemas, visible_schema_filter.as_deref()))
}
Err(error) => {
log::warn!(
"[schema][agent:list_schemas:fallback-failed] connection_id={} database={} error={}",
@ -657,16 +687,21 @@ async fn list_schemas_once(state: &AppState, connection_id: &str, database: &str
}
}
}
return Ok(schemas);
return Ok(filter_visible_schema_names(schemas, visible_schema_filter.as_deref()));
}
Err(agent_error) => {
if let Some(config) = fallback_config.as_ref() {
if let Some(pool) =
native_postgres_metadata_pool(state, connection_id, database, config).await?
{
return db::postgres::list_schemas(&pool).await.map_err(|fallback_error| {
format!("{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}")
});
return db::postgres::list_schemas(&pool)
.await
.map(|schemas| filter_visible_schema_names(schemas, visible_schema_filter.as_deref()))
.map_err(|fallback_error| {
format!(
"{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}"
)
});
}
}
return Err(agent_error);
@ -679,17 +714,39 @@ async fn list_schemas_once(state: &AppState, connection_id: &str, database: &str
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Postgres(p) => db::postgres::list_schemas(p).await,
PoolKind::Postgres(p) => db::postgres::list_schemas(p)
.await
.map(|schemas| filter_visible_schema_names(schemas, visible_schema_filter.as_deref())),
#[cfg(feature = "duckdb-bundled")]
PoolKind::DuckDb(con) => {
let duckdb_attached_names = duckdb_attached_database_names(state, connection_id).await;
let con = con.lock().map_err(|e| e.to_string())?;
duckdb_list_schemas_with_attached(&con, database, &duckdb_attached_names)
.map(|schemas| filter_visible_schema_names(schemas, visible_schema_filter.as_deref()))
}
_ => Ok(vec![]),
}
}
fn visible_schema_filter(
config: Option<&ConnectionConfig>,
database: &str,
apply_visible_filter: bool,
) -> Option<Vec<String>> {
if !apply_visible_filter {
return None;
}
config?.visible_schemas.as_ref()?.get(database).cloned()
}
fn filter_visible_schema_names(schemas: Vec<String>, visible: Option<&[String]>) -> Vec<String> {
let Some(visible) = visible else {
return schemas;
};
let visible: std::collections::HashSet<&str> = visible.iter().map(String::as_str).collect();
schemas.into_iter().filter(|schema| visible.contains(schema.as_str())).collect()
}
pub async fn list_tables_core(
state: &AppState,
connection_id: &str,
@ -1177,9 +1234,10 @@ mod tests {
use super::db;
use super::{
clickhouse_metadata_database, deduplicate_column_infos, filter_mysql_system_databases_for_config,
filter_table_infos, is_agent_postgres_metadata_fallback_config, is_retryable_metadata_error,
mysql_table_metadata_catalog, normalize_information_schema_table_type, oracle_table_comment_from_query_result,
oracle_table_comment_sql, presto_like_information_schema_tables_sql, presto_like_tables_from_query_result,
filter_table_infos, filter_visible_schema_names, is_agent_postgres_metadata_fallback_config,
is_retryable_metadata_error, mysql_table_metadata_catalog, normalize_information_schema_table_type,
oracle_table_comment_from_query_result, oracle_table_comment_sql, presto_like_information_schema_tables_sql,
presto_like_tables_from_query_result, visible_schema_filter,
};
#[cfg(feature = "duckdb-bundled")]
use super::{
@ -1187,6 +1245,7 @@ mod tests {
duckdb_query_tables_in_database,
};
use crate::models::connection::{ConnectionConfig, DatabaseType};
use std::collections::HashMap;
fn test_column(name: &str, comment: Option<&str>, is_primary_key: bool) -> super::db::ColumnInfo {
super::db::ColumnInfo {
@ -1265,6 +1324,28 @@ mod tests {
assert!(!is_retryable_metadata_error("Access denied for user"));
}
#[test]
fn visible_schema_filter_only_applies_when_requested() {
let mut config = test_connection_config(DatabaseType::Oracle);
config.visible_schemas =
Some(HashMap::from([("ORCLPDB1".to_string(), vec!["APP".to_string(), "REPORTING".to_string()])]));
assert_eq!(visible_schema_filter(Some(&config), "ORCLPDB1", false), None);
assert_eq!(
visible_schema_filter(Some(&config), "ORCLPDB1", true),
Some(vec!["APP".to_string(), "REPORTING".to_string()])
);
assert_eq!(visible_schema_filter(Some(&config), "OTHER", true), None);
}
#[test]
fn filter_visible_schema_names_preserves_database_order() {
let schemas = vec!["APP".to_string(), "SYS".to_string(), "REPORTING".to_string()];
let visible = vec!["REPORTING".to_string(), "APP".to_string()];
assert_eq!(filter_visible_schema_names(schemas, Some(&visible)), vec!["APP", "REPORTING"]);
}
fn test_table_info(name: &str) -> super::db::TableInfo {
super::db::TableInfo {
name: name.to_string(),

View File

@ -20,6 +20,7 @@ pub struct SchemaQuery {
pub offset: Option<usize>,
pub object_type: Option<dbx_core::db::ObjectSourceKind>,
pub object_types: Option<String>,
pub apply_visible_filter: Option<bool>,
}
pub async fn list_databases(
@ -90,7 +91,14 @@ pub async fn list_schemas(
Query(q): Query<SchemaQuery>,
) -> Result<Json<Vec<String>>, AppError> {
let database = q.database.as_deref().unwrap_or("");
let result = dbx_core::schema::list_schemas_core(&state.app, &q.connection_id, database).await.map_err(AppError)?;
let result = dbx_core::schema::list_schemas_core_with_visible_filter(
&state.app,
&q.connection_id,
database,
q.apply_visible_filter.unwrap_or(false),
)
.await
.map_err(AppError)?;
Ok(Json(result))
}

View File

@ -68,8 +68,15 @@ pub async fn list_schemas(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
apply_visible_filter: Option<bool>,
) -> Result<Vec<String>, String> {
dbx_core::schema::list_schemas_core(&state, &connection_id, &database).await
dbx_core::schema::list_schemas_core_with_visible_filter(
&state,
&connection_id,
&database,
apply_visible_filter.unwrap_or(false),
)
.await
}
#[tauri::command]