diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
index af4d05d881..0396c7efe1 100644
--- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
+++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
@@ -74,6 +74,7 @@ import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { connectionUsesVisibleSchemaFilter } from "@/lib/database/visibleDatabases";
import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout";
import { sidebarConnectionVisibleFilterMenu } from "@/lib/sidebar/sidebarVisibleFilterMenu";
+import { supportsSidebarObjectNameFilter } from "@/lib/sidebar/sidebarObjectNameFilter";
import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout";
import { objectTypesForGroupNode } from "@/lib/table/tableTree";
import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting";
@@ -5658,7 +5659,7 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
disabled: node.isLoading,
});
}
- if (node.type === "group-tables") {
+ if (supportsSidebarObjectNameFilter(node)) {
items.push({
label: t("contextMenu.tableNameFilters"),
action: () => emit("open-table-name-filters", node),
diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue
index e74c532f62..851c10335d 100644
--- a/apps/desktop/src/components/sidebar/TreeItem.vue
+++ b/apps/desktop/src/components/sidebar/TreeItem.vue
@@ -69,6 +69,7 @@ import { connectionBearingGroupIdsUnder, connectionIdsUnderGroup } from "@/lib/s
import { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
+import { supportsSidebarObjectNameFilter } from "@/lib/sidebar/sidebarObjectNameFilter";
import { isWindows } from "@/lib/backend/platform";
import { flattenTree } from "@/composables/useFlatTree";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
@@ -407,8 +408,8 @@ function visibleLabel(node: TreeNode): string {
return withValidity(displayLabel(node));
}
-function hasActiveTableNameFilter(node: TreeNode): boolean {
- if (node.type !== "group-tables" || !node.connectionId || !node.database) return false;
+function hasActiveObjectNameFilter(node: TreeNode): boolean {
+ if (!supportsSidebarObjectNameFilter(node) || !node.connectionId || !node.database) return false;
const filter = connectionStore.tableNameFilterForScope({
connectionId: node.connectionId,
database: node.database,
@@ -1471,7 +1472,7 @@ function onKeydown(event: KeyboardEvent) {
node.objectCount != null
"
class="text-muted-foreground text-[10px] shrink-0"
- >{{ node.objectCount }} · {{ t("tree.tableNameFilterActive") }}{{ node.objectCount }} · {{ t("tree.tableNameFilterActive") }}
{{ t("editor.defaultDatabase") }}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index b48d036e0c..6d8f5ecada 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -2783,9 +2783,9 @@ export default {
refreshChildren: "Refresh",
expandAll: "Expand All",
tableNameFilters: "Filters...",
- tableNameFiltersDescription: "Show only table names that match the include list, then hide names that match the exclude list. One SQL LIKE pattern per line.",
- tableNameFilterInclude: "Include table patterns",
- tableNameFilterExclude: "Exclude table patterns",
+ tableNameFiltersDescription: "Show only object names that match the include list, then hide names that match the exclude list. One SQL LIKE pattern per line.",
+ tableNameFilterInclude: "Include object name patterns",
+ tableNameFilterExclude: "Exclude object name patterns",
tableNameFilterIncludePlaceholder: "Example:\nads_%\nuser_%",
tableNameFilterExcludePlaceholder: "Example:\n%_bak\ntmp_%",
tableNameFilterLikeHint: "Use SQL LIKE syntax: % matches any characters, _ matches one character. Matching is case-insensitive.",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index da50ec6ab5..81a1383632 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -2716,9 +2716,9 @@ export default withEnglishFallback({
refreshChildren: "Actualizar",
expandAll: "Expandir todo",
tableNameFilters: "Filtros...",
- tableNameFiltersDescription: "Muestra solo tablas que coincidan con include y oculta las que coincidan con exclude. Un patrón SQL LIKE por línea.",
- tableNameFilterInclude: "Patrones de tablas incluidas",
- tableNameFilterExclude: "Patrones de tablas excluidas",
+ tableNameFiltersDescription: "Muestra solo objetos que coincidan con include y oculta los que coincidan con exclude. Un patrón SQL LIKE por línea.",
+ tableNameFilterInclude: "Patrones de nombres de objetos incluidos",
+ tableNameFilterExclude: "Patrones de nombres de objetos excluidos",
tableNameFilterIncludePlaceholder: "Ejemplo:\nads_%\nuser_%",
tableNameFilterExcludePlaceholder: "Ejemplo:\n%_bak\ntmp_%",
tableNameFilterLikeHint: "Usa sintaxis SQL LIKE: % coincide con cualquier texto, _ con un carácter. No distingue mayúsculas.",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index afedec294a..7699ea4d98 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -2714,9 +2714,9 @@ export default withEnglishFallback({
refreshChildren: "Aggiorna",
expandAll: "Espandi tutto",
tableNameFilters: "Filtri...",
- tableNameFiltersDescription: "Mostra solo le tabelle che corrispondono alla lista include, poi nasconde quelle che corrispondono alla lista exclude. Un pattern SQL LIKE per riga.",
- tableNameFilterInclude: "Pattern tabelle incluse",
- tableNameFilterExclude: "Pattern tabelle escluse",
+ tableNameFiltersDescription: "Mostra solo gli oggetti che corrispondono alla lista include, poi nasconde quelli che corrispondono alla lista exclude. Un pattern SQL LIKE per riga.",
+ tableNameFilterInclude: "Pattern nomi oggetto inclusi",
+ tableNameFilterExclude: "Pattern nomi oggetto esclusi",
tableNameFilterIncludePlaceholder: "Esempio:\nads_%\nuser_%",
tableNameFilterExcludePlaceholder: "Esempio:\n%_bak\ntmp_%",
tableNameFilterLikeHint: "Usa la sintassi SQL LIKE: % corrisponde a qualsiasi testo, _ a un carattere. Match case-insensitive.",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 47935d8e29..e7eddd20bf 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -2738,9 +2738,9 @@ export default withEnglishFallback({
refreshChildren: "更新",
expandAll: "すべて展開",
tableNameFilters: "フィルター...",
- tableNameFiltersDescription: "Include リストに一致するテーブル名だけを表示し、Exclude リストに一致する名前を非表示にします。1 行に 1 つの SQL LIKE パターンを入力します。",
- tableNameFilterInclude: "含めるテーブル名パターン",
- tableNameFilterExclude: "除外するテーブル名パターン",
+ tableNameFiltersDescription: "Include リストに一致するオブジェクト名だけを表示し、Exclude リストに一致する名前を非表示にします。1 行に 1 つの SQL LIKE パターンを入力します。",
+ tableNameFilterInclude: "含めるオブジェクト名パターン",
+ tableNameFilterExclude: "除外するオブジェクト名パターン",
tableNameFilterIncludePlaceholder: "例:\nads_%\nuser_%",
tableNameFilterExcludePlaceholder: "例:\n%_bak\ntmp_%",
tableNameFilterLikeHint: "SQL LIKE 構文を使用します。% は任意の文字列、_ は 1 文字に一致します。大文字小文字は区別しません。",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index c8fbdd7201..f6a7b7b7a0 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -2625,9 +2625,9 @@ export default withEnglishFallback({
refreshChildren: "새로고침",
expandAll: "모두 펼치기",
tableNameFilters: "필터...",
- tableNameFiltersDescription: "포함 목록과 일치하는 테이블 이름만 표시한 다음 제외 목록과 일치하는 이름을 숨깁니다. 한 줄에 하나의 SQL LIKE 패턴.",
- tableNameFilterInclude: "포함 테이블 패턴",
- tableNameFilterExclude: "제외 테이블 패턴",
+ tableNameFiltersDescription: "포함 목록과 일치하는 객체 이름만 표시한 다음 제외 목록과 일치하는 이름을 숨깁니다. 한 줄에 하나의 SQL LIKE 패턴.",
+ tableNameFilterInclude: "포함 객체 이름 패턴",
+ tableNameFilterExclude: "제외 객체 이름 패턴",
tableNameFilterIncludePlaceholder: "예:\nads_%\nuser_%",
tableNameFilterExcludePlaceholder: "예:\n%_bak\ntmp_%",
tableNameFilterLikeHint: "SQL LIKE 문법을 사용하세요: %는 임의의 문자, _는 한 문자와 일치합니다. 대소문자를 구분하지 않습니다.",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index a085b019b7..b54deed993 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -2716,9 +2716,9 @@ export default withEnglishFallback({
refreshChildren: "Atualizar",
expandAll: "Expandir tudo",
tableNameFilters: "Filtros...",
- tableNameFiltersDescription: "Mostra apenas tabelas que correspondem à lista include e oculta as que correspondem à lista exclude. Um padrão SQL LIKE por linha.",
- tableNameFilterInclude: "Padrões de tabelas incluídas",
- tableNameFilterExclude: "Padrões de tabelas excluídas",
+ tableNameFiltersDescription: "Mostra apenas objetos que correspondem à lista include e oculta os que correspondem à lista exclude. Um padrão SQL LIKE por linha.",
+ tableNameFilterInclude: "Padrões de nomes de objetos incluídos",
+ tableNameFilterExclude: "Padrões de nomes de objetos excluídos",
tableNameFilterIncludePlaceholder: "Exemplo:\nads_%\nuser_%",
tableNameFilterExcludePlaceholder: "Exemplo:\n%_bak\ntmp_%",
tableNameFilterLikeHint: "Use sintaxe SQL LIKE: % corresponde a qualquer texto, _ a um caractere. A comparação ignora maiúsculas.",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 57275e7e0a..4880a54faf 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -2707,9 +2707,9 @@ export default withEnglishFallback({
refreshChildren: "刷新",
expandAll: "展开全部",
tableNameFilters: "过滤器...",
- tableNameFiltersDescription: "先仅显示命中 include 列表的表名,再隐藏命中 exclude 列表的表名。每行一个 SQL LIKE 模式。",
- tableNameFilterInclude: "包含表名模式",
- tableNameFilterExclude: "排除表名模式",
+ tableNameFiltersDescription: "先仅显示命中 include 列表的对象名称,再隐藏命中 exclude 列表的对象名称。每行一个 SQL LIKE 模式。",
+ tableNameFilterInclude: "包含对象名称模式",
+ tableNameFilterExclude: "排除对象名称模式",
tableNameFilterIncludePlaceholder: "例如:\nads_%\nuser_%",
tableNameFilterExcludePlaceholder: "例如:\n%_bak\ntmp_%",
tableNameFilterLikeHint: "使用 SQL LIKE 语法:% 匹配任意字符,_ 匹配单个字符。匹配时不区分大小写。",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 0cc0761ea1..3dac8434ff 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -2714,9 +2714,9 @@ export default withEnglishFallback({
refreshChildren: "重新整理",
expandAll: "展開全部",
tableNameFilters: "篩選器...",
- tableNameFiltersDescription: "先僅顯示符合 include 清單的資料表名稱,再隱藏符合 exclude 清單的資料表名稱。每行一個 SQL LIKE 模式。",
- tableNameFilterInclude: "包含資料表名稱模式",
- tableNameFilterExclude: "排除資料表名稱模式",
+ tableNameFiltersDescription: "先僅顯示符合 include 清單的物件名稱,再隱藏符合 exclude 清單的物件名稱。每行一個 SQL LIKE 模式。",
+ tableNameFilterInclude: "包含物件名稱模式",
+ tableNameFilterExclude: "排除物件名稱模式",
tableNameFilterIncludePlaceholder: "範例:\nads_%\nuser_%",
tableNameFilterExcludePlaceholder: "範例:\n%_bak\ntmp_%",
tableNameFilterLikeHint: "使用 SQL LIKE 語法:% 符合任意字元,_ 符合單一字元。比對不區分大小寫。",
diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectNameFilter.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectNameFilter.spec.ts
new file mode 100644
index 0000000000..99c8123496
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectNameFilter.spec.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from "vitest";
+import { supportsSidebarObjectNameFilter } from "../../sidebar/sidebarObjectNameFilter";
+
+describe("supportsSidebarObjectNameFilter", () => {
+ it("allows only supported object groups", () => {
+ const supportedTypes = ["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions"] as const;
+ expect(supportedTypes.every((type) => supportsSidebarObjectNameFilter({ type, parentType: undefined }))).toBe(true);
+ expect(supportsSidebarObjectNameFilter({ type: "group-indexes", parentType: undefined })).toBe(false);
+ expect(supportsSidebarObjectNameFilter({ type: "group-triggers", parentType: undefined })).toBe(false);
+ expect(supportsSidebarObjectNameFilter({ type: "group-events", parentType: undefined })).toBe(false);
+ expect(supportsSidebarObjectNameFilter({ type: "group-functions", parentType: "package" })).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index b688e77405..e3ff1ba1e9 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -829,7 +829,7 @@ export async function getTableComment(_connectionId: string, _database: string,
throw new Error("Table comment lookup is not available in the web backend");
}
-export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: (SidebarObjectKind | "EVENT")[], filter?: string, limit?: number, offset?: number, catalog?: string): Promise {
+export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: (SidebarObjectKind | "EVENT")[], filter?: string, limit?: number, offset?: number, catalog?: string, tableNameFilter?: TableNameFilter): Promise {
return get(
`/api/schema/objects?${qs({
connection_id: connectionId,
@@ -840,6 +840,7 @@ export async function listObjects(connectionId: string, database: string, schema
limit,
offset,
catalog,
+ table_name_filter: tableNameFilter ? JSON.stringify(tableNameFilter) : undefined,
})}`,
);
}
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index 5e09d242c3..79ceb6e360 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -1093,7 +1093,7 @@ export async function getTableComment(connectionId: string, database: string, sc
});
}
-export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: (SidebarObjectKind | "EVENT")[], filter?: string, limit?: number, offset?: number, catalog?: string): Promise {
+export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: (SidebarObjectKind | "EVENT")[], filter?: string, limit?: number, offset?: number, catalog?: string, tableNameFilter?: import("@/types/database").TableNameFilter): Promise {
return invoke("list_objects", {
connectionId,
database,
@@ -1103,6 +1103,7 @@ export async function listObjects(connectionId: string, database: string, schema
limit,
offset,
catalog,
+ tableNameFilter,
});
}
diff --git a/apps/desktop/src/lib/sidebar/sidebarObjectNameFilter.ts b/apps/desktop/src/lib/sidebar/sidebarObjectNameFilter.ts
new file mode 100644
index 0000000000..4d611d30f6
--- /dev/null
+++ b/apps/desktop/src/lib/sidebar/sidebarObjectNameFilter.ts
@@ -0,0 +1,12 @@
+import type { TreeNode, TreeNodeType } from "@/types/database";
+
+const sidebarObjectNameFilterGroupTypes: ReadonlySet = new Set(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions"]);
+
+type SidebarObjectNameFilterTarget = {
+ type: TreeNodeType;
+ parentType?: TreeNode["parentType"];
+};
+
+export function supportsSidebarObjectNameFilter(node: SidebarObjectNameFilterTarget): boolean {
+ return node.parentType !== "package" && sidebarObjectNameFilterGroupTypes.has(node.type);
+}
diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts
index 8f6fae45ab..8059c73004 100644
--- a/apps/desktop/src/stores/connectionStore.ts
+++ b/apps/desktop/src/stores/connectionStore.ts
@@ -1715,9 +1715,9 @@ export const useConnectionStore = defineStore("connection", () => {
tableListSourceRevisions.set(connectionId, tableListSourceRevision(connectionId) + 1);
}
- function tableNameFilterMetadataExtra(filter: TableNameFilter | undefined, sourceRevision: number): MetadataScopeInput["extra"] {
+ function tableNameFilterMetadataExtra(filter: TableNameFilter | undefined, sourceRevision?: number): MetadataScopeInput["extra"] {
return {
- tableListSourceRevision: sourceRevision,
+ ...(sourceRevision === undefined ? {} : { tableListSourceRevision: sourceRevision }),
...(filter
? {
tableNameFilterInclude: filter.includePatterns,
@@ -2066,6 +2066,14 @@ export const useConnectionStore = defineStore("connection", () => {
return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset };
}
const searchFilter = options.searchFilter || undefined;
+ const userTableNameFilter = activeTableNameFilterForScope({
+ connectionId: options.node.connectionId,
+ database: options.node.database,
+ schema: options.node.schema,
+ nodeKind: options.node.type,
+ catalog: options.node.catalog,
+ });
+ const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter);
const fetchLimit = searchFilter ? undefined : options.pageSize + 1;
const fetchOffset = searchFilter ? undefined : options.offset;
const objects = await loadCachedMetadataListPage(
@@ -2080,8 +2088,12 @@ export const useConnectionStore = defineStore("connection", () => {
limit: fetchLimit,
offset: fetchOffset,
sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay,
+ extra: tableNameFilterMetadataExtra(tableNameFilter),
}),
- () => api.listObjects(options.node.connectionId!, options.node.database!, options.querySchema, options.objectTypes, searchFilter, fetchLimit, fetchOffset),
+ () =>
+ tableNameFilter || options.node.catalog
+ ? api.listObjects(options.node.connectionId!, options.node.database!, options.querySchema, options.objectTypes, searchFilter, fetchLimit, fetchOffset, options.node.catalog, tableNameFilter)
+ : api.listObjects(options.node.connectionId!, options.node.database!, options.querySchema, options.objectTypes, searchFilter, fetchLimit, fetchOffset),
{ force: options.force },
);
const hasMore = searchFilter ? false : objects.length > options.pageSize;
diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs
index e63852f145..65882b34d9 100644
--- a/crates/dbx-core/src/database_export.rs
+++ b/crates/dbx-core/src/database_export.rs
@@ -2103,6 +2103,7 @@ async fn export_database_sql_core_inner(
None,
None,
None,
+ None,
)
.await
{
diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs
index 801df39533..dc2e477a02 100644
--- a/crates/dbx-core/src/schema.rs
+++ b/crates/dbx-core/src/schema.rs
@@ -2523,6 +2523,7 @@ fn filter_object_infos(
limit: Option,
offset: Option,
object_types: Option<&[String]>,
+ table_name_filter: Option<&TableNameFilter>,
) -> Vec {
let filter = filter.unwrap_or("");
let limit = limit.unwrap_or(usize::MAX);
@@ -2530,6 +2531,7 @@ fn filter_object_infos(
objects
.into_iter()
.filter(|object| metadata_name_or_comment_matches(&object.name, object.comment.as_deref(), filter))
+ .filter(|object| table_name_filter_matches(&object.name, table_name_filter))
.filter(|object| object_info_matches_object_types(object, object_types))
.skip(offset)
.take(limit)
@@ -4224,6 +4226,26 @@ for line in sys.stdin:
assert_eq!(filtered.into_iter().map(|table| table.name).collect::>(), vec!["monthly_orders_mv"]);
}
+ #[test]
+ fn filter_object_infos_applies_sql_like_name_filter() {
+ let objects = vec![
+ test_object_info("fn_get_user", "FUNCTION"),
+ test_object_info("fn_get_role", "FUNCTION"),
+ test_object_info("fn_get_role_bak", "FUNCTION"),
+ test_object_info("internal_hash", "FUNCTION"),
+ ];
+ let object_types = vec!["FUNCTION".to_string()];
+ let name_filter =
+ TableNameFilter { include_patterns: vec!["FN_%".to_string()], exclude_patterns: vec!["%_BAK".to_string()] };
+
+ let filtered = filter_object_infos(objects, None, None, None, Some(&object_types), Some(&name_filter));
+
+ assert_eq!(
+ filtered.into_iter().map(|object| object.name).collect::>(),
+ vec!["fn_get_user", "fn_get_role"]
+ );
+ }
+
#[test]
fn filter_object_infos_filters_object_type_before_offset_and_limit() {
let objects = vec![
@@ -4234,7 +4256,7 @@ for line in sys.stdin:
];
let object_types = vec!["FUNCTION".to_string()];
- let filtered = filter_object_infos(objects, Some("fn"), Some(1), Some(1), Some(&object_types));
+ let filtered = filter_object_infos(objects, Some("fn"), Some(1), Some(1), Some(&object_types), None);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "fetch_name");
@@ -4250,7 +4272,7 @@ for line in sys.stdin:
];
let object_types = vec!["MATERIALIZED_VIEW".to_string()];
- let filtered = filter_object_infos(objects, Some("orders"), Some(1), Some(1), Some(&object_types));
+ let filtered = filter_object_infos(objects, Some("orders"), Some(1), Some(1), Some(&object_types), None);
assert_eq!(filtered.into_iter().map(|object| object.name).collect::>(), vec!["monthly_orders_mv"]);
}
@@ -4264,7 +4286,7 @@ for line in sys.stdin:
let objects = vec![order_view, sync_user, test_object_info("audit_log", "TABLE")];
let object_types = vec!["VIEW".to_string()];
- let filtered = filter_object_infos(objects, Some("revenue"), None, None, Some(&object_types));
+ let filtered = filter_object_infos(objects, Some("revenue"), None, None, Some(&object_types), None);
assert_eq!(filtered.into_iter().map(|object| object.name).collect::>(), vec!["order_view"]);
}
@@ -5024,13 +5046,16 @@ pub async fn list_objects_core(
limit: Option,
offset: Option,
object_types: Option<&[String]>,
+ table_name_filter: Option<&TableNameFilter>,
) -> Result, String> {
let db_config = connection_config(state, connection_id).await;
let filter_locally_after_oracle_comments = db_config.as_ref().is_some_and(|config| {
config.db_type == DatabaseType::Oracle && filter.is_some_and(|filter| !filter.trim().is_empty())
});
- let use_oracle_agent_paging =
- db_config.as_ref().is_some_and(is_default_oracle_agent_config) && !filter_locally_after_oracle_comments;
+ let force_local_table_name_filter = table_name_filter.is_some_and(|filter| !filter.is_empty());
+ let use_oracle_agent_paging = db_config.as_ref().is_some_and(is_default_oracle_agent_config)
+ && !filter_locally_after_oracle_comments
+ && !force_local_table_name_filter;
let metadata_session = EphemeralAgentMetadataSession::open(state, connection_id, Some(database), "objects").await;
let result = retry_metadata_connection_for_session(
state,
@@ -5047,6 +5072,7 @@ pub async fn list_objects_core(
limit,
offset,
object_types,
+ table_name_filter,
metadata_session.client_session_id(),
)
.await
@@ -5058,7 +5084,7 @@ pub async fn list_objects_core(
} else {
offset
};
- filter_object_infos(outcome.objects, filter, limit, final_offset, object_types)
+ filter_object_infos(outcome.objects, filter, limit, final_offset, object_types, table_name_filter)
})?;
Ok(objects)
},
@@ -5481,13 +5507,20 @@ async fn list_objects_once(
limit: Option,
offset: Option,
object_types: Option<&[String]>,
+ table_name_filter: Option<&TableNameFilter>,
client_session_id: Option<&str>,
) -> Result {
let pool_key =
state.get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id).await?;
let db_config = connection_config(state, connection_id).await;
- let (mysql_limit, mysql_offset) =
- if filter.is_none_or(|value| value.trim().is_empty()) { (limit, offset) } else { (None, None) };
+ let force_local_table_name_filter = table_name_filter.is_some_and(|filter| !filter.is_empty());
+ let (mysql_limit, mysql_offset) = if filter.is_none_or(|value| value.trim().is_empty())
+ && !table_name_filter.is_some_and(|filter| !filter.is_empty())
+ {
+ (limit, offset)
+ } else {
+ (None, None)
+ };
{
let connections = state.connections.read().await;
@@ -5544,14 +5577,14 @@ async fn list_objects_once(
}
let mut client = client.lock().await;
let agent_filter = if filter_locally_after_oracle_comments { None } else { filter };
- let agent_limit = if filter_locally_after_oracle_comments {
+ let agent_limit = if filter_locally_after_oracle_comments || force_local_table_name_filter {
None
} else if use_oracle_agent_paging {
limit
} else {
None
};
- let agent_offset = if filter_locally_after_oracle_comments {
+ let agent_offset = if filter_locally_after_oracle_comments || force_local_table_name_filter {
None
} else if use_oracle_agent_paging {
offset
@@ -5818,7 +5851,7 @@ async fn list_completion_objects_once(
}
PoolKind::SqlServer(_) => {
let outcome =
- list_objects_once(state, connection_id, database, schema, None, None, None, None, None).await?;
+ list_objects_once(state, connection_id, database, schema, None, None, None, None, None, None).await?;
Ok(filter_completion_objects(outcome.objects))
}
_ => Ok(Vec::new()),
diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs
index 1c358c9947..53d4f55b56 100644
--- a/crates/dbx-web/src/routes/schema.rs
+++ b/crates/dbx-web/src/routes/schema.rs
@@ -240,6 +240,10 @@ pub async fn list_objects(
let object_types = q.object_types.as_ref().map(|value| {
value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(str::to_string).collect::>()
});
+ let table_name_filter = q
+ .table_name_filter
+ .as_deref()
+ .and_then(|value| serde_json::from_str::(value).ok());
let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await {
let tables = dbx_core::schema::list_doris_catalog_tables_core(
&state.app,
@@ -250,7 +254,7 @@ pub async fn list_objects(
q.limit,
q.offset,
object_types.as_deref(),
- None,
+ table_name_filter.as_ref(),
)
.await
.map_err(AppError::from)?;
@@ -283,6 +287,7 @@ pub async fn list_objects(
q.limit,
q.offset,
object_types.as_deref(),
+ table_name_filter.as_ref(),
)
.await
.map_err(AppError::from)?
diff --git a/packages/app-tests/sidebarObjectNameFilter.test.ts b/packages/app-tests/sidebarObjectNameFilter.test.ts
new file mode 100644
index 0000000000..e07d11b127
--- /dev/null
+++ b/packages/app-tests/sidebarObjectNameFilter.test.ts
@@ -0,0 +1,140 @@
+import { beforeEach, afterEach, expect, test, vi } from "vitest";
+import { createPinia, setActivePinia } from "pinia";
+import type { ConnectionConfig, TreeNode } from "../../apps/desktop/src/types/database.ts";
+
+function installMemoryStorage() {
+ const values = new Map();
+ vi.stubGlobal("localStorage", {
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => values.set(key, value),
+ removeItem: (key: string) => values.delete(key),
+ });
+}
+
+function connection(): ConnectionConfig {
+ return {
+ id: "pg-1",
+ name: "Postgres",
+ db_type: "postgres",
+ host: "127.0.0.1",
+ port: 5432,
+ username: "postgres",
+ password: "",
+ database: "app",
+ } as ConnectionConfig;
+}
+
+beforeEach(() => {
+ vi.resetModules();
+ vi.unstubAllGlobals();
+ installMemoryStorage();
+ setActivePinia(createPinia());
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+test("object-group filters keep table, view, procedure, and function scopes isolated", async () => {
+ const listTables = vi.fn(async (...args: unknown[]) => {
+ const objectTypes = args[6] as string[] | undefined;
+ if (objectTypes?.[0] === "TABLE") return [{ name: "tbl_orders", table_type: "BASE TABLE", comment: null }];
+ if (objectTypes?.[0] === "VIEW") return [{ name: "vw_orders", table_type: "VIEW", comment: null }];
+ return [];
+ });
+ const listObjects = vi.fn(async (...args: unknown[]) => {
+ const objectTypes = args[3] as string[] | undefined;
+ const filter = args[8] as { includePatterns: string[]; excludePatterns: string[] } | undefined;
+ if (objectTypes?.[0] === "PROCEDURE" && filter?.includePatterns.includes("proc_%")) return [{ name: "proc_sync_orders", object_type: "PROCEDURE" }];
+ if (objectTypes?.[0] === "FUNCTION" && filter?.includePatterns.includes("fn_%")) return [{ name: "fn_get_user", object_type: "FUNCTION" }];
+ if (objectTypes?.[0] === "FUNCTION" && !filter)
+ return [
+ { name: "fn_get_user", object_type: "FUNCTION" },
+ { name: "internal_hash", object_type: "FUNCTION" },
+ ];
+ return [];
+ });
+
+ vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
+ vi.doMock("@/lib/backend/api", () => ({
+ checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
+ deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
+ listObjects,
+ 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("../../apps/desktop/src/stores/connectionStore.ts");
+ const store = useConnectionStore();
+ const currentConnection = connection();
+ const group = (type: TreeNode["type"]): TreeNode => ({
+ id: `pg-1:app:public:__${type.slice("group-".length)}`,
+ label: type,
+ type,
+ connectionId: currentConnection.id,
+ database: "app",
+ schema: "public",
+ isExpanded: false,
+ children: [],
+ });
+ const groups = [group("group-tables"), group("group-views"), group("group-procedures"), group("group-functions")];
+ const schemaNode: TreeNode = {
+ id: "pg-1:app:public",
+ label: "public",
+ type: "schema",
+ connectionId: currentConnection.id,
+ database: "app",
+ schema: "public",
+ isExpanded: true,
+ children: groups,
+ };
+ store.connections = [currentConnection];
+ store.connectedIds.add(currentConnection.id);
+ store.treeNodes = [
+ {
+ id: currentConnection.id,
+ label: currentConnection.name,
+ type: "connection",
+ connectionId: currentConnection.id,
+ isExpanded: true,
+ children: [{ id: "pg-1:app", label: "app", type: "database", connectionId: currentConnection.id, database: "app", isExpanded: true, children: [schemaNode] }],
+ },
+ ];
+
+ const liveGroup = (type: TreeNode["type"]): TreeNode => {
+ const node = store.treeNodes[0]?.children?.[0]?.children?.[0]?.children?.find((child: TreeNode) => child.type === type);
+ if (!node) throw new Error(`Missing object group: ${type}`);
+ return node;
+ };
+ const filters = [
+ ["group-tables", { includePatterns: ["tbl_%"], excludePatterns: [] }],
+ ["group-views", { includePatterns: ["vw_%"], excludePatterns: [] }],
+ ["group-procedures", { includePatterns: ["proc_%"], excludePatterns: [] }],
+ ["group-functions", { includePatterns: ["fn_%"], excludePatterns: ["%_bak"] }],
+ ] as const;
+ for (const [type, filter] of filters) {
+ const node = liveGroup(type);
+ const scopeKey = store.tableNameFilterScopeKey({ connectionId: currentConnection.id, database: "app", schema: "public", nodeKind: node.type });
+ const revision = store.setSidebarTableNameFilter(scopeKey, filter);
+ await store.refreshTreeNodeForTableNameFilter(node, scopeKey, revision);
+ }
+
+ const liveGroups = filters.map(([type]) => liveGroup(type));
+ expect(new Set(liveGroups.map((node) => store.tableNameFilterScopeKey({ connectionId: currentConnection.id, database: "app", schema: "public", nodeKind: node.type }))).size).toBe(4);
+ expect(liveGroups.map((node) => node.children?.map((child) => child.label))).toEqual([["tbl_orders"], ["vw_orders"], ["proc_sync_orders"], ["fn_get_user"]]);
+ expect(store.tableNameFilterForScope({ connectionId: currentConnection.id, database: "app", schema: "public", nodeKind: "group-functions" })).toEqual({
+ includePatterns: ["fn_%"],
+ excludePatterns: ["%_bak"],
+ });
+
+ const functionGroup = liveGroup("group-functions");
+ const functionScopeKey = store.tableNameFilterScopeKey({ connectionId: currentConnection.id, database: "app", schema: "public", nodeKind: functionGroup.type });
+ const clearRevision = store.setSidebarTableNameFilter(functionScopeKey, { includePatterns: [], excludePatterns: [] });
+ await store.refreshTreeNodeForTableNameFilter(functionGroup, functionScopeKey, clearRevision);
+
+ expect(liveGroup("group-functions").children?.map((child) => child.label)).toEqual(["fn_get_user", "internal_hash"]);
+ expect(listObjects.mock.calls.at(-1)).toEqual([currentConnection.id, "app", "public", ["FUNCTION"], undefined, 1001, 0]);
+}, 15000);
diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs
index a15def4876..6cf9162937 100644
--- a/src-tauri/src/commands/schema.rs
+++ b/src-tauri/src/commands/schema.rs
@@ -221,6 +221,7 @@ pub async fn list_objects(
offset: Option,
object_types: Option>,
catalog: Option,
+ table_name_filter: Option,
) -> Result, String> {
if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await {
let tables = dbx_core::schema::list_doris_catalog_tables_core(
@@ -232,7 +233,7 @@ pub async fn list_objects(
limit,
offset,
object_types.as_deref(),
- None,
+ table_name_filter.as_ref(),
)
.await?;
return Ok(tables
@@ -264,6 +265,7 @@ pub async fn list_objects(
limit,
offset,
object_types.as_deref(),
+ table_name_filter.as_ref(),
)
.await
}