From 64b3557f893a83e85a60ca12049ab333ac9a56e3 Mon Sep 17 00:00:00 2001 From: kraysent Date: Sat, 21 Feb 2026 23:02:35 +0000 Subject: [PATCH] add endpoint for searching tables --- app/data/model/__init__.py | 2 + app/data/model/table.py | 8 ++++ app/data/repositories/layer0/repository.py | 8 ++++ app/data/repositories/layer0/tables.py | 53 +++++++++++++++++++++- app/domain/adminapi/actions.py | 3 ++ app/domain/adminapi/table_upload.py | 14 ++++++ app/presentation/adminapi/interface.py | 21 +++++++++ app/presentation/adminapi/server.py | 13 ++++++ tests/regression/upload_simple_table.py | 34 ++++++++++++++ tests/unit/data/layer0_repository_test.py | 29 ++++++++++++ 10 files changed, 184 insertions(+), 1 deletion(-) diff --git a/app/data/model/__init__.py b/app/data/model/__init__.py index af58daea..75748c6f 100644 --- a/app/data/model/__init__.py +++ b/app/data/model/__init__.py @@ -25,6 +25,7 @@ HomogenizationRule, Layer0CreationResponse, Layer0RawData, + Layer0TableListItem, Layer0TableMeta, Modifier, TableStatistics, @@ -35,6 +36,7 @@ "Layer0RawData", "Layer0TableMeta", "Layer0CreationResponse", + "Layer0TableListItem", "ColumnDescription", "TableStatistics", "get_object", diff --git a/app/data/model/table.py b/app/data/model/table.py index 5f8dcee8..1038f361 100644 --- a/app/data/model/table.py +++ b/app/data/model/table.py @@ -35,6 +35,14 @@ class Layer0TableMeta: table_id: int | None = None +@dataclass +class Layer0TableListItem: + table_name: str + description: str + num_entries: int + num_fields: int + + @dataclass class Layer0CreationResponse: table_id: int diff --git a/app/data/repositories/layer0/repository.py b/app/data/repositories/layer0/repository.py index 2d630fc8..2d05747b 100644 --- a/app/data/repositories/layer0/repository.py +++ b/app/data/repositories/layer0/repository.py @@ -52,6 +52,14 @@ def fetch_metadata(self, table_name: str) -> model.Layer0TableMeta: def fetch_metadata_by_name(self, table_name: str) -> model.Layer0TableMeta: return self.table_repo.fetch_metadata_by_name(table_name) + def search_tables( + self, + query: str, + page_size: int, + page: int, + ) -> list[model.Layer0TableListItem]: + return self.table_repo.search_tables(query, page_size, page) + def update_column_metadata(self, table_name: str, column_description: model.ColumnDescription) -> None: return self.table_repo.update_column_metadata(table_name, column_description) diff --git a/app/data/repositories/layer0/tables.py b/app/data/repositories/layer0/tables.py index b9f303d6..9616bcbc 100644 --- a/app/data/repositories/layer0/tables.py +++ b/app/data/repositories/layer0/tables.py @@ -9,7 +9,7 @@ from astropy import units as u from app.data import model, repositories, template -from app.data.repositories.layer0.common import RAWDATA_SCHEMA +from app.data.repositories.layer0.common import INTERNAL_ID_COLUMN_NAME, RAWDATA_SCHEMA from app.lib.storage import postgres from app.lib.web.errors import DatabaseError @@ -287,6 +287,57 @@ def update_column_metadata(self, table_name: str, column_description: model.Colu ) self._storage.exec(modification_query, params=[table_id]) + def search_tables( + self, + query: str, + page_size: int, + page: int, + ) -> list[model.Layer0TableListItem]: + pattern = f"%{query}%" if query else "%" + offset = page * page_size + + sql = """ + SELECT + t.table_name, + COALESCE(ti.param->>'description', '') AS description, + COALESCE(ps.n_live_tup::bigint, 0)::int AS num_entries, + ( + SELECT COUNT(*)::int + FROM meta.column_info c + WHERE c.schema_name = %s + AND c.table_name = t.table_name + AND c.column_name != %s + ) AS num_fields + FROM layer0.tables t + LEFT JOIN meta.table_info ti + ON ti.schema_name = %s AND ti.table_name = t.table_name + LEFT JOIN pg_stat_user_tables ps + ON ps.schemaname = %s AND ps.relname = t.table_name + WHERE t.table_name ILIKE %s OR COALESCE(ti.param->>'description', '') ILIKE %s + ORDER BY t.table_name + LIMIT %s OFFSET %s + """ + params = [ + RAWDATA_SCHEMA, + INTERNAL_ID_COLUMN_NAME, + RAWDATA_SCHEMA, + RAWDATA_SCHEMA, + pattern, + pattern, + page_size, + offset, + ] + rows = self._storage.query(sql, params=params) + return [ + model.Layer0TableListItem( + table_name=row["table_name"], + description=row["description"] or "", + num_entries=int(row["num_entries"]), + num_fields=int(row["num_fields"]), + ) + for row in rows + ] + def _get_table_id(self, table_name: str) -> tuple[int, bool]: try: row = self._storage.query_one( diff --git a/app/domain/adminapi/actions.py b/app/domain/adminapi/actions.py index 6b2ad5be..7cb13966 100644 --- a/app/domain/adminapi/actions.py +++ b/app/domain/adminapi/actions.py @@ -43,6 +43,9 @@ def create_marking(self, r: adminapi.CreateMarkingRequest) -> adminapi.CreateMar def get_table(self, r: adminapi.GetTableRequest) -> adminapi.GetTableResponse: return self.table_upload_manager.get_table(r) + def get_table_list(self, r: adminapi.GetTableListRequest) -> adminapi.GetTableListResponse: + return self.table_upload_manager.get_table_list(r) + def get_crossmatch_records(self, r: adminapi.GetRecordsCrossmatchRequest) -> adminapi.GetRecordsCrossmatchResponse: return self.crossmatch_manager.get_crossmatch_records(r) diff --git a/app/domain/adminapi/table_upload.py b/app/domain/adminapi/table_upload.py index df5af588..0414c3f1 100644 --- a/app/domain/adminapi/table_upload.py +++ b/app/domain/adminapi/table_upload.py @@ -160,6 +160,20 @@ def create_marking(self, r: adminapi.CreateMarkingRequest) -> adminapi.CreateMar return adminapi.CreateMarkingResponse() + def get_table_list(self, r: adminapi.GetTableListRequest) -> adminapi.GetTableListResponse: + items = self.layer0_repo.search_tables(r.query, r.page_size, r.page) + return adminapi.GetTableListResponse( + tables=[ + adminapi.TableListItem( + name=item.table_name, + description=item.description, + num_entries=item.num_entries, + num_fields=item.num_fields, + ) + for item in items + ] + ) + def get_table(self, r: adminapi.GetTableRequest) -> adminapi.GetTableResponse: meta = self.layer0_repo.fetch_metadata_by_name(r.table_name) diff --git a/app/presentation/adminapi/interface.py b/app/presentation/adminapi/interface.py index 496c038d..d126c33c 100644 --- a/app/presentation/adminapi/interface.py +++ b/app/presentation/adminapi/interface.py @@ -51,6 +51,23 @@ class GetTableRequest(pydantic.BaseModel): table_name: str +class GetTableListRequest(pydantic.BaseModel): + query: str = "" + page_size: int = 25 + page: int = 0 + + +class TableListItem(pydantic.BaseModel): + name: str + description: str + num_entries: int + num_fields: int + + +class GetTableListResponse(pydantic.BaseModel): + tables: list[TableListItem] + + class MarkingRule(pydantic.BaseModel): catalog: str key: str @@ -281,6 +298,10 @@ def create_table(self, request: CreateTableRequest) -> tuple[CreateTableResponse def get_table(self, request: GetTableRequest) -> GetTableResponse: pass + @abc.abstractmethod + def get_table_list(self, request: GetTableListRequest) -> GetTableListResponse: + pass + @abc.abstractmethod def patch_table(self, request: PatchTableRequest) -> PatchTableResponse: pass diff --git a/app/presentation/adminapi/server.py b/app/presentation/adminapi/server.py index 3d0922f3..89846ea9 100644 --- a/app/presentation/adminapi/server.py +++ b/app/presentation/adminapi/server.py @@ -45,6 +45,12 @@ def get_table( response = self.actions.get_table(request) return server.APIOkResponse(data=response) + def get_table_list( + self, request: Annotated[interface.GetTableListRequest, fastapi.Query()] + ) -> server.APIOkResponse[interface.GetTableListResponse]: + response = self.actions.get_table_list(request) + return server.APIOkResponse(data=response) + def patch_table( self, request: interface.PatchTableRequest, @@ -120,6 +126,13 @@ def __init__( "Retrieve table information", "Fetches details about a specific table using the provided table name", ), + server.Route( + "/v1/tables", + http.HTTPMethod.GET, + api.get_table_list, + "List tables", + "Returns a paginated list of tables matching the search query by name or description", + ), server.Route( "/v1/table", http.HTTPMethod.PATCH, diff --git a/tests/regression/upload_simple_table.py b/tests/regression/upload_simple_table.py index 6e0aa8d9..31b36a1f 100644 --- a/tests/regression/upload_simple_table.py +++ b/tests/regression/upload_simple_table.py @@ -163,6 +163,37 @@ def start_crossmatch(table_name: str): ) +@lib.test_logging_decorator +def check_table_list(session: requests.Session, table_name: str): + response = session.get("/v1/tables", params={"query": table_name}) + response.raise_for_status() + data = response.json()["data"] + assert "tables" in data + tables_list = data["tables"] + matching = [t for t in tables_list if t["name"] == table_name] + assert len(matching) >= 1, f"Expected at least one table with name {table_name}" + for item in tables_list: + assert "name" in item + assert "description" in item + assert "num_entries" in item + assert "num_fields" in item + + +@lib.test_logging_decorator +def check_get_table(session: requests.Session, table_name: str, expected_columns: int, expected_rows: int): + request_data = adminapi.GetTableRequest(table_name=table_name) + response = session.get("/v1/table", params=request_data.model_dump(mode="json")) + response.raise_for_status() + table_info = response.json()["data"] + assert "id" in table_info + assert "description" in table_info + assert "column_info" in table_info + assert len(table_info["column_info"]) == expected_columns + assert table_info["rows_num"] == expected_rows + assert "bibliography" in table_info + assert "meta" in table_info + + @lib.test_logging_decorator def check_table_info(session: requests.Session, table_name: str): request_data = adminapi.GetTableRequest(table_name=table_name) @@ -367,6 +398,9 @@ def run(): seed=test_seed, ) + check_table_list(adminapi, table_name) + check_get_table(adminapi, table_name, expected_columns=6, expected_rows=OBJECTS_NUM) + create_marking(adminapi, table_name) start_marking(table_name) start_crossmatch(table_name) diff --git a/tests/unit/data/layer0_repository_test.py b/tests/unit/data/layer0_repository_test.py index cd1e8f8c..ef8dbf98 100644 --- a/tests/unit/data/layer0_repository_test.py +++ b/tests/unit/data/layer0_repository_test.py @@ -55,3 +55,32 @@ def transform(s): expected = transform(expected_query) self.assertEqual(actual, expected) + + def test_search_tables_calls_query_with_expected_structure(self): + self.storage_mock.query.return_value = [ + { + "table_name": "my_table", + "description": "A test table", + "num_entries": 100, + "num_fields": 6, + } + ] + + result = self.repo.search_tables("my_table", page_size=25, page=1) + + self.storage_mock.query.assert_called_once() + query = self.storage_mock.query.call_args[0][0] + params = self.storage_mock.query.call_args[1]["params"] + + self.assertIn("layer0.tables", query) + self.assertIn("meta.table_info", query) + self.assertIn("ILIKE", query) + self.assertIn("LIMIT", query) + self.assertIn("OFFSET", query) + self.assertEqual(params[-2], 25) + self.assertEqual(params[-1], 25) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].table_name, "my_table") + self.assertEqual(result[0].description, "A test table") + self.assertEqual(result[0].num_entries, 100) + self.assertEqual(result[0].num_fields, 6)