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
130 changes: 110 additions & 20 deletions frontend/src/components/datasources/__tests__/filter-empty.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
/* Copyright 2026 Marimo. All rights reserved. */

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

function makeTable(name: string): DataTable {
Expand All @@ -20,68 +24,154 @@ function makeTable(name: string): DataTable {
};
}

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: Array<{ name: string; tables: DataTable[] }>,
schemas: DatabaseSchema[],
schemas_resolved = true,
): Database {
return {
name,
dialect: "duckdb",
schemas,
schemas_resolved,
engine: null,
};
}

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

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

it("hides databases where every schema is empty", () => {
it("preserves databases whose schemas have not been resolved yet (lazy state)", () => {
const databases = [
makeDatabase("only_empty", [
{ name: "a", tables: [] },
{ name: "b", tables: [] },
]),
makeDatabase("has_tables", [{ name: "main", tables: [makeTable("t1")] }]),
makeDatabase("not_loaded_yet", [], /* schemas_resolved */ false),
];

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

it("preserves databases with no schemas (lazy state)", () => {
const databases = [makeDatabase("not_loaded_yet", [])];
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("not_loaded_yet", []),
makeDatabase("has_tables", [
makeSchema({ name: "main", tables: [makeTable("t1")] }),
]),
]);
});

it("returns an empty list when all databases are empty", () => {
it("hides databases whose schemas all filtered to empty", () => {
const databases = [
makeDatabase("a", [{ name: "main", tables: [] }]),
makeDatabase("b", [{ name: "main", tables: [] }]),
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("does not mutate the input", () => {
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));
Expand Down
32 changes: 22 additions & 10 deletions frontend/src/components/datasources/datasources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,36 +135,48 @@ export const hideEmptyDatasourcesAtom = atomWithStorage<boolean>(
{ 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 no tables are hidden.
* - Databases are hidden when they have at least one schema and every schema
* is empty.
* - Databases with no schemas yet (lazy state) are preserved so users can
* still expand them to trigger a schema fetch.
* - 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 nonEmptySchemas = database.schemas.filter(
(schema) => schema.tables.length > 0,
const visibleSchemas = database.schemas.filter(
(schema) => !isKnownEmptySchema(schema),
);
if (nonEmptySchemas.length === 0) {
if (visibleSchemas.length === 0) {
changed = true;
continue;
}
if (nonEmptySchemas.length === database.schemas.length) {
if (visibleSchemas.length === database.schemas.length) {
result.push(database);
continue;
}
changed = true;
result.push({ ...database, schemas: nonEmptySchemas });
result.push({ ...database, schemas: visibleSchemas });
}
return changed ? result : databases;
}
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
9 changes: 8 additions & 1 deletion marimo/_sql/engines/adbc.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,13 +253,20 @@ def get_databases(
)
)

schemas.append(Schema(name=schema_name, tables=tables))
schemas.append(
Schema(
name=schema_name,
tables=tables,
tables_resolved=include_tables_bool,
)
)

databases.append(
Database(
name=catalog_name,
dialect=self._dialect,
schemas=schemas,
schemas_resolved=include_schemas_bool,
engine=self._engine_name,
)
)
Expand Down
Loading
Loading