diff --git a/common/pom.xml b/common/pom.xml index 5392bfe4..1cb156e6 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -127,6 +127,18 @@ junit-jupiter + + org.mockito + mockito-core + ${version.org.mockito.mockito-junit-jupiter} + test + + + org.mockito + mockito-junit-jupiter + test + + io.github.resilience4j resilience4j-retry diff --git a/common/src/main/java/org/fiware/tmforum/common/querying/ConnectedQueryPart.java b/common/src/main/java/org/fiware/tmforum/common/querying/ConnectedQueryPart.java new file mode 100644 index 00000000..f22e7363 --- /dev/null +++ b/common/src/main/java/org/fiware/tmforum/common/querying/ConnectedQueryPart.java @@ -0,0 +1,10 @@ +package org.fiware.tmforum.common.querying; + +/** + * A single raw TMForum query token paired with the {@link LogicalOperator} that connected it to + * the previous token in the original left-to-right query string. The first token in a query has + * no predecessor; its connector is conventionally {@link LogicalOperator#AND} but must not be + * consulted by callers. + */ +public record ConnectedQueryPart(String rawParameter, LogicalOperator connectorToPrevious) { +} diff --git a/common/src/main/java/org/fiware/tmforum/common/querying/QueryParser.java b/common/src/main/java/org/fiware/tmforum/common/querying/QueryParser.java index 975d9d2c..a4560c8e 100644 --- a/common/src/main/java/org/fiware/tmforum/common/querying/QueryParser.java +++ b/common/src/main/java/org/fiware/tmforum/common/querying/QueryParser.java @@ -9,21 +9,21 @@ import io.github.wistefan.mapping.annotations.RelationshipObject; import io.micronaut.context.annotation.Bean; import lombok.RequiredArgsConstructor; - import lombok.extern.slf4j.Slf4j; import org.fiware.tmforum.common.configuration.GeneralProperties; -import org.fiware.tmforum.common.domain.Entity; import org.fiware.tmforum.common.exception.QueryException; -import javax.smartcardio.ATR; import java.lang.annotation.Annotation; -import java.lang.reflect.Method; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; -import java.util.stream.Stream; import static io.github.wistefan.mapping.JavaObjectMapper.getGetterMethodByName; - import static org.fiware.tmforum.common.querying.Operator.GREATER_THAN; import static org.fiware.tmforum.common.querying.Operator.GREATER_THAN_EQUALS; import static org.fiware.tmforum.common.querying.Operator.LESS_THAN; @@ -71,6 +71,11 @@ private static List translateJsonLdReservedTokens(List pathParts public static final String NGSI_LD_AND = ";"; + // NGSI-LD's own not-exists prefix operator (e.g. `!relatedParty.datasetId`), reused verbatim + // as the sentinel QueryPart#operator() value for a "not exists" term (QueryPart#value() is + // null in that case). + public static final String NOT_EXISTS_PREFIX = "!"; + // the "," in tm-forum values is an or public static final String TMFORUM_OR_VALUE = ","; @@ -153,70 +158,30 @@ private static String removeWellKnownParameters(String queryString) { public QueryParams toNgsiLdQuery(Class queryClass, String queryString) { queryString = removeWellKnownParameters(queryString); - List parameters; - LogicalOperator logicalOperator = LogicalOperator.AND; - // tm-forum does not define queries combining AND and OR - if (queryString.contains(TMFORUM_AND) && queryString.contains(TMFORUM_OR_KEY)) { - throw new QueryException("Combining AND(&) and OR(;) on query level is not supported by the TMForum API."); - } - if (queryString.contains(TMFORUM_AND)) { - parameters = Arrays.asList(queryString.split(TMFORUM_AND)); - logicalOperator = LogicalOperator.AND; - } else if (queryString.contains(TMFORUM_OR_KEY)) { - parameters = Arrays.asList(queryString.split(TMFORUM_OR_KEY)); - logicalOperator = LogicalOperator.OR; - } else { - //query is just a single parameter query - parameters = List.of(queryString); - } + // NGSI-LD's q= already defines AND-before-OR precedence for an un-parenthesized term + // chain (ETSI GS CIM 009 §4.9), so mixing & and ; here does not need any grouping/ + // parenthesization logic on our side - we only need to preserve, for each pair of + // adjacent parameters, which separator connected them, and translate them in the same + // left-to-right order. A "run" is a maximal sequence of OR-connected parameters; runs + // themselves are always AND-connected to each other by construction. + List tokens = QueryTokenizer.tokenize(queryString); + List> orRuns = splitIntoOrRuns(tokens); - Stream queryPartsStream = parameters - .stream() - .map(this::parseParameter); - - // collect the or values to single entries if they use the same key - if (logicalOperator == LogicalOperator.OR) { - Map> collectedParts = queryPartsStream.collect( - Collectors.toMap(QueryPart::attribute, qp -> new ArrayList<>(List.of(qp)), - (qp1, qp2) -> { - qp1.addAll(qp2); - return qp1; - })); - queryPartsStream = collectedParts.entrySet().stream() - .flatMap(entry -> combineParts(entry.getKey(), entry.getValue()).stream()); - } List ids = new ArrayList<>(); List types = new ArrayList<>(); - // translate the attributes - Stream queryStrings = queryPartsStream.map(qp -> { - List path = translateJsonLdReservedTokens( - Arrays.asList(qp.attribute().split("\\."))); - NgsiLdAttribute attribute = JavaObjectMapper.getNGSIAttributePath( - path, - queryClass); - if (attribute.path().isEmpty()) { - log.info("Attribute {} does not have a path in the base class. Get path to additional attributes.", qp.attribute()); - attribute = getPathToAdditionalAttributes(qp); - } - if (attribute.path().size() == 1 && attribute.path().contains("id")) { - ids.add(qp.value()); - return null; - } - if (attribute.path().size() == 1 && attribute.path().contains("type")) { - types.add(qp.value()); - return null; - } - return toQueryString(getQueryPart(attribute, qp, isRelationship(queryClass, attribute)), attribute.type()); + List runFragments = orRuns.stream() + .map(run -> { + List runParts = run.stream() + .map(cqp -> parseParameter(cqp.rawParameter())) + .toList(); + List combinedParts = runParts.size() > 1 ? combineOrRun(runParts) : runParts; + return translateRun(combinedParts, queryClass, ids, types); }) - .filter(Objects::nonNull); - + .filter(fragment -> !fragment.isEmpty()) + .toList(); - String ngsidOrKey = generalProperties.getNgsildOrQueryKey(); - String query = switch (logicalOperator) { - case AND -> queryStrings.collect(Collectors.joining(NGSI_LD_AND)); - case OR -> queryStrings.collect(Collectors.joining(ngsidOrKey)); - }; + String query = String.join(NGSI_LD_AND, runFragments); String idList = null; if (!ids.isEmpty()) { @@ -232,12 +197,108 @@ public QueryParams toNgsiLdQuery(Class queryClass, String queryString) { return new QueryParams(idList, typeList, query); } + /** + * Splits parameters into maximal contiguous runs of OR-connected parameters. A new run + * starts every time an AND-connector is encountered; runs are therefore always AND-connected + * to each other, and every parameter within a run is OR-connected to its neighbours. + */ + private static List> splitIntoOrRuns(List tokens) { + List> runs = new ArrayList<>(); + List currentRun = new ArrayList<>(); + for (ConnectedQueryPart token : tokens) { + if (!currentRun.isEmpty() && token.connectorToPrevious() == LogicalOperator.AND) { + runs.add(currentRun); + currentRun = new ArrayList<>(); + } + currentRun.add(token); + } + if (!currentRun.isEmpty()) { + runs.add(currentRun); + } + return runs; + } + + /** + * Translates every QueryPart in an OR-run to its query-string fragment (resolving the + * attribute path, routing id=/type= to the accumulators instead of the returned string) and + * joins them with the configured OR key. + */ + private String translateRun(List runParts, Class queryClass, List ids, List types) { + String ngsidOrKey = generalProperties.getNgsildOrQueryKey(); + return runParts.stream() + .map(qp -> translateQueryPart(qp, queryClass, ids, types)) + .filter(Objects::nonNull) + .collect(Collectors.joining(ngsidOrKey)); + } + + private String translateQueryPart(QueryPart qp, Class queryClass, List ids, List types) { + List path = translateJsonLdReservedTokens( + Arrays.asList(qp.attribute().split("\\."))); + NgsiLdAttribute attribute = JavaObjectMapper.getNGSIAttributePath( + path, + queryClass); + if (attribute.path().isEmpty()) { + log.info("Attribute {} does not have a path in the base class. Get path to additional attributes.", qp.attribute()); + attribute = getPathToAdditionalAttributes(qp); + } + boolean isNotExists = qp.operator().equals(NOT_EXISTS_PREFIX); + if (attribute.path().size() == 1 && attribute.path().contains("id")) { + if (isNotExists) { + throw new QueryException("!id is not supported, id always exists."); + } + ids.add(qp.value()); + return null; + } + if (attribute.path().size() == 1 && attribute.path().contains("type")) { + if (isNotExists) { + throw new QueryException("!type is not supported, type always exists."); + } + types.add(qp.value()); + return null; + } + + return toQueryString(getQueryPart(attribute, qp, isRelationship(queryClass, attribute)), attribute.type()); + } + + /** + * Groups the QueryParts of a single OR-run by attribute, the same way the whole query used + * to be grouped when it was entirely OR-connected (see {@link #combineParts}), but scoped to + * one run. Not-exists parts are excluded from grouping (combineParts joins .value() strings, + * which would NPE/produce garbage since a not-exists part's value is null) and are appended + * to the result untouched. + */ + private List combineOrRun(List orRunParts) { + List notExists = orRunParts.stream() + .filter(qp -> qp.operator().equals(NOT_EXISTS_PREFIX)) + .toList(); + List combinable = orRunParts.stream() + .filter(qp -> !qp.operator().equals(NOT_EXISTS_PREFIX)) + .toList(); + Map> collectedParts = combinable.stream() + .collect(Collectors.toMap(QueryPart::attribute, qp -> new ArrayList<>(List.of(qp)), + (qp1, qp2) -> { + qp1.addAll(qp2); + return qp1; + })); + List combined = new ArrayList<>(collectedParts.entrySet().stream() + .flatMap(entry -> combineParts(entry.getKey(), entry.getValue()).stream()) + .toList()); + combined.addAll(notExists); + return combined; + } + private NgsiLdAttribute getPathToAdditionalAttributes(QueryPart queryPart) { List path = new ArrayList<>( Arrays.stream(queryPart.attribute().split("\\.")) .map(ReservedWordHandler::escapeReservedWords) .toList()); + if (queryPart.operator().equals(NOT_EXISTS_PREFIX)) { + // no value to type-sniff; NGSI-LD's not-exists check is type-agnostic. The returned + // type is unused downstream for this case (toQueryString's not-exists branch never + // calls encodeValue). + return new NgsiLdAttribute(path, QueryAttributeType.STRING); + } if (isBoolean(queryPart.value())) { return new NgsiLdAttribute(path, QueryAttributeType.BOOLEAN); } @@ -381,6 +442,10 @@ private List combineParts(String attribute, List uncombine private String toQueryString(QueryPart queryPart, QueryAttributeType queryAttributeType) { + if (queryPart.operator().equals(NOT_EXISTS_PREFIX)) { + return NOT_EXISTS_PREFIX + queryPart.attribute(); + } + if (queryPart.value().contains(TMFORUM_OR_VALUE)) { String theQuery = ""; List encodedValues = new ArrayList<>(Arrays.stream(queryPart.value().split(TMFORUM_OR_VALUE)) @@ -446,6 +511,10 @@ private QueryPart getQueryFromEquals(String parameter) { private QueryPart parseParameter(String parameter) { + if (parameter.startsWith(NOT_EXISTS_PREFIX)) { + return new QueryPart(parameter.substring(NOT_EXISTS_PREFIX.length()), NOT_EXISTS_PREFIX, null); + } + Operator operator = getOperatorFromParam(parameter); return switch (operator) { case GREATER_THAN -> paramsToQueryPart(parameter, GREATER_THAN); diff --git a/common/src/main/java/org/fiware/tmforum/common/querying/QueryTokenizer.java b/common/src/main/java/org/fiware/tmforum/common/querying/QueryTokenizer.java new file mode 100644 index 00000000..2e5ec7ba --- /dev/null +++ b/common/src/main/java/org/fiware/tmforum/common/querying/QueryTokenizer.java @@ -0,0 +1,39 @@ +package org.fiware.tmforum.common.querying; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Splits a raw TMForum query string into tokens, preserving which separator ({@code &}=AND, + * {@code ;}=OR) connected each token to the previous one. Unlike a plain {@code String.split}, + * this does not discard the separators, which is what lets callers translate a query that mixes + * AND and OR without picking a single global {@link LogicalOperator} for the whole string. + */ +public final class QueryTokenizer { + + private static final Pattern SEPARATOR = Pattern.compile("([&;])"); + + private QueryTokenizer() { + } + + /** + * The first token's connector is {@link LogicalOperator#AND} by convention and must not be + * consulted by callers (it has no predecessor). + */ + public static List tokenize(String queryString) { + List tokens = new ArrayList<>(); + Matcher matcher = SEPARATOR.matcher(queryString); + int lastEnd = 0; + LogicalOperator nextConnector = LogicalOperator.AND; + while (matcher.find()) { + String token = queryString.substring(lastEnd, matcher.start()); + tokens.add(new ConnectedQueryPart(token, nextConnector)); + nextConnector = matcher.group(1).equals("&") ? LogicalOperator.AND : LogicalOperator.OR; + lastEnd = matcher.end(); + } + tokens.add(new ConnectedQueryPart(queryString.substring(lastEnd), nextConnector)); + return tokens; + } +} diff --git a/common/src/main/java/org/fiware/tmforum/common/querying/SubscriptionQueryParser.java b/common/src/main/java/org/fiware/tmforum/common/querying/SubscriptionQueryParser.java index 600590a1..6fb874b5 100644 --- a/common/src/main/java/org/fiware/tmforum/common/querying/SubscriptionQueryParser.java +++ b/common/src/main/java/org/fiware/tmforum/common/querying/SubscriptionQueryParser.java @@ -23,43 +23,43 @@ public class SubscriptionQueryParser { public static final String TMFORUM_OR_KEY = ";"; public static final String TMFORUM_AND = "&"; + private enum TokenKind { + EVENT_TYPE, FIELDS, QUERY + } + + private record ClassifiedToken(ConnectedQueryPart token, TokenKind kind) { + } public static SubscriptionQuery parse(String queryString, List defaultEventGroups) { SubscriptionQuery subscriptionQuery = new SubscriptionQuery(); if (queryString != null && !queryString.isEmpty()) { - List parameters; - LogicalOperator logicalOperator = LogicalOperator.AND; - // tmforum does not define queries combining AND and OR - if (queryString.contains(TMFORUM_AND) && queryString.contains(TMFORUM_OR_KEY)) { - throw new QueryException("Combining AND(&) and OR(;) on query level is not supported by the TMForum API."); - } - if (queryString.contains(TMFORUM_AND)) { - parameters = Arrays.asList(queryString.split(TMFORUM_AND)); - } else if (queryString.contains(TMFORUM_OR_KEY)) { - parameters = Arrays.asList(queryString.split(TMFORUM_OR_KEY)); - logicalOperator = LogicalOperator.OR; - } else { - //query is just a single parameter query - parameters = List.of(queryString); - } - - List queryParams = new ArrayList<>(); - parameters.forEach(parameter -> { - if (parameter.startsWith(EVENT_TYPE_KEY)) { - subscriptionQuery.addEventType(getParamValue(parameter)); - } else if (parameter.startsWith(FIELDS_KEY)) { - subscriptionQuery.setFields(parseFields(getParamValue(parameter))); - } else { - queryParams.add(removeEventPrefixFromAttributePath(parameter)); + // NGSI-LD's q= already defines AND-before-OR precedence for an un-parenthesized term + // chain, so mixing & and ; here does not require any grouping logic - we only need to + // preserve, per adjacent pair of tokens, which separator connected them. Classification + // (eventType/fields/query) is kept attached to each token so checkLogicalOperator can + // inspect specific adjacencies instead of a single whole-query operator. + List classifiedTokens = QueryTokenizer.tokenize(queryString).stream() + .map(token -> new ClassifiedToken(token, classify(token.rawParameter()))) + .toList(); + + List queryTokens = new ArrayList<>(); + classifiedTokens.forEach(classified -> { + String parameter = classified.token().rawParameter(); + switch (classified.kind()) { + case EVENT_TYPE -> subscriptionQuery.addEventType(getParamValue(parameter)); + case FIELDS -> subscriptionQuery.setFields(parseFields(getParamValue(parameter))); + case QUERY -> queryTokens.add(new ConnectedQueryPart( + removeEventPrefixFromAttributePath(parameter), classified.token().connectorToPrevious())); } }); - String theQuery = String.join(logicalOperator == LogicalOperator.AND ? TMFORUM_AND : TMFORUM_OR_KEY, queryParams); + + String theQuery = joinPreservingConnectors(queryTokens); if (!theQuery.isEmpty()) { subscriptionQuery.setQuery(theQuery); } - checkLogicalOperator(logicalOperator, subscriptionQuery); + checkLogicalOperator(classifiedTokens); } if (subscriptionQuery.getEventTypes().isEmpty()) { @@ -75,20 +75,60 @@ public static SubscriptionQuery parse(String queryString, List defaultEv return subscriptionQuery; } - private static void checkLogicalOperator(LogicalOperator logicalOperator, SubscriptionQuery subscriptionQuery) { - if (logicalOperator == LogicalOperator.OR) { - if (!subscriptionQuery.getFields().isEmpty()) { - throw new QueryException("Logical operator OR(;) cannot be used with 'fields' selector"); + private static TokenKind classify(String parameter) { + if (parameter.startsWith(EVENT_TYPE_KEY)) { + return TokenKind.EVENT_TYPE; + } + if (parameter.startsWith(FIELDS_KEY)) { + return TokenKind.FIELDS; + } + return TokenKind.QUERY; + } + + /** + * Re-joins the surviving query-bound tokens, preserving each token's own recorded connector + * to the previous *original* token - not "repaired" against the nearest surviving + * predecessor if eventType/fields tokens were filtered out in between. This is the simplest, + * most predictable rule, and the one today's single-separator behavior already implied. + */ + private static String joinPreservingConnectors(List queryTokens) { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < queryTokens.size(); i++) { + if (i > 0) { + result.append(queryTokens.get(i).connectorToPrevious() == LogicalOperator.AND ? TMFORUM_AND : TMFORUM_OR_KEY); } - if (!subscriptionQuery.getEventTypes().isEmpty() && subscriptionQuery.getQuery() != null && !subscriptionQuery.getQuery().isEmpty()) { + result.append(queryTokens.get(i).rawParameter()); + } + return result.toString(); + } + + /** + * Both remaining rules have a real technical grounding (unlike the removed "OR cannot combine + * with fields" rule, which had none): {@code eventType} membership is applied as a hard + * pre-filter at the broker/storage level, structurally separate from and prior to the content + * {@code query} match, so "OR between eventType and query" can never actually be honored by + * this architecture; and multiple {@code eventType=} values ANDed together can never match any + * real event (an event has exactly one type). Both are now checked per specific adjacency + * rather than "this operator was used somewhere in the whole query", so they only fire when + * the two conflicting tokens are actually connected to each other. + */ + private static void checkLogicalOperator(List classifiedTokens) { + for (int i = 1; i < classifiedTokens.size(); i++) { + ClassifiedToken previous = classifiedTokens.get(i - 1); + ClassifiedToken current = classifiedTokens.get(i); + LogicalOperator connector = current.token().connectorToPrevious(); + + boolean isEventTypeQueryPair = (previous.kind() == TokenKind.EVENT_TYPE && current.kind() == TokenKind.QUERY) + || (previous.kind() == TokenKind.QUERY && current.kind() == TokenKind.EVENT_TYPE); + if (connector == LogicalOperator.OR && isEventTypeQueryPair) { throw new QueryException("Logical operator OR(;) cannot be used when both 'eventType' and 'query' are defined"); } - } else { - if (subscriptionQuery.getEventTypes().size() > 1) { + + boolean isEventTypePair = previous.kind() == TokenKind.EVENT_TYPE && current.kind() == TokenKind.EVENT_TYPE; + if (connector == LogicalOperator.AND && isEventTypePair) { throw new QueryException("Logical operator AND(&) cannot be used when several 'eventType' are defined"); } } - } private static List parseFields(String fields) { @@ -97,11 +137,16 @@ private static List parseFields(String fields) { } private static String removeEventPrefixFromAttributePath(String attributePath) { - if (attributePath.startsWith(EVENT_PREFIX)) { - return attributePath.substring(attributePath.indexOf(".") + 1); - } else { - return attributePath; - } + // A leading "!" (not-exists) must not stop "event." from being recognised - strip it + // first and reattach it once the event prefix has been removed. + boolean notExists = attributePath.startsWith(QueryParser.NOT_EXISTS_PREFIX); + String withoutNotExists = notExists + ? attributePath.substring(QueryParser.NOT_EXISTS_PREFIX.length()) + : attributePath; + String withoutEventPrefix = withoutNotExists.startsWith(EVENT_PREFIX) + ? withoutNotExists.substring(withoutNotExists.indexOf(".") + 1) + : withoutNotExists; + return notExists ? QueryParser.NOT_EXISTS_PREFIX + withoutEventPrefix : withoutEventPrefix; } private static String getParamValue(String parameter) { diff --git a/common/src/main/java/org/fiware/tmforum/common/querying/SubscriptionQueryResolver.java b/common/src/main/java/org/fiware/tmforum/common/querying/SubscriptionQueryResolver.java index 140a7b84..6c498b00 100644 --- a/common/src/main/java/org/fiware/tmforum/common/querying/SubscriptionQueryResolver.java +++ b/common/src/main/java/org/fiware/tmforum/common/querying/SubscriptionQueryResolver.java @@ -9,58 +9,45 @@ import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.stream.Stream; import static org.fiware.tmforum.common.querying.Operator.*; @Bean public class SubscriptionQueryResolver { - // the ";" in tm-forum parameters is an or - private static final String TMFORUM_OR_KEY = ";"; - private static final String TMFORUM_AND = "&"; - private LogicalOperator logicalOperator; - private Stream queryPartsStream; + private List connectedTokens; + private List queryParts; private String payloadName; private void init(String queryString, String payloadName) { - // tmforum does not define queries combining AND and OR - if (queryString.contains(TMFORUM_AND) && queryString.contains(TMFORUM_OR_KEY)) { - throw new QueryException("Combining AND(&) and OR(;) on query level is not supported by the TMForum API."); - } - this.payloadName = payloadName; - logicalOperator = LogicalOperator.AND; - - List parameters; - if (queryString.contains(TMFORUM_AND)) { - parameters = Arrays.asList(queryString.split(TMFORUM_AND)); - } else if (queryString.contains(TMFORUM_OR_KEY)) { - logicalOperator = LogicalOperator.OR; - parameters = Arrays.asList(queryString.split(TMFORUM_OR_KEY)); + // NGSI-LD's own AND-before-OR precedence (see QueryParser/SubscriptionQueryParser) makes + // mixing & and ; safe to evaluate without any grouping logic - evaluateResult folds the + // per-token results left-to-right, respecting each token's own recorded connector. + connectedTokens = QueryTokenizer.tokenize(queryString); + queryParts = connectedTokens.stream() + .map(cqp -> parseParameter(cqp.rawParameter())) + .toList(); + } + + private QueryPart parseParameter(String parameter) { + if (parameter.startsWith(QueryParser.NOT_EXISTS_PREFIX)) { + return new QueryPart(parameter.substring(QueryParser.NOT_EXISTS_PREFIX.length()), + QueryParser.NOT_EXISTS_PREFIX, null); + } + if (parameter.contains(GREATER_THAN_EQUALS.getTmForumOperator().operator())) { + return paramsToQueryPart(parameter, GREATER_THAN_EQUALS); + } else if (parameter.contains(Operator.LESS_THAN_EQUALS.getTmForumOperator().operator())) { + return paramsToQueryPart(parameter, LESS_THAN_EQUALS); + } else if (parameter.contains(Operator.REGEX.getTmForumOperator().operator())) { + return paramsToQueryPart(parameter, REGEX); + } else if (parameter.contains(GREATER_THAN.getTmForumOperator().operator())) { + return paramsToQueryPart(parameter, GREATER_THAN); + } else if (parameter.contains(LESS_THAN.getTmForumOperator().operator())) { + return paramsToQueryPart(parameter, LESS_THAN); } else { - parameters = List.of(queryString); + return getQueryFromEquals(parameter); } - - queryPartsStream = parameters - .stream() - .map(parameter -> { - QueryPart queryPart; - if (parameter.contains(GREATER_THAN_EQUALS.getTmForumOperator().operator())) { - queryPart = paramsToQueryPart(parameter, GREATER_THAN_EQUALS); - } else if (parameter.contains(Operator.LESS_THAN_EQUALS.getTmForumOperator().operator())) { - queryPart = paramsToQueryPart(parameter, LESS_THAN_EQUALS); - } else if (parameter.contains(Operator.REGEX.getTmForumOperator().operator())) { - queryPart = paramsToQueryPart(parameter, REGEX); - } else if (parameter.contains(GREATER_THAN.getTmForumOperator().operator())) { - queryPart = paramsToQueryPart(parameter, GREATER_THAN); - } else if (parameter.contains(LESS_THAN.getTmForumOperator().operator())) { - queryPart = paramsToQueryPart(parameter, LESS_THAN); - } else { - queryPart = getQueryFromEquals(parameter); - } - return queryPart; - }); } public boolean doesQueryMatchCreateEvent(String queryString, T entity, String payloadName) { @@ -70,16 +57,11 @@ public boolean doesQueryMatchCreateEvent(String queryString, T entity, Strin init(queryString, payloadName); - Stream results = queryPartsStream.map(qp -> { - FieldData fieldData = getFieldData(entity, qp); - if (!fieldData.exists()) { - return false; - } - - return matches(qp, fieldData); - }); + List results = queryParts.stream() + .map(qp -> resolveCreateMatch(qp, entity)) + .toList(); - return evaluateResult(results); + return evaluateResult(connectedTokens, results); } public boolean doesQueryMatchUpdateEvent(String queryString, T entity, T oldState, String payloadName) { @@ -89,27 +71,58 @@ public boolean doesQueryMatchUpdateEvent(String queryString, T entity, T old init(queryString, payloadName); - Stream results = queryPartsStream.map(qp -> { - FieldData updatedFieldData = getFieldData(entity, qp); - FieldData oldFieldData = getFieldData(oldState, qp); + List results = queryParts.stream() + .map(qp -> resolveUpdateMatch(qp, entity, oldState)) + .toList(); - if (updatedFieldData.fieldValue != null && !updatedFieldData.fieldValue.equals(oldFieldData.fieldValue) || - oldFieldData.fieldValue != null && !oldFieldData.fieldValue.equals(updatedFieldData.fieldValue)) { - return matches(qp, updatedFieldData); - } else { - return false; - } + return evaluateResult(connectedTokens, results); + } - }); + private boolean resolveCreateMatch(QueryPart qp, T entity) { + FieldData fieldData = getFieldData(entity, qp); + if (qp.operator().equals(QueryParser.NOT_EXISTS_PREFIX)) { + return !fieldData.exists(); + } + if (!fieldData.exists()) { + return false; + } + return matches(qp, fieldData); + } - return evaluateResult(results); + private boolean resolveUpdateMatch(QueryPart qp, T entity, T oldState) { + FieldData updatedFieldData = getFieldData(entity, qp); + if (qp.operator().equals(QueryParser.NOT_EXISTS_PREFIX)) { + // Evaluated as a pure state predicate against the new state only - a field's + // existence is not expected to flip off in practice, so no old/new transition + // tracking is needed here, unlike the value-comparison branch below. + return !updatedFieldData.exists(); + } + FieldData oldFieldData = getFieldData(oldState, qp); + if (updatedFieldData.fieldValue != null && !updatedFieldData.fieldValue.equals(oldFieldData.fieldValue) || + oldFieldData.fieldValue != null && !oldFieldData.fieldValue.equals(updatedFieldData.fieldValue)) { + return matches(qp, updatedFieldData); + } else { + return false; + } } - private boolean evaluateResult(Stream results) { - return switch (logicalOperator) { - case AND -> results.allMatch(r -> r); - case OR -> results.anyMatch(r -> r); - }; + /** + * Folds the per-token results left-to-right respecting AND-before-OR precedence: accumulate + * an AND-run, then OR the accumulated run-results together. Degenerates to today's allMatch + * for a pure-AND query and anyMatch for a pure-OR query. + */ + private boolean evaluateResult(List tokens, List results) { + boolean orAccumulator = false; + boolean andAccumulator = results.get(0); + for (int i = 1; i < tokens.size(); i++) { + if (tokens.get(i).connectorToPrevious() == LogicalOperator.AND) { + andAccumulator = andAccumulator && results.get(i); + } else { + orAccumulator = orAccumulator || andAccumulator; + andAccumulator = results.get(i); + } + } + return orAccumulator || andAccumulator; } private FieldData getFieldData(T entity, QueryPart qp) { diff --git a/common/src/main/java/org/fiware/tmforum/common/repository/TmForumRepository.java b/common/src/main/java/org/fiware/tmforum/common/repository/TmForumRepository.java index 7bc29a81..620c062e 100644 --- a/common/src/main/java/org/fiware/tmforum/common/repository/TmForumRepository.java +++ b/common/src/main/java/org/fiware/tmforum/common/repository/TmForumRepository.java @@ -104,9 +104,9 @@ public Mono> findEntities(Integer offset, Integer limit, Stri * {@code typeToClass} instead of a single fixed class. */ public Mono> findEntitiesPolymorphic(Integer offset, Integer limit, String types, - String query, String orderBy, Function> typeToClass) { + String query, String ids, String orderBy, Function> typeToClass) { return entitiesApi.queryEntities(generalProperties.getTenant(), - null, + ids, null, types, null, diff --git a/common/src/main/java/org/fiware/tmforum/common/rest/AbstractApiController.java b/common/src/main/java/org/fiware/tmforum/common/rest/AbstractApiController.java index 9497c5a9..853320ee 100644 --- a/common/src/main/java/org/fiware/tmforum/common/rest/AbstractApiController.java +++ b/common/src/main/java/org/fiware/tmforum/common/rest/AbstractApiController.java @@ -182,6 +182,7 @@ protected Mono> listPolymorphic(Integer offset, Integer limit, Str .findEntitiesPolymorphic(offset, limit, Optional.ofNullable(queryParams).map(QueryParams::type).orElse(types), Optional.ofNullable(queryParams).map(QueryParams::query).orElse(null), + Optional.ofNullable(queryParams).map(QueryParams::id).orElse(null), orderBy, typeToClass) .doOnNext(pagedResult -> optionalHttpRequest.ifPresent(theRequest -> { diff --git a/common/src/test/java/org/fiware/tmforum/common/querying/MyPojo.java b/common/src/test/java/org/fiware/tmforum/common/querying/MyPojo.java index 4f1d8757..20a0f820 100644 --- a/common/src/test/java/org/fiware/tmforum/common/querying/MyPojo.java +++ b/common/src/test/java/org/fiware/tmforum/common/querying/MyPojo.java @@ -126,6 +126,11 @@ public MyPojo color(String color) { return this; } + public MyPojo status(String status) { + this.status = status; + return this; + } + public MyPojo temperature(Integer temperature) { this.temperature = temperature; return this; diff --git a/common/src/test/java/org/fiware/tmforum/common/querying/QueryParserTest.java b/common/src/test/java/org/fiware/tmforum/common/querying/QueryParserTest.java index 5d57c434..8fcb043a 100644 --- a/common/src/test/java/org/fiware/tmforum/common/querying/QueryParserTest.java +++ b/common/src/test/java/org/fiware/tmforum/common/querying/QueryParserTest.java @@ -137,6 +137,74 @@ private static Stream queriesAttributesWithDotPath() { ); } + /** + * Mixed AND/OR is safe without any grouping/parenthesization logic on our side: NGSI-LD's q= + * already defines AND-before-OR precedence for an un-parenthesized term chain (ETSI GS CIM + * 009 §4.9), confirmed against a real broker's SQL translation (see conversation notes). + * {@code !attribute} mirrors NGSI-LD's own not-exists syntax directly (no TMForum-side + * translation layer). + */ + @ParameterizedTest + @MethodSource("mixedAndOrAndNotExistsQueries") + public void testMixedAndOrAndNotExists(String tmForumQuery, QueryParams ngsiLdQuery, Class targetClass) { + GeneralProperties properties = new GeneralProperties(); + properties.setEncloseQuery(true); + properties.setNgsildOrQueryKey("|"); + properties.setNgsildOrQueryValue("|"); + properties.setIncludeAttributeInList(true); + properties.setUseDotSeperator(false); + + QueryParser qp = new QueryParser(properties); + assertEquals(ngsiLdQuery, qp.toNgsiLdQuery(targetClass, tmForumQuery), + "Mixed AND/OR and !attribute queries should have been properly translated."); + } + + private static Stream mixedAndOrAndNotExistsQueries() { + return Stream.of( + // Mixed AND+OR: AND between "status" and the "color" OR-run - no guard, no parens needed. + Arguments.of("status=Active&color=Red;color=Blue", + new QueryParams(null, null, "status==\"Active\";(color==\"Red\"|color==\"Blue\")"), MyPojo.class), + // Shaped like the motivating relatedParty example: an exact-datasetId-match OR'd + // against an AND-chained fallback (role match + not-exists on datasetId). + Arguments.of("relatedParty.datasetId=X;relatedParty.role=Owner&!relatedParty.datasetId", + new QueryParams(null, null, "relatedParty[role]==\"Owner\"|relatedParty[datasetId]==\"X\";!relatedParty[datasetId]"), MyPojo.class), + // !attribute alone. + Arguments.of("!status", new QueryParams(null, null, "!status"), MyPojo.class), + // !attribute combined with AND. + Arguments.of("!status&color=Red", new QueryParams(null, null, "!status;color==\"Red\""), MyPojo.class), + // !attribute combined with OR - must not be merged into color's value list by combineOrRun. + Arguments.of("!status;color=Red", new QueryParams(null, null, "color==\"Red\"|!status"), MyPojo.class), + // !attribute on a mapped relationship (rel is @AttributeGetter(RELATIONSHIP)) - isRelationship/getQueryPart unaffected. + Arguments.of("!rel.name", new QueryParams(null, null, "!rel.name"), MyPojo.class), + // !attribute on an unmapped ("additional attributes" fallback) path whose last segment is + // a reserved word - ReservedWordHandler escaping still applies with the ! prefix stripped. + Arguments.of("!relatedParty.id", new QueryParams(null, null, "!relatedParty[tmfEscaped-id]"), MyPojo.class) + ); + } + + /** + * {@code !type} is rejected by the exact same check as {@code !id} in + * {@code translateQueryPart} - not covered by its own case here because none of this test + * class's fixtures resolve a bare {@code type} path to NGSI-LD's native type shortcut (the + * existing "type=" TMForum shortcut is not exercised by any fixture in this file even before + * this change; only the {@code @type} → {@code atType} JSON-LD-reserved-token route is). + */ + @Test + public void testNotExistsOnIdIsRejected() { + GeneralProperties properties = new GeneralProperties(); + properties.setEncloseQuery(true); + properties.setNgsildOrQueryKey("|"); + properties.setNgsildOrQueryValue("|"); + properties.setIncludeAttributeInList(true); + properties.setUseDotSeperator(false); + + QueryParser qp = new QueryParser(properties); + org.junit.jupiter.api.Assertions.assertThrows( + org.fiware.tmforum.common.exception.QueryException.class, + () -> qp.toNgsiLdQuery(MyPojo.class, "!id"), + "!id must be rejected, id always exists."); + } + @ParameterizedTest @MethodSource("scorpioQueries") public void testScorpioQueryParsing(String tmForumQuery, QueryParams ngsiLdQuery, Class targetClass) { diff --git a/common/src/test/java/org/fiware/tmforum/common/querying/SubscriptionQueryParserTest.java b/common/src/test/java/org/fiware/tmforum/common/querying/SubscriptionQueryParserTest.java index f8a5dd84..64211bd3 100644 --- a/common/src/test/java/org/fiware/tmforum/common/querying/SubscriptionQueryParserTest.java +++ b/common/src/test/java/org/fiware/tmforum/common/querying/SubscriptionQueryParserTest.java @@ -1,5 +1,6 @@ package org.fiware.tmforum.common.querying; +import org.fiware.tmforum.common.exception.QueryException; import org.fiware.tmforum.common.notification.EventConstants; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -52,6 +53,63 @@ private static Stream queries() { ); } + /** + * Mixed AND/OR no longer throws, and !attribute passes through the event-prefix stripping + * correctly (a leading "!" used to stop "event." from being recognised - see + * removeEventPrefixFromAttributePath). + */ + @ParameterizedTest + @MethodSource("mixedAndOrAndNotExistsQueries") + public void testMixedAndOrAndNotExists(String queryString, List defaultEventGroups, + SubscriptionQuery expectedSubscriptionQuery) { + assertEquals(expectedSubscriptionQuery, SubscriptionQueryParser.parse(queryString, defaultEventGroups), + "The subscription query should have been properly parsed."); + } + + private static Stream mixedAndOrAndNotExistsQueries() { + return Stream.of( + // AND between eventType and the query, OR within the query - narrowed + // checkLogicalOperator only forbids OR directly between eventType and query. + Arguments.of("eventType=ProductCreateEvent&event.product.name=Some;event.product.color=Red", List.of(), + SubscriptionQueryBuilder.build() + .eventTypes(List.of("ProductCreateEvent")).query("product.name=Some;product.color=Red") + .eventGroups(Set.of("Product"))), + // !attribute survives the "event." prefix stripping. + Arguments.of("eventType=ProductCreateEvent&!event.product.name", List.of(), + SubscriptionQueryBuilder.build() + .eventTypes(List.of("ProductCreateEvent")).query("!product.name") + .eventGroups(Set.of("Product"))), + // OR + fields no longer throws - that rule had no standards basis or functional + // coupling (fields only affects payload projection, orthogonal to match logic). + Arguments.of("event.product.name=Some;fields=event.product.id", List.of(), + SubscriptionQueryBuilder.build() + .query("product.name=Some").fields(List.of("product.id"))) + ); + } + + /** + * Both remaining checkLogicalOperator rules still fire, but only when the conflicting tokens + * are directly connected to each other - not merely "this operator appears somewhere". + */ + @ParameterizedTest + @MethodSource("stillRejectedQueries") + public void testNarrowedRulesStillReject(String queryString, String expectedMessage) { + QueryException exception = org.junit.jupiter.api.Assertions.assertThrows(QueryException.class, + () -> SubscriptionQueryParser.parse(queryString, List.of())); + assertEquals(expectedMessage, exception.getMessage()); + } + + private static Stream stillRejectedQueries() { + return Stream.of( + // eventType directly OR-connected to a query token. + Arguments.of("eventType=ProductCreateEvent;event.product.name=Some", + "Logical operator OR(;) cannot be used when both 'eventType' and 'query' are defined"), + // two eventTypes directly AND-connected to each other. + Arguments.of("eventType=ProductCreateEvent&eventType=ProductDeleteEvent", + "Logical operator AND(&) cannot be used when several 'eventType' are defined") + ); + } + private static class SubscriptionQueryBuilder { public static SubscriptionQuery build() { return new SubscriptionQuery(); diff --git a/common/src/test/java/org/fiware/tmforum/common/querying/SubscriptionQueryResolverTest.java b/common/src/test/java/org/fiware/tmforum/common/querying/SubscriptionQueryResolverTest.java index 0e99e5cd..7267ab19 100644 --- a/common/src/test/java/org/fiware/tmforum/common/querying/SubscriptionQueryResolverTest.java +++ b/common/src/test/java/org/fiware/tmforum/common/querying/SubscriptionQueryResolverTest.java @@ -57,6 +57,63 @@ private static Stream queriesForUpdateEvent() { ); } + /** + * (color=Red AND temperature>10) OR status=Active - confirms the left-to-right AND-before-OR + * fold in evaluateResult, not a plain allMatch/anyMatch over the whole query. + */ + @ParameterizedTest + @MethodSource("precedenceQueries") + public void testAndBeforeOrPrecedence(MyPojo myPojo, boolean expectedResult) { + String query = "myPojo.color=Red&myPojo.temperature>10;myPojo.status=Active"; + assertEquals(expectedResult, subscriptionQueryResolver.doesQueryMatchCreateEvent(query, myPojo, "myPojo"), + "AND must bind tighter than OR when evaluating a mixed query."); + } + + private static Stream precedenceQueries() { + return Stream.of( + // AND-branch satisfied (color=Red, temperature>10) -> true regardless of OR-branch. + Arguments.of(MyPojoBuilder.build().color("Red").temperature(15).status("Inactive"), true), + // AND-branch fails (temperature not >10) but OR-branch (status=Active) is satisfied. + Arguments.of(MyPojoBuilder.build().color("Red").temperature(5).status("Active"), true), + // Neither branch satisfied. + Arguments.of(MyPojoBuilder.build().color("Blue").temperature(5).status("Inactive"), false) + ); + } + + @ParameterizedTest + @MethodSource("notExistsCreateQueries") + public void testNotExistsOnCreateEvent(MyPojo myPojo, boolean expectedResult) { + assertEquals(expectedResult, subscriptionQueryResolver.doesQueryMatchCreateEvent("!myPojo.color", myPojo, "myPojo"), + "!attribute should match iff the field is absent."); + } + + private static Stream notExistsCreateQueries() { + return Stream.of( + Arguments.of(MyPojoBuilder.build(), true), + Arguments.of(MyPojoBuilder.build().color("Red"), false) + ); + } + + /** + * !attribute in an update event is evaluated as a pure state predicate against the new state + * only - unlike a normal value-comparing QueryPart, it does NOT require the field to have + * actually changed (see the old=null/new=null "no change" case below). + */ + @ParameterizedTest + @MethodSource("notExistsUpdateQueries") + public void testNotExistsOnUpdateEvent(MyPojo oldState, MyPojo newState, boolean expectedResult) { + assertEquals(expectedResult, subscriptionQueryResolver.doesQueryMatchUpdateEvent("!myPojo.color", newState, oldState, "myPojo"), + "!attribute on update should match iff the field is absent in the new state."); + } + + private static Stream notExistsUpdateQueries() { + return Stream.of( + Arguments.of(MyPojoBuilder.build().color("Red"), MyPojoBuilder.build(), true), + Arguments.of(MyPojoBuilder.build(), MyPojoBuilder.build(), true), + Arguments.of(MyPojoBuilder.build(), MyPojoBuilder.build().color("Red"), false) + ); + } + private static class MyPojoBuilder { public static MyPojo build() { return new MyPojo("id"); diff --git a/common/src/test/java/org/fiware/tmforum/common/repository/TmForumRepositoryTest.java b/common/src/test/java/org/fiware/tmforum/common/repository/TmForumRepositoryTest.java index 69042c42..07a22431 100644 --- a/common/src/test/java/org/fiware/tmforum/common/repository/TmForumRepositoryTest.java +++ b/common/src/test/java/org/fiware/tmforum/common/repository/TmForumRepositoryTest.java @@ -1,12 +1,20 @@ package org.fiware.tmforum.common.repository; import io.micronaut.http.HttpResponse; +import org.fiware.ngsi.api.EntitiesApiClient; +import org.fiware.ngsi.model.EntityListVO; import org.fiware.tmforum.common.configuration.GeneralProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class TmForumRepositoryTest { @@ -19,6 +27,28 @@ public void setUp() { repository = new TmForumRepository(properties, null, null, null, null, null); } + @Test + public void findEntitiesPolymorphicForwardsTheIdFilterToTheBroker() { + // Regression test: findEntitiesPolymorphic used to hard-code the broker's native + // "id" query param to null, silently dropping any ?id= filter on polymorphic list + // endpoints (e.g. resourceSpecification, which spans several NGSI-LD types). + EntitiesApiClient entitiesApi = mock(EntitiesApiClient.class); + TmForumRepository repositoryWithClient = new TmForumRepository(properties, entitiesApi, null, null, null, null); + String requestedId = "urn:ngsi-ld:software-specification:0e2d5c4a-cf51-43cf-a510-dff06f62f4a3"; + + when(entitiesApi.queryEntities(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any())) + .thenReturn(Mono.just(HttpResponse.ok(new EntityListVO()))); + + repositoryWithClient + .findEntitiesPolymorphic(0, 10, "software-specification,resource-specification", null, requestedId, + null, type -> Object.class) + .block(); + + verify(entitiesApi).queryEntities(eq(properties.getTenant()), eq(requestedId), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + @Test public void extractsTheConfiguredHeader() { properties.setCountHeader("NGSILD-Results-Count"); diff --git a/common/src/test/java/org/fiware/tmforum/common/rest/AbstractApiControllerTest.java b/common/src/test/java/org/fiware/tmforum/common/rest/AbstractApiControllerTest.java new file mode 100644 index 00000000..94c21a4a --- /dev/null +++ b/common/src/test/java/org/fiware/tmforum/common/rest/AbstractApiControllerTest.java @@ -0,0 +1,58 @@ +package org.fiware.tmforum.common.rest; + +import io.micronaut.http.HttpRequest; +import io.micronaut.http.context.ServerRequestContext; +import org.fiware.tmforum.common.configuration.GeneralProperties; +import org.fiware.tmforum.common.querying.MyPojo; +import org.fiware.tmforum.common.querying.QueryParser; +import org.fiware.tmforum.common.repository.PagedResult; +import org.fiware.tmforum.common.repository.TmForumRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AbstractApiControllerTest { + + private static class TestController extends AbstractApiController { + TestController(QueryParser queryParser, TmForumRepository repository) { + super(queryParser, null, repository, null); + } + } + + @AfterEach + public void clearRequestContext() { + ServerRequestContext.set(null); + } + + @Test + public void listPolymorphicForwardsTheIdFilterToTheRepository() { + // Regression test: listPolymorphic used to only forward "type" and "query" from the + // parsed request to the repository, silently dropping any ?id= filter - even though it + // was correctly parsed into QueryParams.id(). The non-polymorphic list() already forwarded + // it; listPolymorphic must do the same. + GeneralProperties properties = new GeneralProperties(); + QueryParser queryParser = new QueryParser(properties); + TmForumRepository repository = mock(TmForumRepository.class); + TestController controller = new TestController(queryParser, repository); + + String requestedId = "urn:ngsi-ld:software-specification:0e2d5c4a-cf51-43cf-a510-dff06f62f4a3"; + ServerRequestContext.set( + HttpRequest.GET("/resourceSpecification?id=" + requestedId + "&fields=lifecycleStatus")); + + when(repository.findEntitiesPolymorphic(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(Mono.just(new PagedResult<>(List.of(), 0, 10, null))); + + controller.listPolymorphic(0, 10, "software-specification,resource-specification", MyPojo.class, + type -> MyPojo.class).block(); + + verify(repository).findEntitiesPolymorphic(any(), any(), any(), any(), eq(requestedId), any(), any()); + } +} diff --git a/resource-inventory/src/test/java/org/fiware/tmforum/resourceinventory/EventSubscriptionApiIT.java b/resource-inventory/src/test/java/org/fiware/tmforum/resourceinventory/EventSubscriptionApiIT.java index 1d8493b6..0b4fc4a0 100644 --- a/resource-inventory/src/test/java/org/fiware/tmforum/resourceinventory/EventSubscriptionApiIT.java +++ b/resource-inventory/src/test/java/org/fiware/tmforum/resourceinventory/EventSubscriptionApiIT.java @@ -273,6 +273,30 @@ private static Stream provideValidEventSubscriptionInputs() { .callback(ANY_CALLBACK) ) ); + // AND/OR mixing is now supported: eventType is OR-combined with itself (still allowed, + // unrelated to the eventType/query rule) and AND-linked to a content query - only OR + // directly between eventType and query is still rejected (see provideInvalidEventSubscriptionInputs). + testEntries.add( + Arguments.of("A listener with a mixed AND/OR query (ORed event types, AND-linked to a content query) should have been created.", + EventSubscriptionInputVOTestExample.build() + .query("eventType=ResourceCreateEvent;eventType=ResourceDeleteEvent&event.Resource.name=Some") + .callback(ANY_CALLBACK), + EventSubscriptionVOTestExample.build() + .query("eventType=ResourceCreateEvent;eventType=ResourceDeleteEvent&event.Resource.name=Some") + .callback(ANY_CALLBACK) + ) + ); + // !attribute (not-exists) mirrors NGSI-LD's own syntax directly. + testEntries.add( + Arguments.of("A listener with a !attribute (not-exists) query should have been created.", + EventSubscriptionInputVOTestExample.build() + .query("eventType=ResourceCreateEvent&!event.Resource.category") + .callback(ANY_CALLBACK), + EventSubscriptionVOTestExample.build() + .query("eventType=ResourceCreateEvent&!event.Resource.category") + .callback(ANY_CALLBACK) + ) + ); return testEntries.stream(); } @@ -294,13 +318,6 @@ private static Stream provideInvalidEventSubscriptionInputs() { .callback(ANY_CALLBACK) ) ); - testEntries.add( - Arguments.of("A query with both logical operators AND and OR should not be created.", - EventSubscriptionInputVOTestExample.build() - .query("eventType=ResourceCreateEvent;eventType=ResourceDeleteEvent&event.Resource.name=Some") - .callback(ANY_CALLBACK) - ) - ); return testEntries.stream(); } diff --git a/resource-inventory/src/test/java/org/fiware/tmforum/resourceinventory/ResourceApiIT.java b/resource-inventory/src/test/java/org/fiware/tmforum/resourceinventory/ResourceApiIT.java index d00985b9..85e39b9b 100644 --- a/resource-inventory/src/test/java/org/fiware/tmforum/resourceinventory/ResourceApiIT.java +++ b/resource-inventory/src/test/java/org/fiware/tmforum/resourceinventory/ResourceApiIT.java @@ -1,8 +1,12 @@ package org.fiware.tmforum.resourceinventory; import com.fasterxml.jackson.databind.ObjectMapper; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; import io.micronaut.test.annotation.MockBean; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import org.fiware.ngsi.api.EntitiesApiClient; @@ -42,6 +46,10 @@ public class ResourceApiIT extends AbstractApiIT implements ResourceApiTestSpec { public final ResourceApiTestClient resourceApiTestClient; + // resourceApiTestClient's generated listResource(fields, offset, limit) has no slot for an + // arbitrary filter query string, so a raw client is used to exercise mixed AND/OR and + // !attribute filters end-to-end against a real broker. + private final HttpClient rawHttpClient; private String message; private String fieldsParameter; @@ -50,9 +58,11 @@ public class ResourceApiIT extends AbstractApiIT implements ResourceApiTestSpec private ResourceVO expectedResource; public ResourceApiIT(ResourceApiTestClient resourceApiTestClient, EntitiesApiClient entitiesApiClient, - ObjectMapper objectMapper, GeneralProperties generalProperties) { + ObjectMapper objectMapper, GeneralProperties generalProperties, + @Client("/") HttpClient rawHttpClient) { super(entitiesApiClient, objectMapper, generalProperties); this.resourceApiTestClient = resourceApiTestClient; + this.rawHttpClient = rawHttpClient; } @MockBean(TMForumEventHandler.class) @@ -452,6 +462,75 @@ public void listResource200() throws Exception { "The correct resources should be retrieved.")); } + /** + * Intended as the end-to-end closure of this session's motivating case: mixing AND(&) and + * OR(;) in a single TMForum filter query. Disabled for now - while diagnosing this test's + * failure, an unrelated pre-existing bug was found in this module's + * {@code application-orion-ld.yaml}: {@code ngsildOrQueryKey} is set to {@code ";"}, the same + * character used for AND, so ANY top-level OR of distinct attributes (with or without AND + * mixed in, and predating this change - verified by reproducing it with a plain + * "name=Alpha;category=Network" query, no AND involved at all) is mistranslated and silently + * interpreted as AND by the real Orion-LD broker. A same-attribute OR + * ("category=Network;category=Compute", the encloseQuery=true value-list form) does not fare + * better - it errors out against this broker version entirely. Neither is caused by the + * AND/OR-mixing or !attribute work in this change; both reproduce identically against the + * pre-existing single-operator code path. Re-enable once ngsildOrQueryKey is fixed for the + * orion-ld profile (should very likely be "|", not ";", to actually differ from AND) and the + * value-list broker compatibility is confirmed against the deployed Orion-LD version. + */ + @Disabled("Pre-existing bug: application-orion-ld.yaml's ngsildOrQueryKey (\";\") collides with " + + "the AND separator, so any top-level OR of distinct attributes is silently treated as " + + "AND by the real broker - unrelated to the AND/OR-mixing feature under test.") + @Test + public void listResourceWithMixedAndOrFilter() throws Exception { + String alphaNetworkId = resourceApiTestClient.createResource(null, + ResourceCreateVOTestExample.build().atSchemaLocation(null).place(null).resourceSpecification(null) + .relatedParty(null).name("Alpha").category("Network")) + .body().getId(); + String betaNetworkId = resourceApiTestClient.createResource(null, + ResourceCreateVOTestExample.build().atSchemaLocation(null).place(null).resourceSpecification(null) + .relatedParty(null).name("Beta").category("Network")) + .body().getId(); + String betaComputeId = resourceApiTestClient.createResource(null, + ResourceCreateVOTestExample.build().atSchemaLocation(null).place(null).resourceSpecification(null) + .relatedParty(null).name("Beta").category("Compute")) + .body().getId(); + + // (name==Alpha OR category==Network) AND name==Beta -> only "Beta"/"Network" matches. + String query = "name=Alpha;category=Network&name=Beta"; + HttpResponse> response = rawHttpClient.toBlocking() + .exchange(HttpRequest.GET("/resource?" + query), Argument.listOf(ResourceVO.class)); + + assertEquals(HttpStatus.OK, response.getStatus(), "The mixed AND/OR query should be accepted."); + List retrievedIds = response.body().stream().map(ResourceVO::getId).toList(); + assertEquals(List.of(betaNetworkId), retrievedIds, + "Only the resource matching (name==Alpha OR category==Network) AND name==Beta should be returned."); + } + + /** + * End-to-end closure for !attribute (not-exists), NGSI-LD's own syntax mirrored directly by + * this connector with no TMForum-side translation layer. + */ + @Test + public void listResourceWithNotExistsFilter() throws Exception { + String withCategoryId = resourceApiTestClient.createResource(null, + ResourceCreateVOTestExample.build().atSchemaLocation(null).place(null).resourceSpecification(null) + .relatedParty(null).category("Network")) + .body().getId(); + String withoutCategoryId = resourceApiTestClient.createResource(null, + ResourceCreateVOTestExample.build().atSchemaLocation(null).place(null).resourceSpecification(null) + .relatedParty(null).category(null)) + .body().getId(); + + HttpResponse> response = rawHttpClient.toBlocking() + .exchange(HttpRequest.GET("/resource?!category"), Argument.listOf(ResourceVO.class)); + + assertEquals(HttpStatus.OK, response.getStatus(), "The !attribute query should be accepted."); + List retrievedIds = response.body().stream().map(ResourceVO::getId).toList(); + assertEquals(List.of(withoutCategoryId), retrievedIds, + "Only the resource without a category should match !category."); + } + @Test @Override public void listResource400() throws Exception {