Skip to content

Commit 07f2cf4

Browse files
committed
drafting filter and tests
1 parent a9465d3 commit 07f2cf4

File tree

6 files changed

+211
-21
lines changed

6 files changed

+211
-21
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from typing import Annotated
2+
3+
from pydantic import BaseModel, Field
4+
5+
from .basic_types import IDStr
6+
7+
8+
class MetadataFilterItem(BaseModel):
9+
key: IDStr
10+
value: Annotated[str | None, Field(description="SQL-like pattern")]
11+
12+
13+
class ListProjectsMarkedAsJobFilter(BaseModel):
14+
job_parent_resource_name_prefix: str | None
15+
any_of_metadata: list[MetadataFilterItem] | None = None
16+
17+
# TODO: early validation of filters
18+
# TODO: update interface to list_projects_marked_as_jobs

packages/models-library/src/models_library/rpc/webserver/projects.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22
from typing import Annotated, TypeAlias
33
from uuid import uuid4
44

5-
from models_library.projects import NodesDict, ProjectID
6-
from models_library.projects_nodes import Node
7-
from models_library.rpc_pagination import PageRpc
85
from pydantic import BaseModel, ConfigDict, Field
96
from pydantic.config import JsonDict
107

8+
from ...projects import NodesDict, ProjectID
9+
from ...projects_filters import ListProjectsMarkedAsJobFilter
10+
from ...projects_nodes import Node
11+
from ...rpc_pagination import PageRpc
12+
13+
# NOTE: for now these interfaces are identical
14+
ListProjectsMarkedAsJobRpcFilter: TypeAlias = ListProjectsMarkedAsJobFilter
15+
1116

1217
class ProjectJobRpcGet(BaseModel):
1318
"""

services/web/server/src/simcore_service_webserver/projects/_controller/projects_rpc.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from models_library.projects import ProjectID
55
from models_library.rest_pagination import PageLimitInt, PageOffsetInt
66
from models_library.rpc.webserver.projects import (
7+
ListProjectsMarkedAsJobRpcFilter,
78
PageRpcProjectJobRpcGet,
89
ProjectJobRpcGet,
910
)
@@ -66,7 +67,9 @@ async def list_projects_marked_as_jobs(
6667
offset: PageOffsetInt,
6768
limit: PageLimitInt,
6869
# filters
70+
# TODO: replace by ListProjectsMarkedAsJobsFilter
6971
job_parent_resource_name_prefix: str | None,
72+
filters: ListProjectsMarkedAsJobRpcFilter | None = None,
7073
) -> PageRpcProjectJobRpcGet:
7174

7275
total, projects = await _jobs_service.list_my_projects_marked_as_jobs(
@@ -76,6 +79,7 @@ async def list_projects_marked_as_jobs(
7679
offset=offset,
7780
limit=limit,
7881
filter_by_job_parent_resource_name_prefix=job_parent_resource_name_prefix,
82+
filter_by_metadata_any_of=filters.any_of_metadata if filters else None,
7983
)
8084

8185
job_projects = [

services/web/server/src/simcore_service_webserver/projects/_jobs_repository.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from simcore_postgres_database.models.groups import user_to_groups
99
from simcore_postgres_database.models.project_to_groups import project_to_groups
1010
from simcore_postgres_database.models.projects import projects
11+
from simcore_postgres_database.models.projects_metadata import projects_metadata
1112
from simcore_postgres_database.models.projects_to_jobs import projects_to_jobs
1213
from simcore_postgres_database.models.projects_to_products import projects_to_products
1314
from simcore_postgres_database.utils_repos import (
@@ -30,6 +31,30 @@
3031
)
3132

3233

34+
def _apply_job_parent_resource_name_filter(
35+
query: sa.sql.Select, prefix: str
36+
) -> sa.sql.Select:
37+
return query.where(projects_to_jobs.c.job_parent_resource_name.like(f"{prefix}%"))
38+
39+
40+
def _apply_metadata_filter(
41+
query: sa.sql.Select, any_of_metadata_fields: list[dict[str, str]]
42+
) -> sa.sql.Select:
43+
44+
assert any_of_metadata_fields # nosec
45+
46+
expressions = [
47+
projects_metadata.c.custom[key].ilike(
48+
# NOTE: we use ilike to be case insensitive
49+
# NOTE: supports glob wildcards
50+
pattern.replace("*", "%")
51+
)
52+
for field in any_of_metadata_fields
53+
for key, pattern in field.items()
54+
]
55+
return query.where(sa.or_(*expressions))
56+
57+
3358
class ProjectJobsRepository(BaseRepository):
3459

3560
async def set_project_as_job(
@@ -60,9 +85,10 @@ async def list_projects_marked_as_jobs(
6085
*,
6186
product_name: ProductName,
6287
user_id: UserID,
63-
offset: int = 0,
64-
limit: int = 10,
65-
job_parent_resource_name_prefix: str | None = None,
88+
pagination_offset: int = 0,
89+
pagination_limit: int = 10,
90+
filter_by_job_parent_resource_name_prefix: str | None = None,
91+
filter_by_any_of_metadata_fields: list[dict[str, str]] | None = None,
6692
) -> tuple[int, list[ProjectJobDBGet]]:
6793
"""Lists projects marked as jobs for a specific user and product
6894
@@ -96,10 +122,15 @@ async def list_projects_marked_as_jobs(
96122
projects_to_products,
97123
projects_to_jobs.c.project_uuid
98124
== projects_to_products.c.project_uuid,
99-
).join(
125+
)
126+
.join(
100127
project_to_groups,
101128
projects_to_jobs.c.project_uuid == project_to_groups.c.project_uuid,
102129
)
130+
.outerjoin(
131+
projects_metadata,
132+
projects_to_jobs.c.project_uuid == projects_metadata.c.project_uuid,
133+
)
103134
)
104135
.where(
105136
projects_to_products.c.product_name == product_name,
@@ -112,21 +143,24 @@ async def list_projects_marked_as_jobs(
112143
)
113144
)
114145

115-
# Apply job_parent_resource_name_filter if provided
116-
if job_parent_resource_name_prefix:
117-
access_query = access_query.where(
118-
projects_to_jobs.c.job_parent_resource_name.like(
119-
f"{job_parent_resource_name_prefix}%"
120-
)
146+
# Step 3: Apply filters
147+
if filter_by_job_parent_resource_name_prefix:
148+
access_query = _apply_job_parent_resource_name_filter(
149+
access_query, filter_by_job_parent_resource_name_prefix
150+
)
151+
152+
if filter_by_any_of_metadata_fields:
153+
access_query = _apply_metadata_filter(
154+
access_query, filter_by_any_of_metadata_fields
121155
)
122156

123-
# Convert access_query to a subquery
157+
# Step 4. Convert access_query to a subquery
124158
base_query = access_query.subquery()
125159

126-
# Step 3: Query to get the total count
160+
# Step 5: Query to get the total count
127161
total_query = sa.select(sa.func.count()).select_from(base_query)
128162

129-
# Step 4: Query to get the paginated list with full selection
163+
# Step 6: Query to get the paginated list with full selection
130164
list_query = (
131165
sa.select(
132166
*_PROJECT_DB_COLS,
@@ -143,8 +177,8 @@ async def list_projects_marked_as_jobs(
143177
projects.c.creation_date.desc(), # latests first
144178
projects.c.id.desc(),
145179
)
146-
.limit(limit)
147-
.offset(offset)
180+
.limit(pagination_limit)
181+
.offset(pagination_offset)
148182
)
149183

150184
# Step 5: Execute queries

services/web/server/src/simcore_service_webserver/projects/_jobs_service.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ async def list_my_projects_marked_as_jobs(
5757
offset: int = 0,
5858
limit: int = 10,
5959
filter_by_job_parent_resource_name_prefix: str | None = None,
60+
filter_by_any_of_metadata: list[dict[str, str]] | None = None,
6061
) -> tuple[int, list[ProjectJobDBGet]]:
6162
"""
6263
Lists paginated projects marked as jobs for the given user and product.
@@ -66,7 +67,8 @@ async def list_my_projects_marked_as_jobs(
6667
return await repo.list_projects_marked_as_jobs(
6768
user_id=user_id,
6869
product_name=product_name,
69-
offset=offset,
70-
limit=limit,
71-
job_parent_resource_name_prefix=filter_by_job_parent_resource_name_prefix,
70+
pagination_offset=offset,
71+
pagination_limit=limit,
72+
filter_by_job_parent_resource_name_prefix=filter_by_job_parent_resource_name_prefix,
73+
filter_by_any_of_metadata_fields=filter_by_any_of_metadata,
7274
)

services/web/server/tests/unit/with_dbs/02/test_projects__jobs_service.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99
from aiohttp.test_utils import TestClient
1010
from common_library.users_enums import UserRole
11+
from models_library.api_schemas_webserver.projects_metadata import MetadataDict
1112
from models_library.products import ProductName
1213
from models_library.projects import ProjectID
1314
from models_library.users import UserID
@@ -16,6 +17,9 @@
1617
list_my_projects_marked_as_jobs,
1718
set_project_as_job,
1819
)
20+
from simcore_service_webserver.projects._metadata_service import (
21+
set_project_custom_metadata,
22+
)
1923
from simcore_service_webserver.projects.models import ProjectDict
2024

2125

@@ -24,6 +28,7 @@ class ProjectJobFixture:
2428
user_id: UserID
2529
project_uuid: ProjectID
2630
job_parent_resource_name: str
31+
custom_metadata: MetadataDict = None
2732

2833

2934
@pytest.fixture
@@ -194,3 +199,125 @@ async def test_user_can_filter_marked_project(
194199
)
195200
assert total_count == 0
196201
assert len(result) == 0
202+
203+
204+
async def test_filter_projects_by_metadata(
205+
client: TestClient,
206+
logged_user: UserInfoDict,
207+
user_project: ProjectDict,
208+
osparc_product_name: ProductName,
209+
):
210+
"""Test that list_my_projects_marked_as_jobs can filter projects by custom metadata"""
211+
assert client.app
212+
213+
user_id = logged_user["id"]
214+
project_uuid = ProjectID(user_project["uuid"])
215+
job_parent_resource_name = "test/resource/metadata"
216+
217+
# 1. Mark the project as a job
218+
await set_project_as_job(
219+
app=client.app,
220+
product_name=osparc_product_name,
221+
user_id=user_id,
222+
project_uuid=project_uuid,
223+
job_parent_resource_name=job_parent_resource_name,
224+
)
225+
226+
# 2. Set custom metadata
227+
custom_metadata: MetadataDict = {
228+
"test_key": "test_value",
229+
"category": "simulation",
230+
"status": "completed",
231+
}
232+
await set_project_custom_metadata(
233+
app=client.app,
234+
user_id=user_id,
235+
project_uuid=project_uuid,
236+
value=custom_metadata,
237+
)
238+
239+
# 3. Filter by exact metadata
240+
filter_exact = [{"test_key": "test_value"}]
241+
total_count, result = await list_my_projects_marked_as_jobs(
242+
app=client.app,
243+
product_name=osparc_product_name,
244+
user_id=user_id,
245+
filter_by_any_of_metadata=filter_exact,
246+
)
247+
assert total_count == 1
248+
assert len(result) == 1
249+
assert result[0].uuid == project_uuid
250+
251+
# 4. Filter by multiple metadata keys in one dict (AND condition)
252+
filter_multiple_keys = [{"category": "simulation", "status": "completed"}]
253+
total_count, result = await list_my_projects_marked_as_jobs(
254+
app=client.app,
255+
product_name=osparc_product_name,
256+
user_id=user_id,
257+
filter_by_any_of_metadata=filter_multiple_keys,
258+
)
259+
assert total_count == 1
260+
assert len(result) == 1
261+
assert result[0].uuid == project_uuid
262+
263+
# 5. Filter by alternative metadata (OR condition)
264+
filter_alternative = [
265+
{"status": "completed"},
266+
{"status": "pending"},
267+
]
268+
total_count, result = await list_my_projects_marked_as_jobs(
269+
app=client.app,
270+
product_name=osparc_product_name,
271+
user_id=user_id,
272+
filter_by_any_of_metadata=filter_alternative,
273+
)
274+
assert total_count == 1
275+
assert len(result) == 1
276+
assert result[0].uuid == project_uuid
277+
278+
# 6. Filter by non-matching metadata
279+
filter_non_matching = [{"status": "failed"}]
280+
total_count, result = await list_my_projects_marked_as_jobs(
281+
app=client.app,
282+
product_name=osparc_product_name,
283+
user_id=user_id,
284+
filter_by_any_of_metadata=filter_non_matching,
285+
)
286+
assert total_count == 0
287+
assert len(result) == 0
288+
289+
# 7. Filter by wildcard pattern (requires SQL LIKE syntax)
290+
# This assumes the implementation supports wildcards in metadata values
291+
filter_wildcard = [{"test_key": "test_*"}]
292+
total_count, result = await list_my_projects_marked_as_jobs(
293+
app=client.app,
294+
product_name=osparc_product_name,
295+
user_id=user_id,
296+
filter_by_any_of_metadata=filter_wildcard,
297+
)
298+
assert total_count == 1
299+
assert len(result) == 1
300+
assert result[0].uuid == project_uuid
301+
302+
# 8. Combine with parent resource name filter
303+
total_count, result = await list_my_projects_marked_as_jobs(
304+
app=client.app,
305+
product_name=osparc_product_name,
306+
user_id=user_id,
307+
filter_by_job_parent_resource_name_prefix="test/resource",
308+
filter_by_any_of_metadata=[{"category": "simulation"}],
309+
)
310+
assert total_count == 1
311+
assert len(result) == 1
312+
assert result[0].uuid == project_uuid
313+
314+
# 9. Conflicting filters should return no results
315+
total_count, result = await list_my_projects_marked_as_jobs(
316+
app=client.app,
317+
product_name=osparc_product_name,
318+
user_id=user_id,
319+
filter_by_job_parent_resource_name_prefix="non-matching",
320+
filter_by_any_of_metadata=[{"category": "simulation"}],
321+
)
322+
assert total_count == 0
323+
assert len(result) == 0

0 commit comments

Comments
 (0)