Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions frontend/src/components/datasources/__tests__/filter-empty.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { describe, expect, it } from "vitest";
import type {
Database,
DatabaseSchema,
DataTable,
} from "@/core/kernel/messages";
import { filterEmptyDatabases } from "../datasources";

function makeTable(name: string): DataTable {
return {
name,
columns: [],
source: "memory",
source_type: "local",
type: "table",
engine: null,
indexes: null,
num_columns: null,
num_rows: null,
variable_name: null,
primary_keys: null,
};
}

function makeSchema(opts: {
name: string;
tables: DataTable[];
tables_resolved?: boolean;
}): DatabaseSchema {
return {
name: opts.name,
tables: opts.tables,
tables_resolved: opts.tables_resolved ?? true,
};
}

function makeDatabase(
name: string,
schemas: DatabaseSchema[],
schemas_resolved = true,
): Database {
return {
name,
dialect: "duckdb",
schemas,
schemas_resolved,
engine: null,
};
}

describe("filterEmptyDatabases", () => {
it("hides schemas whose tables are resolved and empty", () => {
const databases = [
makeDatabase("memory", [
makeSchema({ name: "main", tables: [makeTable("t1")] }),
makeSchema({ name: "empty_schema", tables: [] }),
]),
];

expect(filterEmptyDatabases(databases)).toEqual([
makeDatabase("memory", [
makeSchema({ name: "main", tables: [makeTable("t1")] }),
]),
]);
});

it("preserves databases whose schemas have not been resolved yet (lazy state)", () => {
const databases = [
makeDatabase("not_loaded_yet", [], /* schemas_resolved */ false),
];

expect(filterEmptyDatabases(databases)).toEqual([
makeDatabase("not_loaded_yet", [], false),
]);
});

it("hides databases that have been resolved as empty", () => {
const databases = [
makeDatabase("really_empty", [], /* schemas_resolved */ true),
makeDatabase("has_tables", [
makeSchema({ name: "main", tables: [makeTable("t1")] }),
]),
];

expect(filterEmptyDatabases(databases)).toEqual([
makeDatabase("has_tables", [
makeSchema({ name: "main", tables: [makeTable("t1")] }),
]),
]);
});

it("hides databases whose schemas all filtered to empty", () => {
const databases = [
makeDatabase("only_empty", [
makeSchema({ name: "a", tables: [] }),
makeSchema({ name: "b", tables: [] }),
]),
makeDatabase("has_tables", [
makeSchema({ name: "main", tables: [makeTable("t1")] }),
]),
];

expect(filterEmptyDatabases(databases)).toEqual([
makeDatabase("has_tables", [
makeSchema({ name: "main", tables: [makeTable("t1")] }),
]),
]);
});

it("treats missing schemas_resolved as resolved (backward compatible)", () => {
const databases = [
{ name: "memory", dialect: "duckdb", schemas: [], engine: null },
] as Database[];

expect(filterEmptyDatabases(databases)).toEqual([]);
});

it("preserves schemas whose tables have not been resolved yet", () => {
const databases = [
makeDatabase("snowflake_db", [
// include_tables=False was used; the schema is not actually empty,
// tables will be fetched lazily on expand.
makeSchema({ name: "public", tables: [], tables_resolved: false }),
makeSchema({ name: "audit", tables: [], tables_resolved: false }),
makeSchema({
name: "really_empty",
tables: [],
tables_resolved: true,
}),
]),
];

expect(filterEmptyDatabases(databases)).toEqual([
makeDatabase("snowflake_db", [
makeSchema({ name: "public", tables: [], tables_resolved: false }),
makeSchema({ name: "audit", tables: [], tables_resolved: false }),
]),
]);
});

it("treats missing tables_resolved as resolved (backward compatible)", () => {
// Older payloads predating the new flag may omit it; default semantics
// treat the schema as resolved/authoritative.
const databases = [
makeDatabase("memory", [
{ name: "main", tables: [makeTable("t1")] },
{ name: "empty_schema", tables: [] },
] as DatabaseSchema[]),
];

expect(filterEmptyDatabases(databases)).toEqual([
makeDatabase("memory", [
{ name: "main", tables: [makeTable("t1")] },
] as DatabaseSchema[]),
]);
});

it("returns the same reference when nothing was filtered", () => {
const databases = [
makeDatabase("memory", [
makeSchema({ name: "main", tables: [makeTable("t1")] }),
]),
];

expect(filterEmptyDatabases(databases)).toBe(databases);
});

it("does not mutate the input", () => {
const databases = [
makeDatabase("memory", [
makeSchema({ name: "main", tables: [makeTable("t1")] }),
makeSchema({ name: "empty_schema", tables: [] }),
]),
];
const snapshot = JSON.parse(JSON.stringify(databases));

filterEmptyDatabases(databases);

expect(databases).toEqual(snapshot);
});
});
110 changes: 107 additions & 3 deletions frontend/src/components/datasources/datasources.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { CommandList } from "cmdk";
import { atom, useAtomValue, useSetAtom } from "jotai";
import { PlusIcon, PlusSquareIcon, XIcon } from "lucide-react";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import {
EyeIcon,
EyeOffIcon,
PlusIcon,
PlusSquareIcon,
XIcon,
} from "lucide-react";
import React from "react";
import { dbDisplayName } from "@/components/databases/display";
import { EngineVariable } from "@/components/databases/engine-variable";
Expand Down Expand Up @@ -52,6 +59,7 @@ import { sortBy } from "@/utils/arrays";
import { logNever } from "@/utils/assertNever";
import { cn } from "@/utils/cn";
import { Events } from "@/utils/events";
import { jotaiJsonStorage } from "@/utils/storage/jotai";
import {
DatabaseIcon,
SchemaIcon,
Expand Down Expand Up @@ -116,6 +124,63 @@ const sortedTablesAtom = atom((get) => {
});
});

/**
* Whether to hide empty schemas and databases (those with no tables) in the
* datasources panel.
*/
export const hideEmptyDatasourcesAtom = atomWithStorage<boolean>(
"marimo:datasources:hideEmpty",
false,
jotaiJsonStorage,
{ getOnInit: true },
);

function isKnownEmptySchema(schema: DatabaseSchema): boolean {
return schema.tables_resolved !== false && schema.tables.length === 0;
}

/**
* Apply the "hide empty" filter to a connection's databases.
*
* - Schemas with confirmed-empty table lists are hidden.
* - Databases are hidden when either (a) their schemas have been enumerated
* and the list is empty, or (b) every schema in them was hidden by the
* schema-level filter.
* - Databases / schemas whose contents haven't been resolved yet (deferred
* discovery — `schemas_resolved === false` or `tables_resolved === false`)
* are preserved so the user can expand them to trigger a fetch.
*/
export function filterEmptyDatabases(databases: Database[]): Database[] {
let changed = false;
const result: Database[] = [];
for (const database of databases) {
// Known-empty database: schema list was enumerated and is empty.
if (database.schemas_resolved !== false && database.schemas.length === 0) {
changed = true;
continue;
}
// Deferred schema discovery — keep so the user can expand and load.
if (database.schemas.length === 0) {
result.push(database);
continue;
}
const visibleSchemas = database.schemas.filter(
(schema) => !isKnownEmptySchema(schema),
);
if (visibleSchemas.length === 0) {
changed = true;
continue;
}
if (visibleSchemas.length === database.schemas.length) {
result.push(database);
continue;
}
changed = true;
result.push({ ...database, schemas: visibleSchemas });
}
return changed ? result : databases;
}

/**
* This atom is used to get the data connections that are available to the user.
* It filters out the internal engines if it has no databases or if it has only the in-memory database and no schemas.
Expand Down Expand Up @@ -152,10 +217,27 @@ export const connectionsAtom = atom((get) => {

export const DataSources: React.FC = () => {
const [searchValue, setSearchValue] = React.useState<string>("");
const [hideEmpty, setHideEmpty] = useAtom(hideEmptyDatasourcesAtom);

const closeAllColumns = useSetAtom(closeAllColumnsAtom);
const tables = useAtomValue(sortedTablesAtom);
const dataConnections = useAtomValue(connectionsAtom);
const rawConnections = useAtomValue(connectionsAtom);

const dataConnections = React.useMemo(() => {
if (!hideEmpty) {
return rawConnections;
}
let changed = false;
const filtered = rawConnections.map((connection) => {
const databases = filterEmptyDatabases(connection.databases);
if (databases === connection.databases) {
return connection;
}
changed = true;
return { ...connection, databases };
});
return changed ? filtered : rawConnections;
}, [rawConnections, hideEmpty]);

if (tables.length === 0 && dataConnections.length === 0) {
return (
Expand Down Expand Up @@ -204,6 +286,28 @@ export const DataSources: React.FC = () => {
</button>
)}

<Tooltip
content={
hideEmpty
? "Show empty schemas and databases"
: "Hide empty schemas and databases"
}
>
<Button
data-testid="datasources-hide-empty-button"
variant="ghost"
size="sm"
className="px-2 rounded-none focus-visible:outline-hidden"
onClick={() => setHideEmpty(!hideEmpty)}
>
{hideEmpty ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
)}
</Button>
</Tooltip>

<AddConnectionDialog>
<Button
variant="ghost"
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/core/datasets/data-source-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ const {
return {
...db,
schemas: schemas,
schemas_resolved: true,
};
}),
};
Expand Down Expand Up @@ -213,6 +214,7 @@ const {
return {
...schema,
tables: tables,
tables_resolved: true,
};
}),
};
Expand Down
16 changes: 15 additions & 1 deletion marimo/_data/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,19 @@ class DataTable(BaseStruct):


class Schema(BaseStruct):
"""
Represents a database schema and its tables.

Attributes:
name (str): The name of the schema.
tables (List[DataTable]): Tables in this schema.
tables_resolved (bool): True when `tables` has been enumerated
False when table discovery was deferred. Defaults to True
"""

name: str
tables: list[DataTable]
tables_resolved: bool = True


class Database(BaseStruct):
Expand All @@ -96,13 +107,16 @@ class Database(BaseStruct):
Attributes:
name (str): The name of the database
dialect (str): The dialect of the database
schemas (List[Schema]): List of schemas in the database
schemas (List[Schema]): List of schemas in the database.
schemas_resolved (bool): True when `schemas` has been enumerated.
False when schema discovery was deferred. Defaults to True
engine (Optional[VariableName]): Database engine or connection handler, if any.
"""

name: str
dialect: str
schemas: list[Schema]
schemas_resolved: bool = True
engine: VariableName | None = None


Expand Down
2 changes: 2 additions & 0 deletions marimo/_sql/connection_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def update_schema_list_in_connection(
continue

database.schemas = updated_schema_list
database.schemas_resolved = True
return


Expand Down Expand Up @@ -84,4 +85,5 @@ def update_table_list_in_connection(
continue

schema.tables = updated_table_list
schema.tables_resolved = True
return
Loading
Loading