diff --git a/frontend/src/components/datasources/__tests__/filter-empty.test.ts b/frontend/src/components/datasources/__tests__/filter-empty.test.ts index 2b07ec6e850..5d157ae9eaf 100644 --- a/frontend/src/components/datasources/__tests__/filter-empty.test.ts +++ b/frontend/src/components/datasources/__tests__/filter-empty.test.ts @@ -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 { @@ -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)); diff --git a/frontend/src/components/datasources/datasources.tsx b/frontend/src/components/datasources/datasources.tsx index 0cf8b50f298..7e9855143c6 100644 --- a/frontend/src/components/datasources/datasources.tsx +++ b/frontend/src/components/datasources/datasources.tsx @@ -135,36 +135,48 @@ export const hideEmptyDatasourcesAtom = atomWithStorage( { 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; } diff --git a/frontend/src/core/datasets/data-source-connections.ts b/frontend/src/core/datasets/data-source-connections.ts index 475ebadeece..d205a3f4c3a 100644 --- a/frontend/src/core/datasets/data-source-connections.ts +++ b/frontend/src/core/datasets/data-source-connections.ts @@ -169,6 +169,7 @@ const { return { ...db, schemas: schemas, + schemas_resolved: true, }; }), }; @@ -213,6 +214,7 @@ const { return { ...schema, tables: tables, + tables_resolved: true, }; }), }; diff --git a/marimo/_data/models.py b/marimo/_data/models.py index fe63e666c68..40fc9a9721c 100644 --- a/marimo/_data/models.py +++ b/marimo/_data/models.py @@ -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): @@ -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 diff --git a/marimo/_sql/connection_utils.py b/marimo/_sql/connection_utils.py index 3e16a149cae..7b53d042eb7 100644 --- a/marimo/_sql/connection_utils.py +++ b/marimo/_sql/connection_utils.py @@ -56,6 +56,7 @@ def update_schema_list_in_connection( continue database.schemas = updated_schema_list + database.schemas_resolved = True return @@ -84,4 +85,5 @@ def update_table_list_in_connection( continue schema.tables = updated_table_list + schema.tables_resolved = True return diff --git a/marimo/_sql/engines/adbc.py b/marimo/_sql/engines/adbc.py index c7c7427a9ab..5b9c428ecca 100644 --- a/marimo/_sql/engines/adbc.py +++ b/marimo/_sql/engines/adbc.py @@ -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, ) ) diff --git a/marimo/_sql/engines/clickhouse.py b/marimo/_sql/engines/clickhouse.py index c0f311b8803..ddba96eb040 100644 --- a/marimo/_sql/engines/clickhouse.py +++ b/marimo/_sql/engines/clickhouse.py @@ -327,6 +327,9 @@ def get_databases( ) return databases + include_tables_bool = self._resolve_should_auto_discover( + include_tables + ) include_table_details = self._resolve_should_auto_discover( include_table_details ) @@ -339,13 +342,17 @@ def get_databases( for db in db_names: db_name = cast(str, db) # Skip introspection for meta tables for performance - if db_name.lower() in self._meta_dbs or not include_tables: - tables = [] + is_meta_db = db_name.lower() in self._meta_dbs + if is_meta_db or not include_tables_bool: + tables: list[DataTable] = [] + tables_resolved = False else: - tables = self.get_tables_in_schema( - schema=NO_SCHEMA_NAME, - database=db, - include_table_details=include_table_details, + tables, tables_resolved = ( + self._get_tables_in_schema_with_resolution( + schema=NO_SCHEMA_NAME, + database=db, + include_table_details=include_table_details, + ) ) databases.append( Database( @@ -353,7 +360,13 @@ def get_databases( dialect=self.dialect, engine=self._engine_name, # ClickHouse does not have schemas - schemas=[Schema(name=NO_SCHEMA_NAME, tables=tables)], + schemas=[ + Schema( + name=NO_SCHEMA_NAME, + tables=tables, + tables_resolved=tables_resolved, + ) + ], ) ) return databases @@ -384,9 +397,29 @@ def get_tables_in_schema( Returns: List of DataTable objects. """ + tables, _ = self._get_tables_in_schema_with_resolution( + schema=schema, + database=database, + include_table_details=include_table_details, + ) + return tables + + def _get_tables_in_schema_with_resolution( + self, + *, + schema: str, + database: str, + include_table_details: bool, + ) -> tuple[list[DataTable], bool]: + """ + Return tables along with whether the list is authoritative. + + `False` means table enumeration failed or table details were requested + but could not be loaded for every table. + """ _ = schema # ClickHouse does not have schemas if self._connection is None: - return [] + return [], False tables: list[DataTable] = [] try: @@ -395,18 +428,19 @@ def get_tables_in_schema( table_df = self._connection.query_df(query) except Exception: LOGGER.warning( - f"Failed to get tables from database {database}", exc_info=True + f"Failed to get tables from database {database}", + exc_info=True, ) - return tables + return tables, False import pandas as pd if not isinstance(table_df, pd.DataFrame): LOGGER.warning("Failed to convert table result to DataFrame") - return tables + return tables, False if table_df.empty: - return tables + return tables, True # Assume the first column contains table names. table_names = table_df[table_df.columns[0]].tolist() @@ -433,7 +467,7 @@ def get_tables_in_schema( indexes=[], ) ) - return tables + return tables, len(tables) == len(table_names) def get_table_details( self, *, table_name: str, schema_name: str, database_name: str diff --git a/marimo/_sql/engines/ibis.py b/marimo/_sql/engines/ibis.py index ddea601a135..9a9fbd38ee7 100644 --- a/marimo/_sql/engines/ibis.py +++ b/marimo/_sql/engines/ibis.py @@ -164,9 +164,11 @@ def get_databases( LOGGER.debug("Failed to get databases", exc_info=True) return [] + schemas_resolved = self._resolve_should_auto_discover(include_schemas) + for database_name in database_names: database_name_str = str(database_name) - if self._resolve_should_auto_discover(include_schemas): + if schemas_resolved: schemas = self.get_schemas( database=database_name_str, include_tables=self._resolve_should_auto_discover( @@ -183,6 +185,7 @@ def get_databases( name=database_name_str, dialect=self.dialect, schemas=schemas, + schemas_resolved=schemas_resolved, engine=self._engine_name, ) @@ -242,7 +245,11 @@ def get_schemas( include_table_details=include_table_details, ) - schema = Schema(name=schema_name, tables=tables) + schema = Schema( + name=schema_name, + tables=tables, + tables_resolved=include_tables, + ) schemas.append(schema) return schemas diff --git a/marimo/_sql/engines/pyiceberg.py b/marimo/_sql/engines/pyiceberg.py index 80dddafc10d..28be5456bbd 100644 --- a/marimo/_sql/engines/pyiceberg.py +++ b/marimo/_sql/engines/pyiceberg.py @@ -91,13 +91,14 @@ def get_databases( del include_schemas databases: list[Database] = [] + tables_resolved = self._resolve_should_auto_discover(include_tables) try: namespaces = sorted( self._connection.list_namespaces() ) # Sort for consistent ordering for namespace in namespaces: tables = [] - if self._resolve_should_auto_discover(include_tables): + if tables_resolved: tables = self.get_tables_in_schema( schema=NO_SCHEMA_NAME, database=Catalog.identifier_to_database(namespace), @@ -114,6 +115,7 @@ def get_databases( Schema( name=NO_SCHEMA_NAME, tables=tables, + tables_resolved=tables_resolved, ) ], engine=self._engine_name, diff --git a/marimo/_sql/engines/redshift.py b/marimo/_sql/engines/redshift.py index d3b307f6573..4de001f835b 100644 --- a/marimo/_sql/engines/redshift.py +++ b/marimo/_sql/engines/redshift.py @@ -209,6 +209,7 @@ def get_databases( name=catalog, dialect=self.dialect, schemas=schemas, + schemas_resolved=include_schemas, engine=self._engine_name, ) ) @@ -250,7 +251,13 @@ def get_schemas( if include_tables else [] ) - output_schemas.append(Schema(name=schema_name, tables=tables)) + output_schemas.append( + Schema( + name=schema_name, + tables=tables, + tables_resolved=include_tables, + ) + ) return output_schemas diff --git a/marimo/_sql/engines/sqlalchemy.py b/marimo/_sql/engines/sqlalchemy.py index 31039c82bd9..4a941053838 100644 --- a/marimo/_sql/engines/sqlalchemy.py +++ b/marimo/_sql/engines/sqlalchemy.py @@ -430,6 +430,7 @@ def get_databases( name=database_name, dialect=self.dialect, schemas=schemas, + schemas_resolved=should_include_schemas, engine=self._engine_name, ) ) @@ -466,16 +467,28 @@ def get_schemas( schemas: list[Schema] = [] + meta_schemas = self._get_meta_schemas() for schema in schema_names: + # Eager table discovery is skipped for meta schemas. + # The user can still expand the schema to lazily fetch them + # so we mark `tables_resolved=False` to reflect that no enumeration actually ran. + did_resolve_tables = ( + include_tables and schema.lower() not in meta_schemas + ) tables: list[DataTable] = [] - meta_schemas = self._get_meta_schemas() - if schema.lower() not in meta_schemas and include_tables: + if did_resolve_tables: tables = self.get_tables_in_schema( schema=schema, database=database if database is not None else "", include_table_details=include_table_details, ) - schemas.append(Schema(name=schema, tables=tables)) + schemas.append( + Schema( + name=schema, + tables=tables, + tables_resolved=did_resolve_tables, + ) + ) return schemas diff --git a/packages/openapi/api.yaml b/packages/openapi/api.yaml index 91ca46def5c..b921101b6f0 100644 --- a/packages/openapi/api.yaml +++ b/packages/openapi/api.yaml @@ -1182,7 +1182,9 @@ components: Database: description: "Represents a collection of schemas.\n\nAttributes:\n name (str):\ \ The name of the database\n dialect (str): The dialect of the database\n\ - \ schemas (List[Schema]): List of schemas in the database\n engine (Optional[VariableName]):\ + \ schemas (List[Schema]): List of schemas in the database.\n schemas_resolved\ + \ (bool): True when `schemas` has been enumerated.\n False when schema\ + \ discovery was deferred. Defaults to True\n engine (Optional[VariableName]):\ \ Database engine or connection handler, if any." properties: dialect: @@ -1198,6 +1200,9 @@ components: items: $ref: '#/components/schemas/Schema' type: array + schemas_resolved: + default: true + type: boolean required: - name - dialect @@ -4433,6 +4438,11 @@ components: title: SaveUserConfigurationRequest type: object Schema: + description: "Represents a database schema and its tables.\n\nAttributes:\n\ + \ name (str): The name of the schema.\n tables (List[DataTable]): Tables\ + \ in this schema.\n tables_resolved (bool): True when `tables` has been\ + \ enumerated\n False when table discovery was deferred. Defaults to\ + \ True" properties: name: type: string @@ -4440,6 +4450,9 @@ components: items: $ref: '#/components/schemas/DataTable' type: array + tables_resolved: + default: true + type: boolean required: - name - tables diff --git a/packages/openapi/src/api.ts b/packages/openapi/src/api.ts index 3cf49d21074..7fac0bcc758 100644 --- a/packages/openapi/src/api.ts +++ b/packages/openapi/src/api.ts @@ -4075,7 +4075,9 @@ export interface components { * 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. */ Database: { @@ -4084,6 +4086,8 @@ export interface components { engine?: components["schemas"]["VariableName"] | null; name: string; schemas: components["schemas"]["Schema"][]; + /** @default true */ + schemas_resolved?: boolean; }; /** * DatasetsNotification @@ -6061,10 +6065,21 @@ export interface components { SaveUserConfigurationRequest: { config: Record; }; - /** Schema */ + /** + * Schema + * @description 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 + */ Schema: { name: string; tables: components["schemas"]["DataTable"][]; + /** @default true */ + tables_resolved?: boolean; }; /** SchemaColumn */ SchemaColumn: { diff --git a/tests/_sql/test_clickhouse.py b/tests/_sql/test_clickhouse.py index f85957176a1..832350a603a 100644 --- a/tests/_sql/test_clickhouse.py +++ b/tests/_sql/test_clickhouse.py @@ -20,6 +20,68 @@ HAS_CLICKHOUSE_CONNECT = DependencyManager.clickhouse_connect.has() +@pytest.mark.skipif(not HAS_PANDAS, reason="Pandas not installed") +def test_clickhouse_get_databases_marks_failed_table_loading_unresolved() -> ( + None +): + import pandas as pd + + class Connection: + def query_df( + self, query: str, parameters: dict[str, str] | None = None + ) -> Any: + del parameters + if query == "SHOW DATABASES": + return pd.DataFrame({"name": ["default"]}) + if query.startswith("SHOW TABLES"): + raise RuntimeError("failed to list tables") + raise AssertionError(f"Unexpected query: {query}") + + engine = ClickhouseServer(Connection()) # type: ignore[arg-type] + + databases = engine.get_databases( + include_schemas=True, + include_tables=True, + include_table_details=False, + ) + + schema = databases[0].schemas[0] + assert schema.tables == [] + assert schema.tables_resolved is False + + +@pytest.mark.skipif(not HAS_PANDAS, reason="Pandas not installed") +def test_clickhouse_get_databases_marks_failed_table_details_unresolved() -> ( + None +): + import pandas as pd + + class Connection: + def query_df( + self, query: str, parameters: dict[str, str] | None = None + ) -> Any: + del parameters + if query == "SHOW DATABASES": + return pd.DataFrame({"name": ["default"]}) + if query.startswith("SHOW TABLES"): + return pd.DataFrame({"name": ["my_table"]}) + if "system.tables" in query or query.startswith("DESCRIBE TABLE"): + raise RuntimeError("failed to load table details") + raise AssertionError(f"Unexpected query: {query}") + + engine = ClickhouseServer(Connection()) # type: ignore[arg-type] + + databases = engine.get_databases( + include_schemas=True, + include_tables=True, + include_table_details=True, + ) + + schema = databases[0].schemas[0] + assert schema.tables == [] + assert schema.tables_resolved is False + + @pytest.mark.skipif( not HAS_CLICKHOUSE_CONNECT, reason="Clickhouse connect not installed" ) diff --git a/tests/_sql/test_ibis.py b/tests/_sql/test_ibis.py index 59af8c42a8a..db1fa11569b 100644 --- a/tests/_sql/test_ibis.py +++ b/tests/_sql/test_ibis.py @@ -566,8 +566,8 @@ def test_ibis_engine_get_databases(ibis_backend: SQLBackend) -> None: name="memory", dialect="duckdb", schemas=[ - Schema(name="main", tables=[]), - Schema(name="my_schema", tables=[]), + Schema(name="main", tables=[], tables_resolved=False), + Schema(name="my_schema", tables=[], tables_resolved=False), ], engine=var_name, ) @@ -586,6 +586,7 @@ def test_ibis_engine_get_databases(ibis_backend: SQLBackend) -> None: name="memory", dialect="duckdb", schemas=[], + schemas_resolved=False, engine=var_name, ) @@ -603,6 +604,7 @@ def test_ibis_engine_get_databases(ibis_backend: SQLBackend) -> None: name="memory", dialect="duckdb", schemas=[], + schemas_resolved=False, engine=var_name, ) @@ -663,6 +665,7 @@ def test_ibis_engine_get_databases_auto(ibis_backend: SQLBackend) -> None: name="memory", dialect="duckdb", schemas=[], + schemas_resolved=False, engine=var_name, ) @@ -804,6 +807,87 @@ def test_ibis_get_databases_multiple_catalogs( assert "test_table" in table_names +@pytest.mark.skipif(not HAS_IBIS, reason="Ibis not installed") +@pytest.mark.skipif(not HAS_DUCKDB, reason="DuckDB not installed") +def test_ibis_get_databases_surfaces_empty_schemas( + empty_ibis_backend: SQLBackend, +) -> None: + """Empty schemas are returned and marked as resolved. + + Regression test for https://github.com/marimo-team/marimo/issues/6807. + The frontend uses `tables_resolved` to distinguish a truly empty schema + from one whose tables haven't been fetched yet. + """ + import ibis + + engine = IbisEngine(empty_ibis_backend) + + empty_ibis_backend.create_table( + "my_table", obj=ibis.memtable({"id": [1, 2]}) + ) + empty_ibis_backend.create_database("empty_schema") + + databases = engine.get_databases( + include_schemas=True, + include_tables=True, + include_table_details=False, + ) + + memory_db = next(db for db in databases if db.name == "memory") + assert memory_db.schemas_resolved is True + + empty_schema = next( + schema for schema in memory_db.schemas if schema.name == "empty_schema" + ) + assert empty_schema.tables == [] + assert empty_schema.tables_resolved is True + + +@pytest.mark.skipif(not HAS_IBIS, reason="Ibis not installed") +@pytest.mark.skipif(not HAS_DUCKDB, reason="DuckDB not installed") +def test_ibis_get_databases_marks_deferred_tables( + empty_ibis_backend: SQLBackend, +) -> None: + """When tables are not eagerly fetched, schemas report tables_resolved=False. + + The frontend relies on this to keep deferred schemas visible so the + user can expand them to trigger a lazy table fetch. + """ + engine = IbisEngine(empty_ibis_backend) + + databases = engine.get_databases( + include_schemas=True, + include_tables=False, + include_table_details=False, + ) + + memory_db = next(db for db in databases if db.name == "memory") + assert memory_db.schemas_resolved is True + assert all( + schema.tables == [] and schema.tables_resolved is False + for schema in memory_db.schemas + ) + + +@pytest.mark.skipif(not HAS_IBIS, reason="Ibis not installed") +@pytest.mark.skipif(not HAS_DUCKDB, reason="DuckDB not installed") +def test_ibis_get_databases_marks_deferred_schemas( + empty_ibis_backend: SQLBackend, +) -> None: + """When schemas are not eagerly fetched, databases report schemas_resolved=False.""" + engine = IbisEngine(empty_ibis_backend) + + databases = engine.get_databases( + include_schemas=False, + include_tables=False, + include_table_details=False, + ) + + assert all( + db.schemas == [] and db.schemas_resolved is False for db in databases + ) + + @pytest.mark.skipif(not HAS_IBIS, reason="Ibis not installed") @pytest.mark.skipif(not HAS_DUCKDB, reason="DuckDB not installed") def test_duckdb_temp_table_only_in_temp_catalog( diff --git a/tests/_sql/test_sqlalchemy.py b/tests/_sql/test_sqlalchemy.py index 0a455f2dcea..188517a9f45 100644 --- a/tests/_sql/test_sqlalchemy.py +++ b/tests/_sql/test_sqlalchemy.py @@ -390,6 +390,10 @@ def test_sqlalchemy_skip_meta_schemas( information_schema = databases[0].schemas[1] assert information_schema.tables == [] + # Eager discovery was skipped for the meta schema, so the empty table + # list is not authoritative — `tables_resolved` must be False so the + # frontend doesn't treat it as "known empty". + assert information_schema.tables_resolved is False @pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not installed") @@ -497,8 +501,8 @@ def test_sqlalchemy_get_databases(sqlite_engine: sa.Engine) -> None: name=":memory:", dialect="sqlite", schemas=[ - Schema(name="main", tables=[]), - Schema(name="my_schema", tables=[]), + Schema(name="main", tables=[], tables_resolved=False), + Schema(name="my_schema", tables=[], tables_resolved=False), ], engine=VariableName("test_sqlite"), ) @@ -513,6 +517,7 @@ def test_sqlalchemy_get_databases(sqlite_engine: sa.Engine) -> None: name=":memory:", dialect="sqlite", schemas=[], + schemas_resolved=False, engine=VariableName("test_sqlite"), ) ] @@ -526,6 +531,7 @@ def test_sqlalchemy_get_databases(sqlite_engine: sa.Engine) -> None: name=":memory:", dialect="sqlite", schemas=[], + schemas_resolved=False, engine=VariableName("test_sqlite"), ) ] @@ -578,6 +584,7 @@ def test_sqlalchemy_get_databases_auto(sqlite_engine: sa.Engine) -> None: name=":memory:", dialect="sqlite", schemas=[], + schemas_resolved=False, engine=VariableName("test_sqlite"), ) ]