Skip to content
Draft
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
63 changes: 43 additions & 20 deletions chord_metadata_service/discovery/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from .censorship import censor_count, thresholded_count
from .field_paths.django_field_query import DiscoveryFieldSubquery, get_field_django_mapping_and_queried_entity
from .scope import ValidatedDiscoveryScope
from .pydantic_models import BinWithValue, BinList
from .pydantic_models import BinWithValue, BinList, DiscoveryQueryFilterOneOf
from .stats import stats_for_field

LENGTH_Y_M = 4 + 1 + 2 # dates stored as yyyy-mm-dd
Expand Down Expand Up @@ -392,25 +392,13 @@ def get_condition_for_non_jsonb_field(
return Q(**{f"{field}__{op}": value for op, value in ops})


async def filter_queryset_field_value(
queryset_entity: DiscoveryEntity, qs: QuerySet, field_props: FieldDefinition, value: str, logger: BoundLogger
) -> tuple[QuerySet, DiscoveryEntity]:
"""
Further filter a queryset using the field defined by field_props and the
given value.
It is a prerequisite that the field mapping defined in field_props is represented
in the queryset object.
`mapping_for_search_filter` is an optional property that gets precedence over `mapping`
for the necessity of filtering. It is not necessary to specify this when
the `mapping` value is based on the same model as the queryset.
"""

# - can throw DiscoveryFilterRewriteException if we cannot rewrite the field mapping as a subpath of the queryset
# model
field, subquery, queried_entity = get_field_django_mapping_and_queried_entity(queryset_entity, field_props)

# TODO: resolve schema including extra properties

def queryset_field_single_value_condition(
queryset_entity: DiscoveryEntity,
field: str,
field_props: FieldDefinition,
value: str,
subquery: DiscoveryFieldSubquery | None,
):
if field_props.datatype == "string":
if gb := field_props.group_by:
# JSONField array string check must use 'contains' lookup
Expand Down Expand Up @@ -457,6 +445,41 @@ async def filter_queryset_field_value(
# values of `datatype` to the cases above.
raise NotImplementedError()

return condition


async def filter_queryset_field_value(
queryset_entity: DiscoveryEntity,
qs: QuerySet,
field_props: FieldDefinition,
value: str | DiscoveryQueryFilterOneOf,
logger: BoundLogger
) -> tuple[QuerySet, DiscoveryEntity]:
"""
Further filter a queryset using the field defined by field_props and the
given value.
It is a prerequisite that the field mapping defined in field_props is represented
in the queryset object.
`mapping_for_search_filter` is an optional property that gets precedence over `mapping`
for the necessity of filtering. It is not necessary to specify this when
the `mapping` value is based on the same model as the queryset.
"""

# - can throw DiscoveryFilterRewriteException if we cannot rewrite the field mapping as a subpath of the queryset
# model
field, subq, queried_entity = get_field_django_mapping_and_queried_entity(queryset_entity, field_props)

# TODO: resolve schema including extra properties

if isinstance(value, DiscoveryQueryFilterOneOf):
# build the OR query if our filter value is DiscoveryQueryFilterOneOf
# TODO: check subquery works here...
condition = queryset_field_single_value_condition(queryset_entity, field, field_props, value.values[0], subq)
for v in value.values[1:]:
condition |= queryset_field_single_value_condition(queryset_entity, field, field_props, v, subq)
else:
condition = queryset_field_single_value_condition(queryset_entity, field, field_props, value, subq)

await logger.adebug(
"filtering entity field with condition", entity=queried_entity, field=field, condition=condition
)
Expand Down
10 changes: 7 additions & 3 deletions chord_metadata_service/discovery/filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .censorship import get_max_query_parameters
from .exceptions import DiscoveryEmptyException
from .fields import get_field_options, filter_queryset_field_value
from .pydantic_models import DiscoveryQuery
from .pydantic_models import DiscoveryQueryFilterOneOf, DiscoveryQuery
from .scope import ValidatedDiscoveryScope
from .utils import get_discovery_field_set_permissions, empty_discovery

Expand All @@ -30,7 +30,7 @@ async def validate_field_query_value(
queryset: QuerySet,
scope: ValidatedDiscoveryScope,
field_id: str,
value: str,
value: str | DiscoveryQueryFilterOneOf,
field_permissions: DataPermissions
):
"""
Expand All @@ -47,7 +47,11 @@ async def validate_field_query_value(
value not in options
and not (
# case-insensitive search on categories
field_props.datatype == "string" and _in_case_insensitive(value, options)
field_props.datatype == "string" and (
_in_case_insensitive(value, options)
if isinstance(value, str)
else all(_in_case_insensitive(v, options) for v in value.values)
)
)
and not (
# no restriction when enum is not set for categories
Expand Down
14 changes: 12 additions & 2 deletions chord_metadata_service/discovery/pydantic_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"DiscoveryMatchesPaginatedResponse",
"DiscoverySearchSectionWithOptions",
"DiscoverySearchFieldsResponse",
"DiscoveryQueryFilterOneOf",
"DiscoveryQuery",
"DiscoveryUIHintsResponse",
]
Expand Down Expand Up @@ -202,6 +203,11 @@ class DiscoverySearchFieldsResponse(BaseModel):
sections: list[DiscoverySearchSectionWithOptions]


class DiscoveryQueryFilterOneOf(BaseModel):
filter_type: Literal["one_of"] # really more like "one or more of" - essentially Boolean Or for filter values
values: list[str] = Field(..., min_length=1) # must have at least one value specified


class DiscoveryQuery(BaseModel):
"""
Model for discovery filtering queries. Right now, this is just a dictionary of {discovery field ID: value} extracted
Expand All @@ -217,8 +223,10 @@ class DiscoveryQuery(BaseModel):
fts: str = Field(default="", title="Full-text search query", max_length=256)
fts_type: FTSType = Field(default="plain", title="Full-text search query type")

# Filter query parameters. Keys in this dictionary must be the IDs of filters in the corresponding discovery config.
filters: dict[str, str] = Field(default_factory=dict, title="Filters")
# Filter query parameters:
# - Keys in this dictionary must be the IDs of filters in the corresponding discovery config.
# - Values can be either a string, or (with query:data permissions) a more advanced filter structure.
filters: dict[str, str | DiscoveryQueryFilterOneOf] = Field(default_factory=dict, title="Filters")

def queried_filter_fields(self) -> list[str]:
return list(self.filters.keys())
Expand All @@ -241,6 +249,8 @@ def from_drf_request(cls, request: DrfRequest) -> "DiscoveryQuery":

params = request.query_params if request.method == "GET" else request.data

# TODO: post JSON - directly validate with Pydantic

# Process query parameters and check validity
filters: dict[str, str] = {
k: v[0] if isinstance(v, list) else v
Expand Down