From 0c81e0876f6a5bb1dc1ffd035456c26edc461542 Mon Sep 17 00:00:00 2001 From: Conrad Date: Sat, 18 Jul 2026 14:30:41 -0500 Subject: [PATCH 1/3] feat: Add fileCount GraphQL query for match counts without paging Obtaining the number of files matching a filter meant issuing the files query, which also fetches and hydrates a page of file documents the caller then discards. Clients that only need the size of a result set -- sizing pagination controls, "N files match" labels, faceting UIs that count many filter permutations -- paid for a page they never use. fileCount accepts the same FileMetadataInput filter shape as files and distinctValues and returns a scalar Int, running only count_documents against the filter. It reuses the shared to_query/to_dict filter path and the cutover wait, so an absent filter counts every file. Claude-Session: https://claude.ai/code/session_01Y5eYL9rN5xCDbwTQZrjwky --- schema.graphql | 1 + src/cfdb/api/gql/schema.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/schema.graphql b/schema.graphql index f0408be..088ebb9 100644 --- a/schema.graphql +++ b/schema.graphql @@ -506,6 +506,7 @@ type Query { files(input: [FileMetadataInput!] = null, page: Int! = 0, pageSize: Int! = 25): [FileMetadataType!]! file(id: ObjectIdScalar!): FileMetadataType distinctValues(fields: [String!]!, input: [FileMetadataInput!] = null): [DistinctFieldType!]! + fileCount(input: [FileMetadataInput!] = null): Int! } input SubjectInput { diff --git a/src/cfdb/api/gql/schema.py b/src/cfdb/api/gql/schema.py index d1b9da0..10596e6 100644 --- a/src/cfdb/api/gql/schema.py +++ b/src/cfdb/api/gql/schema.py @@ -156,5 +156,19 @@ async def distinct_values( for field, values in zip(fields, all_values) ] + @strawberry.field + async def file_count( + self, + _: strawberry.Info, + input: list[FileMetadataInput] | None = None, + ) -> int: + # Wait for any database cutover to complete + await locks.wait_for_cutover() + + assert api.db is not None + query = to_query(to_dict(input)) if input else {} + + return await api.db.files.count_documents(query) + schema = strawberry.Schema(query=Query) From 348fb732bb0494516f5c9dd01ada290a4d058506 Mon Sep 17 00:00:00 2001 From: Conrad Date: Sat, 18 Jul 2026 14:30:50 -0500 Subject: [PATCH 2/3] docs: Document the fileCount GraphQL query Note fileCount in the GraphQL Queries section with an example showing it takes the same FileMetadataInput filter as files and returns a count without fetching any documents. Claude-Session: https://claude.ai/code/session_01Y5eYL9rN5xCDbwTQZrjwky --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 81fce1c..1107075 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ The `cloudformation/backend.yml` `ImageURI` and `cloudformation/workers.yml` `Wo ### Queries -The API exposes two queries: `files` (paginated list) and `file` (single lookup by MongoDB ObjectId). +The API exposes queries including `files` (paginated list), `file` (single lookup by MongoDB ObjectId), and `fileCount` (match count for a filter). ```graphql query { @@ -180,6 +180,8 @@ curl -X POST http://localhost:8000/metadata \ Single file lookup: `{ file(id: "507f1f77bcf86cd799439011") { filename accessUrl } }` +File count for a filter: `{ fileCount(input: [{ dcc: [{ dccAbbreviation: ["4DN"] }] }]) }` — returns the number of matching files without fetching any documents. It accepts the same `FileMetadataInput` filter shape as `files`; with no `input` it counts every file. + ### Query Mechanics The GraphQL API uses an implicit OR/AND clause system for building MongoDB queries: From 8816b303367660cc7e0f5f73b559493791b7be11 Mon Sep 17 00:00:00 2001 From: Conrad Date: Sat, 18 Jul 2026 14:30:56 -0500 Subject: [PATCH 3/3] test: Cover the fileCount GraphQL query Add TestFileCountQuery: the unfiltered total, a filtered subset, no-match and empty-database zeros, the empty-input-list count-all edge, OR across multiple values in one field, OR across multiple input entries, AND across multiple fields, agreement with the files result length for the same filter, and a property-based invariant that the count equals the number of documents matching a generated filter. Claude-Session: https://claude.ai/code/session_01Y5eYL9rN5xCDbwTQZrjwky --- tests/test_schema.py | 356 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) diff --git a/tests/test_schema.py b/tests/test_schema.py index a117e11..09ec371 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -2,7 +2,11 @@ from __future__ import annotations +import asyncio + import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st from cfdb.api.gql.schema import from_pydantic, schema from cfdb.api.gql.types import FileMetadataType @@ -670,3 +674,355 @@ async def test_distinct_values_rejects_bare_subdocument_field(self, mock_db): # Assert assert result.errors is not None assert "file_format" in result.errors[0].message + + +# DCC abbreviations the fileCount property test draws documents and filters +# from. Kept small so generated docs use fake-resolvable dict paths +# (``dcc.dcc_abbreviation``). +_DCC_POOL = ["hubmap", "4dn", "encode"] + + +class TestFileCountQuery: + @pytest.fixture(autouse=True) + def _patch_cutover(self, mocker): + """No-op ``locks.wait_for_cutover`` for every test in this class.""" + mocker.patch.object(locks, "wait_for_cutover", return_value=None) + + @pytest.mark.asyncio + async def test_file_count_should_return_total_when_no_filter(self, mock_db): + """Test the file count reflects every document when no filter is supplied. + + Given: + Three files spanning multiple DCCs + When: + The fileCount query is executed with no input filter + Then: + It should return the total number of files + """ + # Arrange + mock_db.files.docs = [ + _make_file_doc("h1", "hubmap"), + _make_file_doc("f1", "4dn"), + _make_file_doc("e1", "encode"), + ] + + # Act + result = await schema.execute("query { fileCount }") + + # Assert + assert result.errors is None + assert result.data["fileCount"] == 3 + + @pytest.mark.asyncio + async def test_file_count_should_count_only_matches_when_filter_applied( + self, mock_db + ): + """Test the file count honors an input filter. + + Given: + Three files, two from HuBMAP and one from 4DN + When: + The fileCount query is executed with a DCC filter for HuBMAP + Then: + It should return only the count of matching files + """ + # Arrange + mock_db.files.docs = [ + _make_file_doc("h1", "hubmap"), + _make_file_doc("h2", "hubmap"), + _make_file_doc("f1", "4dn"), + ] + + # Act + result = await schema.execute( + """ + query { + fileCount(input: [{ dcc: [{ dccAbbreviation: ["hubmap"] }] }]) + } + """ + ) + + # Assert + assert result.errors is None + assert result.data["fileCount"] == 2 + + @pytest.mark.asyncio + async def test_file_count_should_return_zero_when_filter_matches_nothing( + self, mock_db + ): + """Test the file count is zero when no document satisfies the filter. + + Given: + Three files, none from the filtered DCC + When: + The fileCount query is executed with a DCC filter for an absent DCC + Then: + It should return zero + """ + # Arrange + mock_db.files.docs = [ + _make_file_doc("h1", "hubmap"), + _make_file_doc("h2", "hubmap"), + _make_file_doc("f1", "4dn"), + ] + + # Act + result = await schema.execute( + """ + query { + fileCount(input: [{ dcc: [{ dccAbbreviation: ["encode"] }] }]) + } + """ + ) + + # Assert + assert result.errors is None + assert result.data["fileCount"] == 0 + + @pytest.mark.asyncio + async def test_file_count_should_return_zero_when_database_empty(self, mock_db): + """Test the file count is zero against an empty collection. + + Given: + An empty database + When: + The fileCount query is executed with no input filter + Then: + It should return zero + """ + # Arrange + mock_db.files.docs = [] + + # Act + result = await schema.execute("query { fileCount }") + + # Assert + assert result.errors is None + assert result.data["fileCount"] == 0 + + @pytest.mark.asyncio + async def test_file_count_should_count_all_when_input_is_an_empty_list( + self, mock_db + ): + """Test a present-but-empty input list counts every document. + + Given: + Three files and an explicitly empty input list + When: + The fileCount query is executed with input: [] + Then: + It should return the total, since an empty list is falsy and + builds no filter (distinct from an omitted/null input) + """ + # Arrange + mock_db.files.docs = [ + _make_file_doc("h1", "hubmap"), + _make_file_doc("f1", "4dn"), + _make_file_doc("e1", "encode"), + ] + + # Act + result = await schema.execute("query { fileCount(input: []) }") + + # Assert + assert result.errors is None + assert result.data["fileCount"] == 3 + + @pytest.mark.asyncio + async def test_file_count_should_count_the_union_when_a_field_lists_multiple_values( + self, mock_db + ): + """Test multiple values in one field are combined as an OR union. + + Given: + Three files, one each from HuBMAP, 4DN, and ENCODE + When: + The fileCount query is executed with a DCC filter listing two + abbreviations (["hubmap", "4dn"]) + Then: + It should return the union count of the two DCCs, excluding ENCODE + """ + # Arrange + mock_db.files.docs = [ + _make_file_doc("h1", "hubmap"), + _make_file_doc("f1", "4dn"), + _make_file_doc("e1", "encode"), + ] + + # Act + result = await schema.execute( + """ + query { + fileCount(input: [{ dcc: [{ dccAbbreviation: ["hubmap", "4dn"] }] }]) + } + """ + ) + + # Assert + assert result.errors is None + assert result.data["fileCount"] == 2 + + @pytest.mark.asyncio + async def test_file_count_should_count_the_union_when_input_has_multiple_entries( + self, mock_db + ): + """Test multiple entries in the outer input list are combined as an OR. + + Given: + Three files with distinct local IDs + When: + The fileCount query is executed with two separate input entries, + each filtering a different local ID + Then: + It should return the union count of the two entries + """ + # Arrange + mock_db.files.docs = [ + _make_file_doc("h1", "hubmap"), + _make_file_doc("f1", "4dn"), + _make_file_doc("e1", "encode"), + ] + + # Act + result = await schema.execute( + """ + query { + fileCount(input: [{ localId: ["h1"] }, { localId: ["f1"] }]) + } + """ + ) + + # Assert + assert result.errors is None + assert result.data["fileCount"] == 2 + + @pytest.mark.asyncio + async def test_file_count_should_count_the_intersection_when_multiple_fields_filter( + self, mock_db + ): + """Test multiple fields in one entry are combined as an AND intersection. + + Given: + Three files: a public HuBMAP file, a non-public HuBMAP file, and a + public 4DN file + When: + The fileCount query is executed with a filter combining a DCC field + and a data-access-level field (HuBMAP AND public) + Then: + It should return only the file matching both conditions, excluding + the non-public HuBMAP file and the public 4DN file + """ + # Arrange + public_hubmap = _make_file_doc("h1", "hubmap") + protected_hubmap = _make_file_doc("h2", "hubmap") + protected_hubmap["data_access_level"] = "protected" + public_4dn = _make_file_doc("f1", "4dn") + mock_db.files.docs = [public_hubmap, protected_hubmap, public_4dn] + + # Act + result = await schema.execute( + """ + query { + fileCount(input: [{ + dcc: [{ dccAbbreviation: ["hubmap"] }], + dataAccessLevel: ["public"] + }]) + } + """ + ) + + # Assert + assert result.errors is None + assert result.data["fileCount"] == 1 + + @pytest.mark.asyncio + async def test_file_count_should_equal_the_files_result_length_for_the_same_filter( + self, mock_db + ): + """Test fileCount agrees with the number of files the same filter returns. + + Given: + A mixed multi-DCC dataset smaller than one page + When: + The fileCount and files queries are executed with the same filter + Then: + fileCount should equal the length of the files result + """ + # Arrange + mock_db.files.docs = [ + _make_file_doc("h1", "hubmap"), + _make_file_doc("h2", "hubmap"), + _make_file_doc("f1", "4dn"), + _make_file_doc("e1", "encode"), + ] + + # Act + result = await schema.execute( + """ + query { + fileCount(input: [{ dcc: [{ dccAbbreviation: ["hubmap"] }] }]) + files(input: [{ dcc: [{ dccAbbreviation: ["hubmap"] }] }], pageSize: 100) { + localId + } + } + """ + ) + + # Assert + assert result.errors is None + assert result.data["fileCount"] == len(result.data["files"]) + assert result.data["fileCount"] == 2 + + @settings( + max_examples=50, + deadline=None, + suppress_health_check=[ + HealthCheck.function_scoped_fixture, + HealthCheck.too_slow, + ], + ) + @given( + abbreviations=st.lists(st.sampled_from(_DCC_POOL), max_size=15), + selected=st.lists(st.sampled_from(_DCC_POOL), unique=True), + ) + def test_file_count_should_equal_the_number_of_matching_documents( + self, mock_db, abbreviations, selected + ): + """Test the count equals the number of documents matching the filter. + + Given: + An arbitrary set of files each tagged with a DCC abbreviation drawn + from a small pool, and an arbitrary (possibly empty) subset of + abbreviations as the filter + When: + The fileCount query is executed with that filter (or no filter when + the subset is empty) + Then: + It should equal the number of files whose abbreviation is in the + subset, and the total when the subset is empty + """ + # Arrange + mock_db.files.docs = [ + _make_file_doc(f"f{i}", abbr) for i, abbr in enumerate(abbreviations) + ] + variables = ( + {"input": [{"dcc": [{"dccAbbreviation": selected}]}]} if selected else None + ) + expected = ( + sum(1 for abbr in abbreviations if abbr in selected) + if selected + else len(abbreviations) + ) + + # Act + result = asyncio.run( + schema.execute( + "query FileCount($input: [FileMetadataInput!]) {" + " fileCount(input: $input) }", + variable_values=variables, + ) + ) + + # Assert + assert result.errors is None + assert result.data["fileCount"] == expected