Skip to content

Fix CSV search download - #17

Merged
matthewberry merged 3 commits into
mainfrom
mjberry/fix-csv-columns
Oct 22, 2026
Merged

matthewberry merged 3 commits into
mainfrom
mjberry/fix-csv-columns

Conversation

@matthewberry

Copy link
Copy Markdown
Contributor

Problem

Every `/api/v1/search` request with `format=csv` returned a 500, e.g.

https://cleandb-api.platform.moleculemaker.org/api/v1/search?accession=P51451&format=csv&limit=10

```
{"detail":"Error retrieving data: 'CLEANSearchQueryParams' object has no attribute 'columns'"}
```

a1ea901 removed the `columns` field from `CLEANSearchQueryParams`, but the CSV branch still read `params.columns` before anything else. `parse_query_params` never accepted a `columns` query param, so that branch was dead.

Changes

  • Remove the stale `params.columns` branch; the CSV header comes from the first row's keys, as it already did when that branch was skipped.
  • Join array columns (`clean_ec_number_array`, `clean_ec_confidence_array`, `annot_ec_number_array`) into one `;`-separated cell instead of a Python list repr (`2.7.10.2;2.7.11.1` rather than `['2.7.10.2', '2.7.11.1']`). Element order is kept, so EC numbers and confidences still pair by position. NULL and empty arrays both give an empty cell.

Testing

  • Reproduced the 500 against production.
  • Ran the endpoint through `TestClient` with `get_total_count`/`get_filtered_data` mocked: `origin/main` returns 500 with the reported error; this branch returns 200 with the expected CSV, including multi-value, single-value, NULL and empty arrays and a field needing CSV quoting.
  • Not tested against a real database. `Database.fetch` returns plain dicts and asyncpg 0.30.0 decodes arrays to lists, so the list check applies to real rows.
  • If an array can contain a NULL element, it would render as `None` in the cell; I don't know whether the data allows that.

The columns field was removed from CLEANSearchQueryParams in a1ea901,
but the CSV branch still read params.columns, so every format=csv
request returned a 500.
Array cells were written as Python list reprs, e.g. ['2.7.10.2'].

@matthewberry matthewberry left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two findings are about lines outside the diff hunks, so they go here:

CSV export silently stops at the default page size (search.py:208-215). When limit is not given, it falls back to AUTO_PAGINATION_THRESHOLD (5000). The CSV branch returns only those rows, with no total count and no next-page link. So ?ec_number=1.1.1.1&format=csv with more than 5000 matches produces a file that looks complete but isn't. This existed before the PR, but now that the download works, people will actually hit it.

get_total_count runs on every CSV request and its result is thrown away (search.py:212). That's an extra COUNT over the joined views, with the same WHERE clause, for nothing. Either move it into the JSON branch or send it as an X-Total-Count header, which would also fix the problem above.

These findings come from reading the code; none of them were run against a database.

Comment thread app/routers/search.py Outdated
if params.columns:
fieldnames = params.columns
elif data and len(data) > 0:
if data and len(data) > 0:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No header row when the query matches nothing. Column names now come only from data[0], so an empty result writes just \r\n. A client reading it with csv.DictReader, as the endpoint's docstring example does, gets fieldnames=None instead of an empty table with headers.

The fixed column list is already in CLEANColumn (app/models/clean_data.py:10), so fieldnames = [c.value for c in CLEANColumn] would always produce a header. It would also make this branch unnecessary, including the redundant len(data) > 0, since a non-empty list is already truthy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and fixed in ebafcd8. (Small correction: DictReader gives fieldnames == [] rather than None, but the result is the same: no header.) The header now comes from CLEANColumn, which I checked matches the 14 columns in the get_filtered_data SELECT exactly and in order. test_csv_has_header_when_no_rows_match covers it.

Comment thread app/routers/search.py
writer.writerow(row)
# Join array columns into one ';'-separated cell
writer.writerow({
k: ";".join(str(v) for v in value) if isinstance(value, list) else value

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A NULL element inside an array becomes the text None. str(None) turns e.g. clean_ec_confidence_array = [0.91, NULL] into 0.91;None, which shows up as a value in spreadsheets and parsers. I don't know whether the data allows NULL elements; if it can, something like "" if v is None else str(v) would handle it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed for the CSV, but declining here: the same NULL element already makes the JSON path return a 500, because ECNumberConfidence.score: float and annot_ec_number_array: List[str] reject None (checked with TestClient). JSON already assumes there are no NULL elements and no JSON failures have been reported, so if they do turn up, the fix belongs in both outputs, starting with JSON.

Comment thread app/routers/search.py
writer.writerow(row)
# Join array columns into one ';'-separated cell
writer.writerow({
k: ";".join(str(v) for v in value) if isinstance(value, list) else value

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An empty array and a NULL array both become an empty cell. annot_ec_number_array comes from a LEFT JOIN (queries.py:132). NULL there means there's no annotation record, and [] means a record with no EC numbers. Both become '' here, while the JSON output keeps them distinct (null vs []).

@matthewberry matthewberry Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed (CSV writes an empty cell for both, JSON null vs []). Declining: CSV has no standard NULL, and I know of no one who needs to tell "no annotation record" from "record with no EC numbers" in the CSV download.

Comment thread app/routers/search.py
@@ -233,7 +231,11 @@ async def get_data(
)
writer.writeheader()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSV and JSON outputs have different shapes. The CSV writes raw DB rows, while JSON goes through the CLEANDataBase mapping. So the column names differ (protein_name/protein, ncbi_taxid/ncbi_tax_id, enzyme_function/function), and EC predictions are parallel ;-joined arrays here but predicted_ec[{ec_number, score}] in JSON. A future column change has to be made in two places. Flattening the model for the CSV, or using array_to_string in SQL, would fix the cause instead of special-casing types in the writer. This doesn't block the PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and this was the case before this PR. Deferring to #19, since aligning the two would rename existing CSV columns and needs a decision about CSV consumers.

Comment thread app/routers/search.py
writer.writeheader()
for row in data:
writer.writerow(row)
# Join array columns into one ';'-separated cell

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No regression test. The PR body describes a manual TestClient run with mocked get_total_count/get_filtered_data. Committing that as a test would keep the 500, caused by the field removed in a1ea901, from coming back. The repo has a pytest.ini but no tests yet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in ebafcd8: tests/test_search_csv.py (TestClient, DB calls mocked), plus pythonpath = . in pytest.ini so app imports. On origin/main both tests fail with the original 500; on this branch both pass.

Take fieldnames from CLEANColumn, which matches the SELECT list in
get_filtered_data, so an empty result still has a header row. Add
TestClient tests for the CSV branch with the DB calls mocked.
@matthewberry

Copy link
Copy Markdown
Contributor Author

Re the two findings in the review summary, both confirmed by reading (the CSV branch returns only Content-Disposition, and get_total_count is awaited once per CSV request):

  • CSV truncates at 5000 rows: deferring to CSV search export silently truncates at AUTO_PAGINATION_THRESHOLD #18. It predates this PR, and the fix means choosing between streaming everything, sending count/link headers, or documenting the limit.
  • Unused get_total_count on CSV: tracked in the same issue, since a count header is one of the options there and would put the count to use.

@matthewberry
matthewberry merged commit 3220a0d into main Oct 22, 2026
1 check passed
@matthewberry
matthewberry deleted the mjberry/fix-csv-columns branch October 22, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant