Skip to content
Merged
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
2 changes: 2 additions & 0 deletions app/data/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
HomogenizationRule,
Layer0CreationResponse,
Layer0RawData,
Layer0TableListItem,
Layer0TableMeta,
Modifier,
TableStatistics,
Expand All @@ -35,6 +36,7 @@
"Layer0RawData",
"Layer0TableMeta",
"Layer0CreationResponse",
"Layer0TableListItem",
"ColumnDescription",
"TableStatistics",
"get_object",
Expand Down
8 changes: 8 additions & 0 deletions app/data/model/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions app/data/repositories/layer0/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
53 changes: 52 additions & 1 deletion app/data/repositories/layer0/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions app/domain/adminapi/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions app/domain/adminapi/table_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 21 additions & 0 deletions app/presentation/adminapi/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions app/presentation/adminapi/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions tests/regression/upload_simple_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/data/layer0_repository_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading