From 0b446039ef1aaaec309c35a45eee0c1a086322c2 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 16 Sep 2024 22:29:27 +1000 Subject: [PATCH] Fix invalid bbox format for Geomet OGC Features compliance changes --- prez/dependencies.py | 26 +++++- prez/models/ogc_features.py | 7 +- prez/models/query_params.py | 34 +++---- prez/routers/ogc_features_router.py | 41 +++++++-- prez/services/connegp_service.py | 31 ------- prez/services/generate_queryables.py | 4 +- prez/services/listings.py | 115 ++++++++++++++++++++---- prez/services/objects.py | 14 ++- prez/services/query_generation/count.py | 16 ++-- prez/services/query_generation/cql.py | 27 +++--- 10 files changed, 207 insertions(+), 108 deletions(-) diff --git a/prez/dependencies.py b/prez/dependencies.py index efc6e2a2..a4558d8b 100755 --- a/prez/dependencies.py +++ b/prez/dependencies.py @@ -466,10 +466,10 @@ async def get_profile_nodeshape( ) -async def get_url_path( +async def get_url( request: Request, ): - return request.url.path + return request.url async def get_endpoint_uri( @@ -547,3 +547,25 @@ async def get_template_query( template_query = prez_system_graph.value(s, RDF.value, None) return str(template_query) return None + + +async def check_unknown_params(request: Request): + known_params = { + "_mediatype", + "page", + "limit", + "datetime", + "bbox", + "filter-lang", + "filter_crs", + "q", + "filter", + "order_by", + "order_by_direction", + } + unknown_params = set(request.query_params.keys()) - known_params + if unknown_params: + raise HTTPException( + status_code=400, + detail=f"Unknown query parameters: {', '.join(unknown_params)}", + ) diff --git a/prez/models/ogc_features.py b/prez/models/ogc_features.py index 8f2ee2f4..f2b7be95 100644 --- a/prez/models/ogc_features.py +++ b/prez/models/ogc_features.py @@ -17,13 +17,18 @@ class Link(BaseModel): title: Optional[str] = None +class Links(BaseModel): + links: List[Link] + + class OGCFeaturesLandingPage(BaseModel): title: str description: str links: List[Link] -def generate_landing_page_links(url_path): +def generate_landing_page_links(url): + url_path = url.path link_dicts = [ { "href": f"{settings.system_uri}{url_path}", diff --git a/prez/models/query_params.py b/prez/models/query_params.py index 1cf58c11..89ba0e41 100755 --- a/prez/models/query_params.py +++ b/prez/models/query_params.py @@ -124,40 +124,40 @@ class QueryParams: def __init__( self, - mediatype: Optional[str] = Query( - "text/turtle", alias="_mediatype", description="Requested mediatype" + mediatype: str = Query( + default="text/turtle", alias="_mediatype", description="Requested mediatype" ), - page: Optional[int] = Query( - 1, ge=1, description="Page number, must be greater than 0" + page: int = Query( + default=1, ge=1, description="Page number, must be greater than 0" ), - limit: Optional[int] = Query( - 10, + limit: int = Query( + default=10, ge=1, le=10000, description="Number of items per page, must be 1<=limit<=10000", alias="limit", + style="form", + explode=False, ), datetime: Optional[tuple] = Depends(validate_datetime), bbox: List[float] = Depends(reformat_bbox), - filter_lang: Optional[FilterLangEnum] = Query( - "cql2-json", + filter_lang: FilterLangEnum = Query( + default="cql2-json", description="Language of the filter expression", alias="filter-lang", ), - filter_crs: Optional[str] = Query( + filter_crs: str = Query( "http://www.opengis.net/def/crs/OGC/1.3/CRS84", description="CRS used for the filter expression", ), - q: Optional[str] = Query(None, description="Search query", example="building"), - filter: Optional[str] = Query( - None, + q: str = Query(None, description="Search query", example="building"), + filter: str = Query( + default=None, description="CQL JSON expression.", ), - order_by: Optional[str] = Query( - None, description="Optional: Field to order by" - ), - order_by_direction: Optional[OrderByDirectionEnum] = Query( - None, + order_by: str = Query(default=None, description="Optional: Field to order by"), + order_by_direction: OrderByDirectionEnum = Query( + default=None, description="Optional: Order direction, must be 'ASC' or 'DESC'", ), ): diff --git a/prez/routers/ogc_features_router.py b/prez/routers/ogc_features_router.py index 5f3cbbc7..da434765 100755 --- a/prez/routers/ogc_features_router.py +++ b/prez/routers/ogc_features_router.py @@ -1,12 +1,16 @@ from typing import Optional, List from fastapi import Depends, FastAPI +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from starlette import status +from starlette.requests import Request from starlette.responses import StreamingResponse, JSONResponse from prez.dependencies import ( get_data_repo, cql_get_parser_dependency, - get_url_path, + get_url, get_ogc_features_mediatype, get_system_repo, get_endpoint_nodeshapes, @@ -14,6 +18,7 @@ get_endpoint_uri_type, get_ogc_features_path_params, get_template_query, + check_unknown_params, ) from prez.exceptions.model_exceptions import ( ClassNotFoundException, @@ -28,7 +33,6 @@ from prez.repositories import Repo from prez.routers.api_extras_examples import ogc_features_openapi_extras from prez.routers.conformance import router as conformance_router -from prez.services.connegp_service import generate_link_headers from prez.services.exception_catchers import ( catch_400, catch_404, @@ -39,7 +43,7 @@ catch_prefix_not_found_exception, catch_no_profiles_exception, ) -from prez.services.listings import ogc_features_listing_function +from prez.services.listings import ogc_features_listing_function, generate_link_headers from prez.services.objects import ogc_features_object_function from prez.services.query_generation.cql import CQLParser from prez.services.query_generation.shacl import NodeShape @@ -62,15 +66,33 @@ features_subapi.include_router(conformance_router) +@features_subapi.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content=jsonable_encoder( + { + "detail": exc.errors(), + "body": exc.body, + "note": { + "This error was caught as a RequestValidationError which OGC Features " + "specification specifies should be raised with a status code of 400. " + "It would otherwise be a 422 Unprocessable Entity." + }, + } + ), + ) + + @features_subapi.api_route( "/", summary="OGC Features API", methods=ALLOWED_METHODS, ) async def ogc_features_api( - url_path: str = Depends(get_url_path), + url: str = Depends(get_url), ): - links = generate_landing_page_links(url_path) + links = generate_landing_page_links(url) link_headers = generate_link_headers(links) lp = OGCFeaturesLandingPage( title="OGC API - Features", @@ -114,10 +136,11 @@ async def ogc_features_api( openapi_extra=ogc_features_openapi_extras.get("feature-collection"), ) async def listings_with_feature_collection( + validate_unknown_params: bool = Depends(check_unknown_params), endpoint_uri_type: tuple = Depends(get_endpoint_uri_type), endpoint_nodeshape: NodeShape = Depends(get_endpoint_nodeshapes), profile_nodeshape: NodeShape = Depends(get_profile_nodeshape), - url_path: str = Depends(get_url_path), + url: str = Depends(get_url), mediatype: str = Depends(get_ogc_features_mediatype), path_params: dict = Depends(get_ogc_features_path_params), query_params: QueryParams = Depends(), @@ -131,7 +154,7 @@ async def listings_with_feature_collection( endpoint_nodeshape, profile_nodeshape, mediatype, - url_path, + url, data_repo, system_repo, cql_parser, @@ -166,7 +189,7 @@ async def listings_with_feature_collection( async def objects( template_query: Optional[str] = Depends(get_template_query), mediatype: str = Depends(get_ogc_features_mediatype), - url_path: str = Depends(get_url_path), + url: str = Depends(get_url), path_params: dict = Depends(get_ogc_features_path_params), data_repo: Repo = Depends(get_data_repo), system_repo: Repo = Depends(get_system_repo), @@ -175,7 +198,7 @@ async def objects( content, headers = await ogc_features_object_function( template_query, mediatype, - url_path, + url, data_repo, system_repo, **path_params, diff --git a/prez/services/connegp_service.py b/prez/services/connegp_service.py index 9dece688..d4ff47f1 100755 --- a/prez/services/connegp_service.py +++ b/prez/services/connegp_service.py @@ -342,34 +342,3 @@ async def _do_query(self, query: str) -> tuple[Graph, list]: log.debug(tabulate(table_data, headers=headers, tablefmt="grid")) return response - - -def generate_ogc_features_links(url_path: str, selected_mediatype: str) -> List[Link]: - components_after_collections = url_path.split("collections")[1:] - components_len = len(components_after_collections) - - if components_len == 1: # collections or a specific collection - links are the same - self_link = Link( - rel="self", - href=f"{settings.system_uri}{url_path}?{urlencode({'_mediatype': selected_mediatype})}", - type="application/json", - ) - - alt_links = [ - Link( - rel="alternate", - href=f"{settings.system_uri}{url_path}?{urlencode({'_mediatype': mediatype})}", - type=mediatype, - ) - for mediatype in RDF_MEDIATYPES - if mediatype != selected_mediatype - ] - return [self_link] + alt_links - return [] - - -def generate_link_headers(links) -> Dict[str, str]: - link_header = ", ".join( - [f'<{link.href}>; rel="{link.rel}"; type="{link.type}"' for link in links] - ) - return {"Link": link_header} diff --git a/prez/services/generate_queryables.py b/prez/services/generate_queryables.py index d0e24420..f2fe3c78 100644 --- a/prez/services/generate_queryables.py +++ b/prez/services/generate_queryables.py @@ -3,7 +3,7 @@ from prez.reference_data.prez_ns import PREZ, OGCFEAT -def generate_queryables_json(item_graph, annotations_graph, url_path, endpoint_uri): +def generate_queryables_json(item_graph, annotations_graph, url, endpoint_uri): queryable_props = {} for queryable in item_graph.subjects(): queryable_props[str(queryable)] = QueryableProperty( @@ -21,7 +21,7 @@ def generate_queryables_json(item_graph, annotations_graph, url_path, endpoint_u "Local queryable properties for the collection in the OGC Features API." ) queryable_params = { - "$id": f"{settings.system_uri}{url_path}", + "$id": f"{settings.system_uri}{url.path}", "title": title, "description": description, "properties": queryable_props, diff --git a/prez/services/listings.py b/prez/services/listings.py index 87817732..7e01bdcf 100755 --- a/prez/services/listings.py +++ b/prez/services/listings.py @@ -2,7 +2,10 @@ import io import json import logging +from datetime import datetime +from typing import Dict from urllib.parse import urlencode +from zoneinfo import ZoneInfo from fastapi.responses import PlainTextResponse from rdf2geojson import convert @@ -27,10 +30,10 @@ from prez.cache import endpoints_graph_cache from prez.config import settings from prez.enums import NonAnnotatedRDFMediaType -from prez.models.ogc_features import Collection, Link, Collections +from prez.models.ogc_features import Collection, Link, Collections, Links from prez.reference_data.prez_ns import PREZ, ALTREXT, ONT, OGCFEAT from prez.renderers.renderer import return_from_graph, return_annotated_rdf -from prez.services.connegp_service import RDF_MEDIATYPES, generate_link_headers +from prez.services.connegp_service import RDF_MEDIATYPES from prez.services.curie_functions import get_uri_for_curie_id, get_curie_id_for_uri from prez.services.generate_queryables import generate_queryables_json from prez.services.link_generation import add_prez_links @@ -145,13 +148,15 @@ async def ogc_features_listing_function( endpoint_nodeshape, profile_nodeshape, selected_mediatype, - url_path, + url, data_repo, system_repo, cql_parser, query_params, **path_params, ): + count_query = None + count = 0 collectionId = path_params.get("collectionId") subselect_kwargs = merge_listing_query_grammar_inputs( endpoint_nodeshape=endpoint_nodeshape, @@ -198,8 +203,11 @@ async def ogc_features_listing_function( construct_tss_list=construct_tss_list, profile_triples=profile_nodeshape.tssp_list, **subselect_kwargs, - ).to_string() - queries.append(query) + ) + queries.append(query.to_string()) + # add the count query + subselect = copy.deepcopy(query.inner_select) + count_query = CountQuery(original_subselect=subselect).to_string() else: # list items in a Feature Collection # add inbound links - not currently possible via profiles opt_inbound_gpnt = _add_inbound_triple_pattern_match(construct_tss_list) @@ -210,12 +218,16 @@ async def ogc_features_listing_function( profile_triples=profile_nodeshape.tssp_list, profile_gpnt=profile_nodeshape.gpnt_list, **subselect_kwargs, - ).to_string() - queries.append(feature_list_query) + ) + queries.append(feature_list_query.to_string()) + + # add the count query + subselect = copy.deepcopy(feature_list_query.inner_select) + count_query = CountQuery(original_subselect=subselect).to_string() - collection_uri = await get_uri_for_curie_id(collectionId) # Features listing requires CBD of the Feature Collection as well; reuse items profile to get all props/bns to # depth two. + collection_uri = await get_uri_for_curie_id(collectionId) gpnt = GraphPatternNotTriples( content=Bind( expression=Expression.from_primary_expression( @@ -236,9 +248,7 @@ async def ogc_features_listing_function( ).to_string() queries.append(feature_collection_query) - # link_headers = generate_link_headers(links) link_headers = None - if selected_mediatype == "application/sparql-query": # queries_dict = {f"query_{i}": query for i, query in enumerate(queries)} # just do the first query for now: @@ -248,6 +258,10 @@ async def ogc_features_listing_function( item_graph, _ = await data_repo.send_queries(queries, []) annotations_graph = await return_annotated_rdf(item_graph, data_repo, system_repo) + if count_query: + count_g, _ = await data_repo.send_queries([count_query], []) + if count_g: + count = int(next(iter(count_g.objects()))) if selected_mediatype == "application/json": if endpoint_uri_type[0] in [ @@ -255,7 +269,7 @@ async def ogc_features_listing_function( OGCFEAT["queryables-global"], ]: queryables = generate_queryables_json( - item_graph, annotations_graph, url_path, endpoint_uri_type[0] + item_graph, annotations_graph, url, endpoint_uri_type[0] ) content = io.BytesIO( queryables.model_dump_json(exclude_none=True, by_alias=True).encode( @@ -264,7 +278,12 @@ async def ogc_features_listing_function( ) else: collections = create_collections_json( - item_graph, annotations_graph, url_path, selected_mediatype + item_graph, + annotations_graph, + url, + selected_mediatype, + query_params, + count, ) all_links = collections.links for coll in collections.collections: @@ -276,6 +295,13 @@ async def ogc_features_listing_function( elif selected_mediatype == "application/geo+json": geojson = convert(g=item_graph, do_validate=False, iri2id=get_curie_id_for_uri) + all_links = create_self_alt_links(selected_mediatype, url, query_params, count) + all_links_dict = Links(links=all_links).model_dump() + link_headers = generate_link_headers(all_links) + geojson["links"] = all_links_dict["links"] + geojson["timeStamp"] = get_brisbane_timestamp() + geojson["numberMatched"] = count + geojson["numberReturned"] = len(geojson["features"]) content = io.BytesIO(json.dumps(geojson).encode("utf-8")) elif selected_mediatype in NonAnnotatedRDFMediaType: content = io.BytesIO( @@ -301,7 +327,7 @@ def _add_inbound_triple_pattern_match(construct_tss_list): def create_collections_json( - item_graph, annotations_graph, url_path, selected_mediatype + item_graph, annotations_graph, url, selected_mediatype, query_params, count ): collections_list = [] for s, p, o in item_graph.triples((None, RDF.type, GEO.FeatureCollection)): @@ -318,7 +344,7 @@ def create_collections_json( links=[ Link( href=URIRef( - f"{settings.system_uri}{url_path}/{curie_id}/items?{urlencode({'_mediatype': mt})}" + f"{settings.system_uri}{url.path}/{curie_id}/items?{urlencode({'_mediatype': mt})}" ), rel="items", type=mt, @@ -327,21 +353,61 @@ def create_collections_json( ], ) ) + self_alt_links = create_self_alt_links(selected_mediatype, url, query_params, count) collections = Collections( collections=collections_list, - links=[ + links=self_alt_links, + ) + return collections + + +def create_self_alt_links(selected_mediatype, url, query_params, count): + self_alt_links = [] + for mt in [selected_mediatype, *RDF_MEDIATYPES]: + self_alt_links.append( Link( href=URIRef( - f"{settings.system_uri}{url_path}?{urlencode({'_mediatype': mt})}" + f"{settings.system_uri}{url.path}?{urlencode({'_mediatype': mt})}" ), rel="self" if mt == selected_mediatype else "alternate", type=mt, title="this document", ) - for mt in ["application/json", *RDF_MEDIATYPES] - ], + ) + page = query_params.page + limit = query_params.limit + if page != 1: + prev_page = page - 1 + self_alt_links.append( + Link( + href=URIRef( + f"{settings.system_uri}{url.path}?{urlencode({'_mediatype': selected_mediatype, 'page': prev_page, 'limit': limit})}" + ), + rel="prev", + type=selected_mediatype, + title="previous page", + ) + ) + if count > page * limit: + next_page = page + 1 + self_alt_links.append( + Link( + href=URIRef( + f"{settings.system_uri}{url.path}?{urlencode({'_mediatype': selected_mediatype, 'page': next_page, 'limit': limit})}" + ), + rel="next", + type=selected_mediatype, + title="next page", + ) + ) + return self_alt_links + + +def generate_link_headers(links) -> Dict[str, str]: + link_header = ", ".join( + [f'<{link.href}>; rel="{link.rel}"; type="{link.type}"' for link in links] ) - return collections + return {"Link": link_header} async def handle_alt_profile(original_endpoint_type, pmts): @@ -361,3 +427,14 @@ async def handle_alt_profile(original_endpoint_type, pmts): # 'dynamicaly' expressed in SHACL. The class is only known at runtime ) return endpoint_nodeshape + + +def get_brisbane_timestamp(): + # Get current time in Brisbane + brisbane_time = datetime.now(ZoneInfo("Australia/Brisbane")) + + # Format the timestamp + timestamp = brisbane_time.strftime("%Y-%m-%dT%H:%M:%S%z") + + # Insert colon in timezone offset + return f"{timestamp[:-2]}:{timestamp[-2:]}" diff --git a/prez/services/objects.py b/prez/services/objects.py index e22b6355..c404e0ae 100755 --- a/prez/services/objects.py +++ b/prez/services/objects.py @@ -14,10 +14,10 @@ from prez.models.query_params import QueryParams from prez.reference_data.prez_ns import ALTREXT, ONT, PREZ from prez.renderers.renderer import return_from_graph, return_annotated_rdf -from prez.services.connegp_service import RDF_MEDIATYPES, generate_link_headers +from prez.services.connegp_service import RDF_MEDIATYPES from prez.services.curie_functions import get_uri_for_curie_id, get_curie_id_for_uri from prez.services.link_generation import add_prez_links -from prez.services.listings import listing_function +from prez.services.listings import listing_function, generate_link_headers from prez.services.query_generation.umbrella import ( PrezQueryConstructor, ) @@ -98,7 +98,7 @@ async def object_function( async def ogc_features_object_function( template_query, selected_mediatype, - url_path, + url, data_repo, system_repo, **path_params, @@ -144,7 +144,7 @@ async def ogc_features_object_function( content = io.BytesIO(query.encode("utf-8")) elif selected_mediatype == "application/json": collection = create_collection_json( - collectionId, collection_uri, annotations_graph, url_path + collectionId, collection_uri, annotations_graph, url ) link_headers = generate_link_headers(collection.links) content = io.BytesIO( @@ -160,9 +160,7 @@ async def ogc_features_object_function( return content, link_headers -def create_collection_json( - collection_curie, collection_uri, annotations_graph, url_path -): +def create_collection_json(collection_curie, collection_uri, annotations_graph, url): return Collection( id=collection_curie, title=annotations_graph.value( @@ -174,7 +172,7 @@ def create_collection_json( links=[ Link( href=URIRef( - f"{settings.system_uri}{url_path}/items?{urlencode({'_mediatype': mt})}" + f"{settings.system_uri}{url.path}/items?{urlencode({'_mediatype': mt})}" ), rel="items", type=mt, diff --git a/prez/services/query_generation/count.py b/prez/services/query_generation/count.py index f21b72a6..e1e92d39 100755 --- a/prez/services/query_generation/count.py +++ b/prez/services/query_generation/count.py @@ -40,7 +40,7 @@ class CountQuery(ConstructQuery): """ Counts focus nodes that can be retrieved for listing queries. - Default limit is 1000 and can be configured in the settings. + Default limit is 100 and can be configured in the settings. Query is of the form: CONSTRUCT { @@ -53,10 +53,10 @@ class CountQuery(ConstructQuery): SELECT ?focus_node WHERE { <<< original where clause >>> - } LIMIT 1001 + } LIMIT 101 } } - BIND(IF(?count = 1001, ">1000", STR(?count)) AS ?count_str) + BIND(IF(?count = 101, ">100", STR(?count)) AS ?count_str) } """ @@ -95,7 +95,7 @@ def __init__(self, original_subselect: SubSelect): ), ) outer_ss_ggp = GroupGraphPattern(content=outer_ss) - count_equals_1001_expr = Expression( + count_equals_limit_expr = Expression( conditional_or_expression=ConditionalOrExpression( conditional_and_expressions=[ ConditionalAndExpression( @@ -134,7 +134,7 @@ def __init__(self, original_subselect: SubSelect): ] ) ) - gt_1000_exp = Expression.from_primary_expression( + gt_limit_exp = Expression.from_primary_expression( PrimaryExpression(content=RDFLiteral(value=f">{limit}")) ) str_count_exp = Expression.from_primary_expression( @@ -150,7 +150,11 @@ def __init__(self, original_subselect: SubSelect): PrimaryExpression( content=BuiltInCall( function_name="IF", - arguments=[count_equals_1001_expr, gt_1000_exp, str_count_exp], + arguments=[ + count_equals_limit_expr, + gt_limit_exp, + str_count_exp, + ], ) ) ), diff --git a/prez/services/query_generation/cql.py b/prez/services/query_generation/cql.py index 67a36068..0d3eb73a 100755 --- a/prez/services/query_generation/cql.py +++ b/prez/services/query_generation/cql.py @@ -137,7 +137,7 @@ def parse(self): self.inner_select_gpnt_list = gpnt_list def parse_logical_operators( - self, element, existing_ggps=None + self, element, existing_ggps=None ) -> Generator[GroupGraphPatternSub, None, None]: operator = element.get(str(CQL.operator))[0].get("@value") args = element.get(str(CQL.args)) @@ -418,9 +418,9 @@ def _handle_temporal(self, comp_func, args, existing_ggps=None): # check if the arg is a date date = ( - arg.get(str(CQL.date)) - or arg.get(str(CQL.datetime)) - or arg.get(str(CQL.timestamp)) + arg.get(str(CQL.date)) + or arg.get(str(CQL.datetime)) + or arg.get(str(CQL.timestamp)) ) if date: date_val = date[0].get("@value") @@ -522,11 +522,13 @@ def _dt_to_rdf_literal(self, i, dt_str, label, operands): def format_coordinates_as_wkt(bbox_values): if len(bbox_values) == 4: coordinates = [ - (bbox_values[0], bbox_values[1]), - (bbox_values[0], bbox_values[3]), - (bbox_values[2], bbox_values[3]), - (bbox_values[2], bbox_values[1]), - (bbox_values[0], bbox_values[1]), + [ + [bbox_values[0], bbox_values[1]], + [bbox_values[0], bbox_values[3]], + [bbox_values[2], bbox_values[3]], + [bbox_values[2], bbox_values[1]], + [bbox_values[0], bbox_values[1]], + ] ] else: if len(bbox_values) == 6: @@ -574,8 +576,7 @@ def create_temporal_filter_gpnt(dt: datetime, op: str) -> GraphPatternNotTriples def create_temporal_or_gpnt( - comparisons: list[tuple[Var | RDFLiteral, str, Var | RDFLiteral]], - negated=False + comparisons: list[tuple[Var | RDFLiteral, str, Var | RDFLiteral]], negated=False ) -> GraphPatternNotTriples: """ Create a FILTER with multiple conditions joined by OR (||). @@ -661,7 +662,7 @@ def create_temporal_or_gpnt( ) ) ) - ) + ), ) ) ) @@ -702,7 +703,7 @@ def create_filter_bool_gpnt(boolean: bool) -> GraphPatternNotTriples: def create_temporal_and_gpnt( - comparisons: list[tuple[Var | RDFLiteral, str, Var | RDFLiteral]] + comparisons: list[tuple[Var | RDFLiteral, str, Var | RDFLiteral]] ) -> GraphPatternNotTriples: """ Create a FILTER with multiple conditions joined by AND.