This is an automated email from the ASF dual-hosted git repository.
martinweiler pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-kie-kogito-apps.git
The following commit(s) were added to refs/heads/main by this push:
new 9e1900f8d [Incubator-kie-issues#2323] Allow filtering schema on nested
arrays (#2328)
9e1900f8d is described below
commit 9e1900f8d8e3f0960a5679ad486b2dbec7db36ff
Author: GopikaReghunath <[email protected]>
AuthorDate: Thu Jul 2 19:14:26 2026 +0530
[Incubator-kie-issues#2323] Allow filtering schema on nested arrays (#2328)
* Allow filtering schema on nested arrays
* Allow filtering schema on nested arrays
* Allow filtering schema on nested arrays
* Allow filtering schema on nested arrays
---------
Co-authored-by: Gopikar <[email protected]>
---
.../query/GraphQLInputObjectTypeMapper.java | 18 +
.../index/graphql/query/GraphQLQueryMapper.java | 194 ++++++++++-
.../main/resources/graphql/basic.schema.graphqls | 80 ++++-
.../graphql/query/GraphQLQueryMapperTest.java | 284 ++++++++++++++++
.../java/org/kie/kogito/index/test/TestUtils.java | 28 ++
.../org/kie/kogito/index/jpa/storage/JPAQuery.java | 247 +++++++++++++-
.../AbstractProcessInstanceEntityQueryIT.java | 366 +++++++++++++++++++++
.../persistence/api/query/FilterCondition.java | 12 +
8 files changed, 1203 insertions(+), 26 deletions(-)
diff --git
a/data-index/data-index-graphql/src/main/java/org/kie/kogito/index/graphql/query/GraphQLInputObjectTypeMapper.java
b/data-index/data-index-graphql/src/main/java/org/kie/kogito/index/graphql/query/GraphQLInputObjectTypeMapper.java
index 92d08f4db..0c839452c 100644
---
a/data-index/data-index-graphql/src/main/java/org/kie/kogito/index/graphql/query/GraphQLInputObjectTypeMapper.java
+++
b/data-index/data-index-graphql/src/main/java/org/kie/kogito/index/graphql/query/GraphQLInputObjectTypeMapper.java
@@ -45,6 +45,7 @@ public class GraphQLInputObjectTypeMapper extends
AbstractInputObjectTypeMapper
private static final Logger LOGGER =
LoggerFactory.getLogger(GraphQLInputObjectTypeMapper.class);
private static final String ARGUMENT = "Argument";
+ public static final String ARRAY_ARGUMENT = "ArrayArgument";
private boolean mapOperators;
@@ -93,6 +94,10 @@ public class GraphQLInputObjectTypeMapper extends
AbstractInputObjectTypeMapper
private GraphQLInputType getInputTypeByField(GraphQLFieldDefinition field)
{
String name = resolveBaseTypeName(field.getType());
+
+ // Check if this is an array field
+ boolean isArray = field.getType() instanceof GraphQLList;
+
switch (name) {
case "Int":
return getInputObjectType("NumericArgument");
@@ -108,7 +113,20 @@ public class GraphQLInputObjectTypeMapper extends
AbstractInputObjectTypeMapper
return getInputObjectType("BooleanArgument");
case "DateTime":
return getInputObjectType("DateArgument");
+ case "StringArray":
+ return getInputObjectType("StringArrayArgument");
default:
+ // For array fields, try to use ArrayArgument type first
+ if (isArray) {
+ String arrayTypeName = name + ARRAY_ARGUMENT;
+ GraphQLType arraySchemaType =
getExistingType(arrayTypeName);
+ if (arraySchemaType != null) {
+ LOGGER.debug("Using array argument type: {} for field:
{}", arrayTypeName, field.getName());
+ return (GraphQLInputType) arraySchemaType;
+ }
+ }
+
+ // Fall back to regular Argument type
String typeName = name + ARGUMENT;
GraphQLType schemaType = getExistingType(typeName);
if (schemaType == null) {
diff --git
a/data-index/data-index-graphql/src/main/java/org/kie/kogito/index/graphql/query/GraphQLQueryMapper.java
b/data-index/data-index-graphql/src/main/java/org/kie/kogito/index/graphql/query/GraphQLQueryMapper.java
index 1fb357ffa..5b05a8d0d 100644
---
a/data-index/data-index-graphql/src/main/java/org/kie/kogito/index/graphql/query/GraphQLQueryMapper.java
+++
b/data-index/data-index-graphql/src/main/java/org/kie/kogito/index/graphql/query/GraphQLQueryMapper.java
@@ -34,12 +34,14 @@ import graphql.schema.GraphQLInputObjectType;
import graphql.schema.GraphQLInputType;
import graphql.schema.GraphQLList;
import graphql.schema.GraphQLNamedType;
+import graphql.schema.GraphQLType;
import static graphql.schema.GraphQLTypeUtil.isList;
import static graphql.schema.GraphQLTypeUtil.simplePrint;
import static graphql.schema.GraphQLTypeUtil.unwrapNonNull;
import static graphql.schema.GraphQLTypeUtil.unwrapOne;
import static java.util.stream.Collectors.toList;
+import static
org.kie.kogito.index.graphql.query.GraphQLInputObjectTypeMapper.ARRAY_ARGUMENT;
import static org.kie.kogito.index.json.JsonUtils.jsonFilter;
import static org.kie.kogito.persistence.api.query.FilterCondition.NOT;
import static org.kie.kogito.persistence.api.query.QueryFilterFactory.and;
@@ -82,10 +84,10 @@ public class GraphQLQueryMapper implements
Function<GraphQLInputObjectType, Grap
parser.mapAttribute(field.getName(),
mapEnumArgument(field.getName()));
} else if (isListOfType(field.getType(), type.getName())) {
parser.mapAttribute(field.getName(),
mapRecursiveListArgument(field.getName(), parser));
- } else if (((GraphQLNamedType)
field.getType()).getName().equals(type.getName())) {
+ } else if (field.getType() instanceof GraphQLNamedType
namedType && namedType.getName().equals(type.getName())) {
parser.mapAttribute(field.getName(),
mapRecursiveArgument(field.getName(), parser));
- } else {
- String name = ((GraphQLNamedType)
field.getType()).getName();
+ } else if (field.getType() instanceof GraphQLNamedType
namedType) {
+ String name = namedType.getName();
switch (name) {
case "IdArgument":
parser.mapAttribute(field.getName(),
mapIdArgument(field.getName()));
@@ -115,8 +117,13 @@ public class GraphQLQueryMapper implements
Function<GraphQLInputObjectType, Grap
parser.mapAttribute(field.getName(),
mapJsonArgument(field.getName()));
break;
default:
- if (field.getType() instanceof
GraphQLInputObjectType) {
- parser.mapAttribute(field.getName(),
mapSubEntityArgument(field.getName(), new
GraphQLQueryMapper().apply((GraphQLInputObjectType) field.getType())));
+ if (field.getType() instanceof
GraphQLInputObjectType inputType) {
+ // Check if this is an array argument type
(ends with "ArrayArgument")
+ if
(inputType.getName().endsWith(ARRAY_ARGUMENT)) {
+ parser.mapAttribute(field.getName(),
mapArrayArgument(field.getName(), inputType));
+ } else {
+ parser.mapAttribute(field.getName(),
mapSubEntityArgument(field.getName(), new
GraphQLQueryMapper().apply(inputType)));
+ }
}
}
}
@@ -229,6 +236,183 @@ public class GraphQLQueryMapper implements
Function<GraphQLInputObjectType, Grap
});
}
+ private Function<Object, Stream<AttributeFilter<?>>>
mapArrayArgument(String attribute, GraphQLInputObjectType arrayArgType) {
+ return argument -> {
+ Map<String, Object> argMap = (Map<String, Object>) argument;
+
+ // Separate array operations from backward-compatible fields
+ Map<String, Object> backwardCompatFields = new
java.util.HashMap<>();
+ List<Stream<AttributeFilter<?>>> arrayOpStreams = new
java.util.ArrayList<>();
+
+ // Lazy initialization of element parser (only when needed)
+ GraphQLQueryParser elementParser = null;
+
+ // Helper to get element parser (lazy initialization)
+ final GraphQLQueryParser[] parserHolder = new
GraphQLQueryParser[1];
+ java.util.function.Supplier<GraphQLQueryParser> getElementParser =
() -> {
+ if (parserHolder[0] == null) {
+ GraphQLInputType containsFieldType =
arrayArgType.getField("contains") != null ?
arrayArgType.getField("contains").getType() : null;
+ if (containsFieldType == null) {
+ LOGGER.warn("Array argument type {} does not have a
'contains' field required for element operations", arrayArgType.getName());
+ return null;
+ }
+ GraphQLType unwrappedType =
unwrapNonNull(containsFieldType);
+ if (!(unwrappedType instanceof GraphQLInputObjectType
elementType)) {
+ LOGGER.warn("Contains field type is not an input
object type: {}", simplePrint(unwrappedType));
+ return null;
+ }
+ parserHolder[0] = new
GraphQLQueryMapper().apply(elementType);
+ }
+ return parserHolder[0];
+ };
+
+ for (Map.Entry<String, Object> entry : argMap.entrySet()) {
+ FilterCondition condition =
FilterCondition.fromLabel(entry.getKey());
+ Object value = entry.getValue();
+
+ if (condition == null) {
+ // Backward compatibility: treat as element property (like
'contains')
+ backwardCompatFields.put(entry.getKey(), value);
+ continue;
+ }
+
+ switch (condition) {
+ case CONTAINS:
+ // Single element filter: nodes.name = 'X'
+ if (value instanceof Map) {
+ GraphQLQueryParser parser = getElementParser.get();
+ if (parser != null) {
+
arrayOpStreams.add(parser.apply(value).stream().map(f -> {
+ f.setAttribute(attribute + "." +
f.getAttribute());
+ return f;
+ }));
+ }
+ }
+ break;
+
+ case CONTAINS_ALL:
+ // All elements must match: AND(nodes.name = 'X',
nodes.name = 'Y')
+ if (value instanceof List) {
+ GraphQLQueryParser parser = getElementParser.get();
+ if (parser != null) {
+ List<AttributeFilter<?>> allFilters =
((List<?>) value).stream()
+ .flatMap(elem -> {
+ if (elem instanceof Map) {
+ return
parser.apply(elem).stream().map(f -> {
+ f.setAttribute(attribute +
"." + f.getAttribute());
+ return f;
+ });
+ }
+ return Stream.empty();
+ })
+ .collect(toList());
+ if (!allFilters.isEmpty()) {
+
arrayOpStreams.add(Stream.of(and(allFilters)));
+ }
+ }
+ }
+ break;
+
+ case CONTAINS_ANY:
+ // Any element matches: OR(nodes.name = 'X',
nodes.name = 'Y')
+ if (value instanceof List) {
+ GraphQLQueryParser parser = getElementParser.get();
+ if (parser != null) {
+ List<AttributeFilter<?>> anyFilters =
((List<?>) value).stream()
+ .flatMap(elem -> {
+ if (elem instanceof Map) {
+ return
parser.apply(elem).stream().map(f -> {
+ f.setAttribute(attribute +
"." + f.getAttribute());
+ return f;
+ });
+ }
+ return Stream.empty();
+ })
+ .collect(toList());
+ if (!anyFilters.isEmpty()) {
+
arrayOpStreams.add(Stream.of(or(anyFilters)));
+ }
+ }
+ }
+ break;
+
+ case IS_NULL:
+ // Check if array is null or empty
+ if (value instanceof Boolean) {
+
arrayOpStreams.add(Stream.of(Boolean.TRUE.equals(value) ? isNull(attribute) :
notNull(attribute)));
+ }
+ break;
+
+ case AND:
+ // Recursively process AND conditions
+ if (value instanceof List) {
+ List<AttributeFilter<?>> andFilters = ((List<?>)
value).stream()
+ .flatMap(elem -> {
+ if (elem instanceof Map) {
+ return mapArrayArgument(attribute,
arrayArgType).apply(elem);
+ }
+ return Stream.empty();
+ })
+ .collect(toList());
+ if (!andFilters.isEmpty()) {
+ arrayOpStreams.add(Stream.of(and(andFilters)));
+ }
+ }
+ break;
+
+ case OR:
+ // Recursively process OR conditions
+ if (value instanceof List) {
+ List<AttributeFilter<?>> orFilters = ((List<?>)
value).stream()
+ .flatMap(elem -> {
+ if (elem instanceof Map) {
+ return mapArrayArgument(attribute,
arrayArgType).apply(elem);
+ }
+ return Stream.empty();
+ })
+ .collect(toList());
+ if (!orFilters.isEmpty()) {
+ arrayOpStreams.add(Stream.of(or(orFilters)));
+ }
+ }
+ break;
+
+ case NOT:
+ // Recursively process NOT condition
+ if (value instanceof Map) {
+ List<AttributeFilter<?>> notFilters =
mapArrayArgument(attribute, arrayArgType).apply(value).collect(toList());
+ if (notFilters.size() == 1) {
+ // Single filter: apply NOT directly
+
arrayOpStreams.add(Stream.of(not(notFilters.get(0))));
+ } else if (notFilters.size() > 1) {
+ // Multiple filters: combine with AND, then
apply NOT
+
arrayOpStreams.add(Stream.of(not(and(notFilters))));
+ }
+ }
+ break;
+
+ default:
+ // Unknown condition - ignore
+ break;
+ }
+ }
+
+ // Process backward-compatible fields as 'contains'
+ if (!backwardCompatFields.isEmpty()) {
+ GraphQLQueryParser parser = getElementParser.get();
+ if (parser != null) {
+
arrayOpStreams.add(parser.apply(backwardCompatFields).stream().map(f -> {
+ f.setAttribute(attribute + "." + f.getAttribute());
+ return f;
+ }));
+ }
+ }
+
+ // Combine all streams
+ return arrayOpStreams.stream().flatMap(s -> s);
+ };
+ }
+
private Function<Object, Stream<AttributeFilter<?>>> mapIdArgument(String
attribute) {
return argument -> ((Map<String, Object>)
argument).entrySet().stream().map(entry -> {
FilterCondition condition =
FilterCondition.fromLabel(entry.getKey());
diff --git
a/data-index/data-index-graphql/src/main/resources/graphql/basic.schema.graphqls
b/data-index/data-index-graphql/src/main/resources/graphql/basic.schema.graphqls
index 32edd6e27..6d12f99e2 100644
---
a/data-index/data-index-graphql/src/main/resources/graphql/basic.schema.graphqls
+++
b/data-index/data-index-graphql/src/main/resources/graphql/basic.schema.graphqls
@@ -214,8 +214,8 @@ input ProcessInstanceArgument {
rootProcessId: StringArgument
state: ProcessInstanceStateArgument
error: ProcessInstanceErrorArgument
- nodes: NodeInstanceArgument
- milestones: MilestoneArgument
+ nodes: NodeInstanceArrayArgument
+ milestones: MilestoneArrayArgument
endpoint: StringArgument
roles: StringArrayArgument
start: DateArgument
@@ -252,6 +252,28 @@ input NodeInstanceArgument {
slaDueDate: DateArgument
}
+input NodeInstanceArrayArgument {
+ # Array-specific operations
+ contains: NodeInstanceArgument
+ containsAll: [NodeInstanceArgument!]
+ containsAny: [NodeInstanceArgument!]
+ isNull: Boolean
+ and: [NodeInstanceArrayArgument!]
+ or: [NodeInstanceArrayArgument!]
+ not: NodeInstanceArrayArgument
+ # Backward compatibility:
+ id: IdArgument
+ name: StringArgument
+ definitionId: StringArgument
+ nodeId: StringArgument
+ type: StringArgument
+ enter: DateArgument
+ exit: DateArgument
+ errorMessage: StringArgument
+ retrigger: BooleanArgument
+ slaDueDate: DateArgument
+}
+
input MilestoneStatusArgument {
equal: MilestoneStatus
in: [MilestoneStatus]
@@ -263,6 +285,20 @@ input MilestoneArgument {
status: MilestoneStatusArgument
}
+input MilestoneArrayArgument {
+ contains: MilestoneArgument
+ containsAll: [MilestoneArgument!]
+ containsAny: [MilestoneArgument!]
+ isNull: Boolean
+ and: [MilestoneArrayArgument!]
+ or: [MilestoneArrayArgument!]
+ not: MilestoneArrayArgument
+ # Backward compatibility fields
+ id: IdArgument
+ name: StringArgument
+ status: MilestoneStatusArgument
+}
+
input StringArrayArgument {
contains: String
containsAll: [String!]
@@ -437,8 +473,8 @@ input UserTaskInstanceArgument {
started: DateArgument
referenceName: StringArgument
lastUpdate: DateArgument
- comments: CommentArgument
- attachments: AttachmentArgument
+ comments: CommentArrayArgument
+ attachments: AttachmentArrayArgument
slaDueDate: DateArgument
rootProcessInstanceId: StringArgument
rootProcessId: StringArgument
@@ -448,7 +484,26 @@ input UserTaskInstanceArgument {
input CommentArgument {
id: IdArgument
- name: StringArgument
+ content: StringArgument
+ updatedBy: StringArgument
+ updatedAt: DateArgument
+}
+
+input CommentArrayArgument {
+ # Array-specific operations
+ contains: CommentArgument
+ containsAll: [CommentArgument!]
+ containsAny: [CommentArgument!]
+ isNull: Boolean
+ # Logical operators
+ and: [CommentArrayArgument!]
+ or: [CommentArrayArgument!]
+ not: CommentArrayArgument
+ # Backward compatibility: direct property access (treated as 'contains')
+ id: IdArgument
+ content: StringArgument
+ updatedBy: StringArgument
+ updatedAt: DateArgument
}
input AttachmentArgument {
@@ -456,6 +511,21 @@ input AttachmentArgument {
name: StringArgument
}
+input AttachmentArrayArgument {
+ # Array-specific operations
+ contains: AttachmentArgument
+ containsAll: [AttachmentArgument!]
+ containsAny: [AttachmentArgument!]
+ isNull: Boolean
+ # Logical operators
+ and: [AttachmentArrayArgument!]
+ or: [AttachmentArrayArgument!]
+ not: AttachmentArrayArgument
+ # Backward compatibility: direct property access (treated as 'contains')
+ id: IdArgument
+ name: StringArgument
+}
+
input UserTaskInstanceOrderBy {
state: OrderBy
actualOwner: OrderBy
diff --git
a/data-index/data-index-graphql/src/test/java/org/kie/kogito/index/graphql/query/GraphQLQueryMapperTest.java
b/data-index/data-index-graphql/src/test/java/org/kie/kogito/index/graphql/query/GraphQLQueryMapperTest.java
index 27f053f9a..9febcd77f 100644
---
a/data-index/data-index-graphql/src/test/java/org/kie/kogito/index/graphql/query/GraphQLQueryMapperTest.java
+++
b/data-index/data-index-graphql/src/test/java/org/kie/kogito/index/graphql/query/GraphQLQueryMapperTest.java
@@ -24,6 +24,13 @@ import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.kie.kogito.persistence.api.query.AttributeFilter;
+import org.kie.kogito.persistence.api.query.FilterCondition;
+
+import graphql.Scalars;
+import graphql.schema.GraphQLInputObjectField;
+import graphql.schema.GraphQLInputObjectType;
+import graphql.schema.GraphQLList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.kie.kogito.index.json.JsonUtils.jsonFilter;
@@ -115,4 +122,281 @@ public class GraphQLQueryMapperTest {
assertThat(mapper.mapJsonArgument("variables").apply(Map.of("workflowdata",
Map.of("number", Map.of("isNull", false))))).containsExactly(
jsonFilter(notNull("variables.workflowdata.number")));
}
+
+ // ========== Array Argument Tests ==========
+
+ @Test
+ void testArrayArgumentContains() {
+ // Create the input type and parser
+ GraphQLInputObjectType inputType =
createProcessInstanceArgumentWithNodes();
+ var parser = mapper.apply(inputType);
+
+ // Test: nodes: { contains: { name: { equal: "StartProcess" } } }
+ var filters = parser.apply(Map.of("nodes", Map.of("contains",
Map.of("name", Map.of("equal", "StartProcess")))));
+
+ assertThat(filters).hasSize(1);
+ AttributeFilter<?> filter = filters.get(0);
+ assertThat(filter.getAttribute()).isEqualTo("nodes.name");
+ assertThat(filter.getCondition()).isEqualTo(FilterCondition.EQUAL);
+ assertThat(filter.getValue()).isEqualTo("StartProcess");
+ }
+
+ @Test
+ void testArrayArgumentContainsAll() {
+ GraphQLInputObjectType inputType =
createProcessInstanceArgumentWithNodes();
+ var parser = mapper.apply(inputType);
+
+ // Test: nodes: { containsAll: [{ name: { equal: "Start" } }, { name:
{ equal: "End" } }] }
+ var filters = parser.apply(Map.of("nodes", Map.of("containsAll",
List.of(
+ Map.of("name", Map.of("equal", "Start")),
+ Map.of("name", Map.of("equal", "End"))))));
+
+ assertThat(filters).hasSize(1);
+ AttributeFilter<?> filter = filters.get(0);
+ assertThat(filter.getCondition()).isEqualTo(FilterCondition.AND);
+
+ @SuppressWarnings("unchecked")
+ List<AttributeFilter<?>> andFilters = (List<AttributeFilter<?>>)
filter.getValue();
+ assertThat(andFilters).hasSize(2);
+ assertThat(andFilters.get(0).getAttribute()).isEqualTo("nodes.name");
+ assertThat(andFilters.get(0).getValue()).isEqualTo("Start");
+ assertThat(andFilters.get(1).getAttribute()).isEqualTo("nodes.name");
+ assertThat(andFilters.get(1).getValue()).isEqualTo("End");
+ }
+
+ @Test
+ void testArrayArgumentContainsAny() {
+ GraphQLInputObjectType inputType =
createProcessInstanceArgumentWithNodes();
+ var parser = mapper.apply(inputType);
+
+ // Test: nodes: { containsAny: [{ name: { equal: "Start" } }, { name:
{ equal: "End" } }] }
+ var filters = parser.apply(Map.of("nodes", Map.of("containsAny",
List.of(
+ Map.of("name", Map.of("equal", "Start")),
+ Map.of("name", Map.of("equal", "End"))))));
+
+ assertThat(filters).hasSize(1);
+ AttributeFilter<?> filter = filters.get(0);
+ assertThat(filter.getCondition()).isEqualTo(FilterCondition.OR);
+
+ @SuppressWarnings("unchecked")
+ List<AttributeFilter<?>> orFilters = (List<AttributeFilter<?>>)
filter.getValue();
+ assertThat(orFilters).hasSize(2);
+ assertThat(orFilters.get(0).getAttribute()).isEqualTo("nodes.name");
+ assertThat(orFilters.get(1).getAttribute()).isEqualTo("nodes.name");
+ }
+
+ @Test
+ void testArrayArgumentIsNull() {
+ GraphQLInputObjectType inputType =
createProcessInstanceArgumentWithNodes();
+ var parser = mapper.apply(inputType);
+
+ // Test: nodes: { isNull: true }
+ var filters = parser.apply(Map.of("nodes", Map.of("isNull", true)));
+
+ assertThat(filters).hasSize(1);
+ AttributeFilter<?> filter = filters.get(0);
+ assertThat(filter.getAttribute()).isEqualTo("nodes");
+ assertThat(filter.getCondition()).isEqualTo(FilterCondition.IS_NULL);
+ }
+
+ @Test
+ void testArrayArgumentIsNotNull() {
+ GraphQLInputObjectType inputType =
createProcessInstanceArgumentWithNodes();
+ var parser = mapper.apply(inputType);
+
+ // Test: nodes: { isNull: false }
+ var filters = parser.apply(Map.of("nodes", Map.of("isNull", false)));
+
+ assertThat(filters).hasSize(1);
+ AttributeFilter<?> filter = filters.get(0);
+ assertThat(filter.getAttribute()).isEqualTo("nodes");
+ assertThat(filter.getCondition()).isEqualTo(FilterCondition.NOT_NULL);
+ }
+
+ @Test
+ void testArrayArgumentWithNot() {
+ GraphQLInputObjectType inputType =
createProcessInstanceArgumentWithNodes();
+ var parser = mapper.apply(inputType);
+
+ // Test: nodes: { not: { contains: { name: { equal: "StartProcess" } }
} }
+ var filters = parser.apply(Map.of("nodes", Map.of("not",
Map.of("contains", Map.of("name", Map.of("equal", "StartProcess"))))));
+
+ assertThat(filters).hasSize(1);
+ AttributeFilter<?> filter = filters.get(0);
+ assertThat(filter.getCondition()).isEqualTo(FilterCondition.NOT);
+
+ AttributeFilter<?> notFilter = (AttributeFilter<?>) filter.getValue();
+ assertThat(notFilter.getAttribute()).isEqualTo("nodes.name");
+ assertThat(notFilter.getValue()).isEqualTo("StartProcess");
+ }
+
+ @Test
+ void testArrayArgumentWithNotContainsAll() {
+ GraphQLInputObjectType inputType =
createProcessInstanceArgumentWithNodes();
+ var parser = mapper.apply(inputType);
+
+ // Test: nodes: { not: { containsAll: [{ name: { equal: "Start" } }, {
name: { equal: "End" } }] } }
+ var filters = parser.apply(Map.of("nodes", Map.of("not",
Map.of("containsAll", List.of(
+ Map.of("name", Map.of("equal", "Start")),
+ Map.of("name", Map.of("equal", "End")))))));
+
+ assertThat(filters).hasSize(1);
+ AttributeFilter<?> filter = filters.get(0);
+ assertThat(filter.getCondition()).isEqualTo(FilterCondition.NOT);
+
+ AttributeFilter<?> notFilter = (AttributeFilter<?>) filter.getValue();
+ assertThat(notFilter.getCondition()).isEqualTo(FilterCondition.AND);
+ }
+
+ @Test
+ void testArrayArgumentBackwardCompatibility() {
+ GraphQLInputObjectType inputType =
createProcessInstanceArgumentWithNodes();
+ var parser = mapper.apply(inputType);
+
+ // Test: nodes: { name: { equal: "StartProcess" } }
+ // Should work like 'contains' for backward compatibility
+ var filters = parser.apply(Map.of("nodes", Map.of("name",
Map.of("equal", "StartProcess"))));
+
+ assertThat(filters).hasSize(1);
+ AttributeFilter<?> filter = filters.get(0);
+ assertThat(filter.getAttribute()).isEqualTo("nodes.name");
+ assertThat(filter.getCondition()).isEqualTo(FilterCondition.EQUAL);
+ assertThat(filter.getValue()).isEqualTo("StartProcess");
+ }
+
+ @Test
+ void testArrayArgumentWithAndOperator() {
+ GraphQLInputObjectType inputType =
createProcessInstanceArgumentWithNodes();
+ var parser = mapper.apply(inputType);
+
+ // Test: nodes: { and: [{ contains: { name: { equal: "Start" } } }, {
contains: { type: { equal: "HumanTask" } } }] }
+ var filters = parser.apply(Map.of("nodes", Map.of("and", List.of(
+ Map.of("contains", Map.of("name", Map.of("equal", "Start"))),
+ Map.of("contains", Map.of("type", Map.of("equal",
"HumanTask")))))));
+
+ assertThat(filters).hasSize(1);
+ AttributeFilter<?> filter = filters.get(0);
+ assertThat(filter.getCondition()).isEqualTo(FilterCondition.AND);
+
+ @SuppressWarnings("unchecked")
+ List<AttributeFilter<?>> andFilters = (List<AttributeFilter<?>>)
filter.getValue();
+ assertThat(andFilters).hasSize(2);
+ assertThat(andFilters.get(0).getAttribute()).isEqualTo("nodes.name");
+ assertThat(andFilters.get(1).getAttribute()).isEqualTo("nodes.type");
+ }
+
+ @Test
+ void testArrayArgumentWithOrOperator() {
+ GraphQLInputObjectType inputType =
createProcessInstanceArgumentWithNodes();
+ var parser = mapper.apply(inputType);
+
+ // Test: nodes: { or: [{ contains: { name: { equal: "Start" } } }, {
contains: { name: { equal: "End" } } }] }
+ var filters = parser.apply(Map.of("nodes", Map.of("or", List.of(
+ Map.of("contains", Map.of("name", Map.of("equal", "Start"))),
+ Map.of("contains", Map.of("name", Map.of("equal", "End")))))));
+
+ assertThat(filters).hasSize(1);
+ AttributeFilter<?> filter = filters.get(0);
+ assertThat(filter.getCondition()).isEqualTo(FilterCondition.OR);
+
+ @SuppressWarnings("unchecked")
+ List<AttributeFilter<?>> orFilters = (List<AttributeFilter<?>>)
filter.getValue();
+ assertThat(orFilters).hasSize(2);
+ assertThat(orFilters.get(0).getAttribute()).isEqualTo("nodes.name");
+ assertThat(orFilters.get(1).getAttribute()).isEqualTo("nodes.name");
+ }
+
+ @Test
+ void testArrayArgumentComplexQuery() {
+ GraphQLInputObjectType inputType =
createProcessInstanceArgumentWithNodes();
+ var parser = mapper.apply(inputType);
+
+ // Test: nodes: { and: [{ contains: { name: { equal: "HumanTask" } }
}, { not: { contains: { type: { equal: "EndEvent" } } } }] }
+ var filters = parser.apply(Map.of("nodes", Map.of("and", List.of(
+ Map.of("contains", Map.of("name", Map.of("equal",
"HumanTask"))),
+ Map.of("not", Map.of("contains", Map.of("type",
Map.of("equal", "EndEvent"))))))));
+
+ assertThat(filters).hasSize(1);
+ AttributeFilter<?> filter = filters.get(0);
+ assertThat(filter.getCondition()).isEqualTo(FilterCondition.AND);
+
+ @SuppressWarnings("unchecked")
+ List<AttributeFilter<?>> andFilters = (List<AttributeFilter<?>>)
filter.getValue();
+ assertThat(andFilters).hasSize(2);
+ assertThat(andFilters.get(0).getAttribute()).isEqualTo("nodes.name");
+
assertThat(andFilters.get(1).getCondition()).isEqualTo(FilterCondition.NOT);
+ }
+
+ // Helper method to create ProcessInstanceArgument with
NodeInstanceArrayArgument
+ private GraphQLInputObjectType createProcessInstanceArgumentWithNodes() {
+ // Create StringArgument type
+ GraphQLInputObjectType stringArgument =
GraphQLInputObjectType.newInputObject()
+ .name("StringArgument")
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("equal")
+ .type(Scalars.GraphQLString)
+ .build())
+ .build();
+
+ // Create NodeInstanceArgument type
+ GraphQLInputObjectType nodeInstanceArgument =
GraphQLInputObjectType.newInputObject()
+ .name("NodeInstanceArgument")
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("name")
+ .type(stringArgument)
+ .build())
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("type")
+ .type(stringArgument)
+ .build())
+ .build();
+
+ // Create NodeInstanceArrayArgument type (note the "ArrayArgument"
suffix)
+ GraphQLInputObjectType nodeInstanceArrayArgument =
GraphQLInputObjectType.newInputObject()
+ .name("NodeInstanceArrayArgument")
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("contains")
+ .type(nodeInstanceArgument)
+ .build())
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("containsAll")
+ .type(new GraphQLList(nodeInstanceArgument))
+ .build())
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("containsAny")
+ .type(new GraphQLList(nodeInstanceArgument))
+ .build())
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("isNull")
+ .type(Scalars.GraphQLBoolean)
+ .build())
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("and")
+ .type(new
GraphQLList(GraphQLInputObjectType.newInputObject()
+ .name("NodeInstanceArrayArgumentRecursive")
+ .build()))
+ .build())
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("or")
+ .type(new
GraphQLList(GraphQLInputObjectType.newInputObject()
+ .name("NodeInstanceArrayArgumentRecursive2")
+ .build()))
+ .build())
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("not")
+ .type(GraphQLInputObjectType.newInputObject()
+ .name("NodeInstanceArrayArgumentRecursive3")
+ .build())
+ .build())
+ .build();
+
+ // Create ProcessInstanceArgument type with nodes field
+ return GraphQLInputObjectType.newInputObject()
+ .name("ProcessInstanceArgument")
+ .field(GraphQLInputObjectField.newInputObjectField()
+ .name("nodes")
+ .type(nodeInstanceArrayArgument)
+ .build())
+ .build();
+ }
}
diff --git
a/data-index/data-index-storage/data-index-storage-api/src/test/java/org/kie/kogito/index/test/TestUtils.java
b/data-index/data-index-storage/data-index-storage-api/src/test/java/org/kie/kogito/index/test/TestUtils.java
index ffbb87fc6..9b0cdf5ee 100644
---
a/data-index/data-index-storage/data-index-storage-api/src/test/java/org/kie/kogito/index/test/TestUtils.java
+++
b/data-index/data-index-storage/data-index-storage-api/src/test/java/org/kie/kogito/index/test/TestUtils.java
@@ -372,4 +372,32 @@ public class TestUtils {
return task;
}
+
+ public static ProcessInstanceStateDataEvent getProcessCloudEvent(String
processId, String processInstanceId, ProcessInstanceState status, String
rootProcessInstanceId, String rootProcessId,
+ String parentProcessInstanceId, String identity) {
+
+ int eventType = status.equals(ProcessInstanceState.COMPLETED) ?
ProcessInstanceStateEventBody.EVENT_TYPE_ENDED :
ProcessInstanceStateEventBody.EVENT_TYPE_STARTED;
+ ProcessInstanceStateEventBody body =
ProcessInstanceStateEventBody.create()
+ .processInstanceId(processInstanceId)
+ .parentInstanceId(parentProcessInstanceId)
+ .rootProcessInstanceId(rootProcessInstanceId)
+ .rootProcessId(rootProcessId)
+ .processId(processId)
+ .eventDate(new Date())
+ .state(status.ordinal())
+ .businessKey(processInstanceId)
+ .roles("admin")
+ .eventUser(identity)
+ .eventType(eventType)
+ .build();
+
+ ProcessInstanceStateDataEvent event = new
ProcessInstanceStateDataEvent();
+ event.setKogitoProcessId(processId);
+ event.setKogitoRootProcessId(rootProcessId);
+ event.setKogitoRootProcessInstanceId(rootProcessInstanceId);
+ event.setKogitoProcessInstanceId(processInstanceId);
+ event.setData(body);
+
+ return event;
+ }
}
diff --git
a/data-index/data-index-storage/data-index-storage-jpa-common/src/main/java/org/kie/kogito/index/jpa/storage/JPAQuery.java
b/data-index/data-index-storage/data-index-storage-jpa-common/src/main/java/org/kie/kogito/index/jpa/storage/JPAQuery.java
index 66f608ab0..29f34644f 100644
---
a/data-index/data-index-storage/data-index-storage-jpa-common/src/main/java/org/kie/kogito/index/jpa/storage/JPAQuery.java
+++
b/data-index/data-index-storage/data-index-storage-jpa-common/src/main/java/org/kie/kogito/index/jpa/storage/JPAQuery.java
@@ -18,6 +18,7 @@
*/
package org.kie.kogito.index.jpa.storage;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -26,17 +27,20 @@ import java.util.function.Function;
import org.kie.kogito.index.jpa.model.AbstractEntity;
import org.kie.kogito.persistence.api.query.AttributeFilter;
import org.kie.kogito.persistence.api.query.AttributeSort;
+import org.kie.kogito.persistence.api.query.FilterCondition;
import org.kie.kogito.persistence.api.query.Query;
import org.kie.kogito.persistence.api.query.SortDirection;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
+import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Order;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
+import jakarta.persistence.criteria.Subquery;
import jakarta.persistence.metamodel.Attribute;
import static java.util.stream.Collectors.toList;
@@ -92,6 +96,8 @@ public class JPAQuery<E extends AbstractEntity, T> implements
Query<T> {
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<E> criteriaQuery = builder.createQuery(entityClass);
Root<E> root = criteriaQuery.from(entityClass);
+
+ criteriaQuery.select(root);
addWhere(builder, criteriaQuery, root);
if (sortBy != null && !sortBy.isEmpty()) {
List<Order> orderBy = sortBy.stream().map(f -> {
@@ -110,29 +116,52 @@ public class JPAQuery<E extends AbstractEntity, T>
implements Query<T> {
return (List<T>)
query.getResultList().stream().map(mapper).collect(toList());
}
- protected Function<AttributeFilter<?>, Predicate>
filterPredicateFunction(Root<E> root, CriteriaBuilder builder) {
+ /**
+ * Creates a function that converts an AttributeFilter to a JPA Predicate.
+ *
+ * @param root the query root
+ * @param builder the criteria builder
+ * @param criteriaQuery the criteria query
+ * @return a function that converts filters to predicates
+ */
+ protected Function<AttributeFilter<?>, Predicate>
filterPredicateFunction(Root<E> root, CriteriaBuilder builder, CriteriaQuery<?>
criteriaQuery) {
return filter -> jsonPredicateBuilder.filter(b ->
filter.isJson()).map(b -> b.buildPredicate(filter, root, builder))
- .orElseGet(() -> buildPredicateFunction(filter, root,
builder));
+ .orElseGet(() -> buildPredicateFunction(filter, root, builder,
criteriaQuery));
}
- protected final Predicate buildPredicateFunction(AttributeFilter filter,
Root<E> root, CriteriaBuilder builder) {
+ protected final Predicate buildPredicateFunction(AttributeFilter filter,
Root<E> root, CriteriaBuilder builder, CriteriaQuery<?> criteriaQuery) {
switch (filter.getCondition()) {
case CONTAINS:
- return builder.isMember(filter.getValue(),
getAttributePath(root, filter.getAttribute()));
+ if (isCollectionAttribute(filter.getAttribute())) {
+ return buildCollectionPredicate(filter, root, builder,
criteriaQuery, false);
+ } else {
+ return builder.isMember(filter.getValue(),
getAttributePath(root, filter.getAttribute()));
+ }
case CONTAINS_ALL:
- List<Predicate> predicatesAll = (List<Predicate>) ((List)
filter.getValue()).stream()
- .map(o -> builder.isMember(o, getAttributePath(root,
filter.getAttribute()))).collect(toList());
- return builder.and(predicatesAll.toArray(new Predicate[] {}));
+ if (isCollectionAttribute(filter.getAttribute())) {
+ return buildCollectionPredicate(filter, root, builder,
criteriaQuery, false);
+ } else {
+ List<Predicate> predicatesAll = (List<Predicate>) ((List)
filter.getValue()).stream()
+ .map(o -> builder.isMember(o,
getAttributePath(root, filter.getAttribute()))).collect(toList());
+ return builder.and(predicatesAll.toArray(new Predicate[]
{}));
+ }
case CONTAINS_ANY:
- List<Predicate> predicatesAny = (List<Predicate>) ((List)
filter.getValue()).stream()
- .map(o -> builder.isMember(o, getAttributePath(root,
filter.getAttribute()))).collect(toList());
- return builder.or(predicatesAny.toArray(new Predicate[] {}));
+ if (isCollectionAttribute(filter.getAttribute())) {
+ return buildCollectionPredicate(filter, root, builder,
criteriaQuery, false);
+ } else {
+ List<Predicate> predicatesAny = (List<Predicate>) ((List)
filter.getValue()).stream()
+ .map(o -> builder.isMember(o,
getAttributePath(root, filter.getAttribute()))).collect(toList());
+ return builder.or(predicatesAny.toArray(new Predicate[]
{}));
+ }
case IN:
return getAttributePath(root,
filter.getAttribute()).in((Collection<?>) filter.getValue());
case LIKE:
return builder.like(getAttributePath(root,
filter.getAttribute()),
filter.getValue().toString().replaceAll("\\*", "%"));
case EQUAL:
+ if (filter.getAttribute() != null &&
isCollectionAttribute(filter.getAttribute())) {
+ return buildCollectionPredicate(filter, root, builder,
criteriaQuery, false);
+ }
return builder.equal(getAttributePath(root,
filter.getAttribute()), filter.getValue());
case IS_NULL:
Path pathNull = getAttributePath(root, filter.getAttribute());
@@ -156,17 +185,135 @@ public class JPAQuery<E extends AbstractEntity, T>
implements Query<T> {
return builder
.lessThanOrEqualTo(getAttributePath(root,
filter.getAttribute()), (Comparable) filter.getValue());
case OR:
- return builder.or(getRecursivePredicate(filter, root,
builder).toArray(new Predicate[] {}));
+ return buildGroupedPredicate(filter, root, builder,
criteriaQuery, false, false);
case AND:
- return builder.and(getRecursivePredicate(filter, root,
builder).toArray(new Predicate[] {}));
+ return buildGroupedPredicate(filter, root, builder,
criteriaQuery, true, false);
case NOT:
- return builder.not(filterPredicateFunction(root,
builder).apply((AttributeFilter<?>) filter.getValue()));
+ AttributeFilter<?> innerFilter = (AttributeFilter<?>)
filter.getValue();
+
+ // Handle NOT with AND/OR: Apply De Morgan's Law by negating
and flipping the operator
+ if (innerFilter.getCondition() == FilterCondition.AND ||
innerFilter.getCondition() == FilterCondition.OR) {
+ // NOT (A AND B) = NOT A OR NOT B, NOT (A OR B) = NOT A
AND NOT B
+ boolean flipToAnd = innerFilter.getCondition() ==
FilterCondition.OR;
+ return buildGroupedPredicate(innerFilter, root, builder,
criteriaQuery, flipToAnd, true);
+ }
+
+ if (innerFilter.getAttribute() != null &&
isCollectionAttribute(innerFilter.getAttribute()) &&
+ innerFilter.getCondition().isCollectionOperation()) {
+ return buildCollectionPredicate(innerFilter, root,
builder, criteriaQuery, true);
+ }
+
+ if (innerFilter.getAttribute() != null &&
isCollectionAttribute(innerFilter.getAttribute())) {
+ return buildNegatedCollectionPredicate(innerFilter, root,
builder, criteriaQuery);
+ }
+
+ return builder.not(filterPredicateFunction(root, builder,
criteriaQuery).apply(innerFilter));
default:
return null;
}
}
+ /**
+ * Builds predicates for AND/OR, grouping CONTAINS filters on same
collection into single EXISTS.
+ *
+ * @param isNegated if true, negates each predicate (for NOT operations
with De Morgan's Law)
+ */
+ private Predicate buildGroupedPredicate(AttributeFilter<?> filter, Root<E>
root,
+ CriteriaBuilder builder, CriteriaQuery<?> criteriaQuery, boolean
isAnd, boolean isNegated) {
+
+ List<AttributeFilter<?>> nestedFilters = (List<AttributeFilter<?>>)
filter.getValue();
+
+ java.util.Map<String, List<AttributeFilter<?>>> groups = new
java.util.HashMap<>();
+ List<Predicate> otherPredicates = new ArrayList<>();
+
+ for (AttributeFilter<?> f : nestedFilters) {
+ if (f.getAttribute() != null &&
isCollectionAttribute(f.getAttribute()) &&
+ f.getCondition().isCollectionOperation()) {
+ String collection = f.getAttribute().split("\\.")[0];
+ groups.computeIfAbsent(collection, k -> new
ArrayList<>()).add(f);
+ } else {
+ Predicate pred = filterPredicateFunction(root, builder,
criteriaQuery).apply(f);
+ otherPredicates.add(isNegated ? builder.not(pred) : pred);
+ }
+ }
+
+ List<Predicate> allPredicates = new ArrayList<>(otherPredicates);
+ for (List<AttributeFilter<?>> groupFilters : groups.values()) {
+
allPredicates.add(buildMultiFilterCollectionPredicate(groupFilters, root,
builder, criteriaQuery, isAnd, isNegated));
+ }
+
+ return isAnd ? builder.and(allPredicates.toArray(new Predicate[0]))
+ : builder.or(allPredicates.toArray(new Predicate[0]));
+ }
+
+ /**
+ * Builds single EXISTS with HAVING for multiple CONTAINS filters on same
collection.
+ * Example: [{ comments.id: "A" }, { comments.status: "ACTIVE" }]
+ * → EXISTS(... HAVING SUM(id='A')>0 AND SUM(status='ACTIVE')>0)
+ *
+ * @param isNegated if true, uses = 0 instead of > 0 and applies De
Morgan's Law
+ */
+ private Predicate
buildMultiFilterCollectionPredicate(List<AttributeFilter<?>> filters, Root<E>
root,
+ CriteriaBuilder builder, CriteriaQuery<?> criteriaQuery, boolean
combineWithAnd, boolean isNegated) {
+
+ String collectionName = filters.get(0).getAttribute().split("\\.")[0];
+
+ Subquery<Integer> subquery = criteriaQuery.subquery(Integer.class);
+ Root<E> subRoot = subquery.from(entityClass);
+ Join<?, ?> collectionJoin = subRoot.join(collectionName);
+
+ List<Predicate> havingConditions = new ArrayList<>();
+
+ for (AttributeFilter<?> filter : filters) {
+ String property = filter.getAttribute().split("\\.")[1];
+ List<Object> values = filter.getCondition().expectsSingleValue()
+ ? List.of(filter.getValue())
+ : (List<Object>) filter.getValue();
+ boolean filterUseAnd =
filter.getCondition().combineValuesWithAnd();
+
+ List<Predicate> valuePreds = new ArrayList<>();
+ for (Object value : values) {
+ Expression<Integer> caseExpr = builder.sum(
+ builder.<Integer> selectCase()
+
.when(builder.equal(collectionJoin.get(property), value), 1)
+ .otherwise(0));
+ // Apply negation: > 0 becomes = 0
+ valuePreds.add(isNegated ? builder.equal(caseExpr, 0) :
builder.greaterThan(caseExpr, 0));
+ }
+ // Apply De Morgan's Law via XOR for value combination
+ boolean useAnd = filterUseAnd ^ isNegated;
+ havingConditions.add(useAnd ? builder.and(valuePreds.toArray(new
Predicate[0]))
+ : builder.or(valuePreds.toArray(new Predicate[0])));
+ }
+
+ Predicate havingPredicate = combineWithAnd
+ ? builder.and(havingConditions.toArray(new Predicate[0]))
+ : builder.or(havingConditions.toArray(new Predicate[0]));
+
+ subquery.select(builder.literal(1))
+ .where(builder.equal(subRoot.get("id"), root.get("id")))
+ .groupBy(subRoot.get("id"))
+ .having(havingPredicate);
+
+ return builder.exists(subquery);
+ }
+
+ /**
+ * Builds negated collection predicate using EXISTS with HAVING = 0.
+ * Uses EXISTS with inverted HAVING clause instead of NOT EXISTS for
better optimization.
+ *
+ * Examples:
+ * - NOT contains: EXISTS(...HAVING SUM(CASE WHEN id='A' THEN 1 ELSE 0
END) = 0)
+ * - NOT containsAll: EXISTS(...HAVING SUM(id='A')=0 OR SUM(id='B')=0)
+ * - NOT containsAny: EXISTS(...HAVING SUM(id='A')=0 AND SUM(id='B')=0)
+ */
+ private Predicate buildNegatedCollectionPredicate(AttributeFilter<?>
filter, Root<E> root,
+ CriteriaBuilder builder, CriteriaQuery<?> criteriaQuery) {
+
+ return buildCollectionPredicate(filter, root, builder, criteriaQuery,
true);
+ }
+
private Path getAttributePath(Root<E> root, String attribute) {
String[] split = attribute.split("\\.");
if (split.length == 1) {
@@ -186,10 +333,23 @@ public class JPAQuery<E extends AbstractEntity, T>
implements Query<T> {
.anyMatch(pluralAttribute ->
pluralAttribute.equals(attribute));
}
- private List<Predicate> getRecursivePredicate(AttributeFilter<?> filter,
Root<E> root, CriteriaBuilder builder) {
+ /**
+ * Checks if the attribute is a collection attribute (e.g., "nodes.name"
starts with "nodes").
+ * This helps detect when NOT operations need special subquery handling.
+ */
+ private boolean isCollectionAttribute(final String attribute) {
+ if (attribute == null || !attribute.contains(".")) {
+ return false;
+ }
+ // Extract the first part (e.g., "nodes" from "nodes.name")
+ String firstPart = attribute.split("\\.")[0];
+ return isPluralAttribute(firstPart);
+ }
+
+ private List<Predicate> getRecursivePredicate(AttributeFilter<?> filter,
Root<E> root, CriteriaBuilder builder, CriteriaQuery<?> criteriaQuery) {
return ((List<AttributeFilter<?>>) filter.getValue())
.stream()
- .map(filterPredicateFunction(root, builder))
+ .map(filterPredicateFunction(root, builder, criteriaQuery))
.collect(toList());
}
@@ -205,7 +365,62 @@ public class JPAQuery<E extends AbstractEntity, T>
implements Query<T> {
private <V> void addWhere(CriteriaBuilder builder, CriteriaQuery<V>
criteriaQuery, Root<E> root) {
if (filters != null && !filters.isEmpty()) {
-
criteriaQuery.where(filters.stream().map(filterPredicateFunction(root,
builder)).toArray(Predicate[]::new));
+
criteriaQuery.where(filters.stream().map(filterPredicateFunction(root, builder,
criteriaQuery)).toArray(Predicate[]::new));
}
}
+
+ /**
+ * Unified method to build collection predicates using EXISTS + HAVING
clause.
+ * Handles CONTAINS, CONTAINS_ALL, CONTAINS_ANY, and EQUAL operations.
+ */
+ private Predicate buildCollectionPredicate(AttributeFilter<?> filter,
Root<E> root,
+ CriteriaBuilder builder, CriteriaQuery<?> criteriaQuery, boolean
isNegated) {
+
+ // Parse attribute: "comments.id" -> collection="comments",
property="id"
+ String[] parts = filter.getAttribute().split("\\.");
+ String collectionName = parts[0];
+ String propertyPath = parts[1];
+
+ // Get values: single value for CONTAINS/EQUAL, list for
CONTAINS_ALL/CONTAINS_ANY
+ List<Object> values = filter.getCondition().expectsSingleValue()
+ ? List.of(filter.getValue())
+ : (List<Object>) filter.getValue();
+
+ // Determine combination logic: AND for CONTAINS/CONTAINS_ALL/EQUAL,
OR for CONTAINS_ANY
+ boolean combineUsingAnd = filter.getCondition().combineValuesWithAnd();
+
+ // Create subquery
+ Subquery<Integer> subquery = criteriaQuery.subquery(Integer.class);
+ Root<E> subRoot = subquery.from(entityClass);
+ Join<?, ?> collectionJoin = subRoot.join(collectionName);
+
+ // Build CASE expressions: SUM(CASE WHEN c.id = 'A' THEN 1 ELSE 0 END)
+ List<Predicate> havingConditions = new ArrayList<>();
+ for (Object value : values) {
+ Expression<Integer> caseExpr = builder.sum(
+ builder.<Integer> selectCase()
+
.when(builder.equal(collectionJoin.get(propertyPath), value), 1)
+ .otherwise(0));
+
+ // Condition: > 0 for positive, = 0 for negative
+ Predicate condition = isNegated
+ ? builder.equal(caseExpr, 0)
+ : builder.greaterThan(caseExpr, 0);
+ havingConditions.add(condition);
+ }
+
+ // Combine with AND/OR (apply De Morgan's Law via XOR)
+ boolean useAnd = combineUsingAnd ^ isNegated;
+ Predicate havingPredicate = useAnd
+ ? builder.and(havingConditions.toArray(new Predicate[0]))
+ : builder.or(havingConditions.toArray(new Predicate[0]));
+
+ // Build complete subquery
+ subquery.select(builder.literal(1))
+ .where(builder.equal(subRoot.get("id"), root.get("id")))
+ .groupBy(subRoot.get("id"))
+ .having(havingPredicate);
+
+ return builder.exists(subquery);
+ }
}
diff --git
a/data-index/data-index-storage/data-index-storage-jpa-common/src/test/java/org/kie/kogito/index/jpa/query/AbstractProcessInstanceEntityQueryIT.java
b/data-index/data-index-storage/data-index-storage-jpa-common/src/test/java/org/kie/kogito/index/jpa/query/AbstractProcessInstanceEntityQueryIT.java
index b61d47fb5..f7aa7736a 100644
---
a/data-index/data-index-storage/data-index-storage-jpa-common/src/test/java/org/kie/kogito/index/jpa/query/AbstractProcessInstanceEntityQueryIT.java
+++
b/data-index/data-index-storage/data-index-storage-jpa-common/src/test/java/org/kie/kogito/index/jpa/query/AbstractProcessInstanceEntityQueryIT.java
@@ -22,12 +22,18 @@ import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
+import org.kie.kogito.event.process.ProcessInstanceNodeDataEvent;
+import org.kie.kogito.event.process.ProcessInstanceNodeEventBody;
import org.kie.kogito.event.process.ProcessInstanceStateDataEvent;
import org.kie.kogito.index.jpa.storage.ProcessInstanceEntityStorage;
+import org.kie.kogito.index.model.ProcessInstance;
import org.kie.kogito.index.test.TestUtils;
import org.kie.kogito.index.test.query.AbstractProcessInstanceQueryIT;
+import jakarta.transaction.Transactional;
+
import static org.assertj.core.api.Assertions.assertThat;
+import static org.kie.kogito.index.model.ProcessInstanceState.ACTIVE;
import static org.kie.kogito.index.model.ProcessInstanceState.COMPLETED;
import static org.kie.kogito.persistence.api.query.QueryFilterFactory.*;
@@ -51,4 +57,364 @@ public abstract class AbstractProcessInstanceEntityQueryIT
extends AbstractProce
assertThat(storage.query().count()).isNotZero();
assertThat(storage.query().filter(List.of(in("state",
List.of(34)))).count()).isZero();
}
+
+ // ========================================
+ // NOT Operator Tests for Collection Operations
+ // ========================================
+
+ /**
+ * Test: NOT with CONTAINS on collection attribute
+ * Verifies entity-level negation using NOT EXISTS subquery
+ */
+ @Test
+ @Transactional
+ void testNotContainsOnCollectionAttribute() {
+ // Setup: Create process instances with different nodes
+ String processId = "testProcess_" + UUID.randomUUID().toString();
+ String pi1 = UUID.randomUUID().toString();
+ String pi2 = UUID.randomUUID().toString();
+ String pi3 = UUID.randomUUID().toString();
+
+ // PI1: Has nodes ["Start", "HR Interview", "End"]
+ ProcessInstanceStateDataEvent event1 =
TestUtils.getProcessCloudEvent(processId, pi1, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event1);
+ addNodeToProcessInstance(pi1, "Start", "node1");
+ addNodeToProcessInstance(pi1, "HR Interview", "node2");
+ addNodeToProcessInstance(pi1, "End", "node3");
+
+ // PI2: Has nodes ["Start", "Technical Interview", "End"]
+ ProcessInstanceStateDataEvent event2 =
TestUtils.getProcessCloudEvent(processId, pi2, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event2);
+ addNodeToProcessInstance(pi2, "Start", "node1");
+ addNodeToProcessInstance(pi2, "Technical Interview", "node4");
+ addNodeToProcessInstance(pi2, "End", "node3");
+
+ // PI3: Has nodes ["Start", "End"]
+ ProcessInstanceStateDataEvent event3 =
TestUtils.getProcessCloudEvent(processId, pi3, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event3);
+ addNodeToProcessInstance(pi3, "Start", "node1");
+ addNodeToProcessInstance(pi3, "End", "node3");
+
+ // Test: Find processes that DON'T have "HR Interview" node
+ List<ProcessInstance> result = storage.query()
+ .filter(List.of(
+ equalTo("processId", processId),
+ not(contains("nodes.name", "HR Interview"))))
+ .execute();
+
+ // Verify: Should return PI2 and PI3 (not PI1)
+ assertThat(result).hasSize(2);
+
assertThat(result).extracting(ProcessInstance::getId).containsExactlyInAnyOrder(pi2,
pi3);
+
assertThat(result).extracting(ProcessInstance::getId).doesNotContain(pi1);
+ }
+
+ /**
+ * Test: NOT with CONTAINS_ALL on collection
+ * Verifies De Morgan's Law: NOT (A AND B) = (NOT A) OR (NOT B)
+ */
+ @Test
+ @Transactional
+ void testNotContainsAllOnCollection() {
+ String processId = "testProcess_" + UUID.randomUUID().toString();
+ String pi1 = UUID.randomUUID().toString();
+ String pi2 = UUID.randomUUID().toString();
+ String pi3 = UUID.randomUUID().toString();
+
+ // PI1: Has all required nodes ["Start", "HR Interview", "End"]
+ ProcessInstanceStateDataEvent event1 =
TestUtils.getProcessCloudEvent(processId, pi1, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event1);
+ addNodeToProcessInstance(pi1, "Start", "node1");
+ addNodeToProcessInstance(pi1, "HR Interview", "node2");
+ addNodeToProcessInstance(pi1, "End", "node3");
+
+ // PI2: Missing "HR Interview" - has ["Start", "End"]
+ ProcessInstanceStateDataEvent event2 =
TestUtils.getProcessCloudEvent(processId, pi2, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event2);
+ addNodeToProcessInstance(pi2, "Start", "node1");
+ addNodeToProcessInstance(pi2, "End", "node3");
+
+ // PI3: Missing "End" - has ["Start", "HR Interview"]
+ ProcessInstanceStateDataEvent event3 =
TestUtils.getProcessCloudEvent(processId, pi3, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event3);
+ addNodeToProcessInstance(pi3, "Start", "node1");
+ addNodeToProcessInstance(pi3, "HR Interview", "node2");
+
+ // Test: Find processes that DON'T have ALL of ["HR Interview", "End"]
+ List<ProcessInstance> result = storage.query()
+ .filter(List.of(
+ equalTo("processId", processId),
+ not(containsAll("nodes.name", List.of("HR Interview",
"End")))))
+ .execute();
+
+ // Verify: Should return PI2 and PI3 (missing at least one node)
+ assertThat(result).hasSize(2);
+
assertThat(result).extracting(ProcessInstance::getId).containsExactlyInAnyOrder(pi2,
pi3);
+
assertThat(result).extracting(ProcessInstance::getId).doesNotContain(pi1);
+ }
+
+ /**
+ * Test: NOT with CONTAINS_ANY on collection
+ * Verifies De Morgan's Law: NOT (A OR B) = (NOT A) AND (NOT B)
+ */
+ @Test
+ @Transactional
+ void testNotContainsAnyOnCollection() {
+ String processId = "testProcess_" + UUID.randomUUID().toString();
+ String pi1 = UUID.randomUUID().toString();
+ String pi2 = UUID.randomUUID().toString();
+ String pi3 = UUID.randomUUID().toString();
+
+ // PI1: Has "HR Interview"
+ ProcessInstanceStateDataEvent event1 =
TestUtils.getProcessCloudEvent(processId, pi1, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event1);
+ addNodeToProcessInstance(pi1, "Start", "node1");
+ addNodeToProcessInstance(pi1, "HR Interview", "node2");
+
+ // PI2: Has "Technical Interview"
+ ProcessInstanceStateDataEvent event2 =
TestUtils.getProcessCloudEvent(processId, pi2, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event2);
+ addNodeToProcessInstance(pi2, "Start", "node1");
+ addNodeToProcessInstance(pi2, "Technical Interview", "node3");
+
+ // PI3: Has neither
+ ProcessInstanceStateDataEvent event3 =
TestUtils.getProcessCloudEvent(processId, pi3, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event3);
+ addNodeToProcessInstance(pi3, "Start", "node1");
+ addNodeToProcessInstance(pi3, "End", "node4");
+
+ // Test: Find processes that DON'T have ANY of ["HR Interview",
"Technical Interview"]
+ List<ProcessInstance> result = storage.query()
+ .filter(List.of(
+ equalTo("processId", processId),
+ not(containsAny("nodes.name", List.of("HR Interview",
"Technical Interview")))))
+ .execute();
+
+ // Verify: Should return only PI3 (has neither)
+ assertThat(result).hasSize(1);
+
assertThat(result).extracting(ProcessInstance::getId).containsExactly(pi3);
+ }
+
+ /**
+ * Test: NOT with AND containing collection operations
+ * Verifies De Morgan's Law: NOT (A AND B) = (NOT A) OR (NOT B)
+ */
+ @Test
+ @Transactional
+ void testNotWithAndContainingCollectionOperations() {
+ String processId = "testProcess_" + UUID.randomUUID().toString();
+ String pi1 = UUID.randomUUID().toString();
+ String pi2 = UUID.randomUUID().toString();
+ String pi3 = UUID.randomUUID().toString();
+
+ // PI1: Has both "HR Interview" and "Technical Interview"
+ ProcessInstanceStateDataEvent event1 =
TestUtils.getProcessCloudEvent(processId, pi1, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event1);
+ addNodeToProcessInstance(pi1, "HR Interview", "node1");
+ addNodeToProcessInstance(pi1, "Technical Interview", "node2");
+
+ // PI2: Has only "HR Interview"
+ ProcessInstanceStateDataEvent event2 =
TestUtils.getProcessCloudEvent(processId, pi2, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event2);
+ addNodeToProcessInstance(pi2, "HR Interview", "node1");
+
+ // PI3: Has only "Technical Interview"
+ ProcessInstanceStateDataEvent event3 =
TestUtils.getProcessCloudEvent(processId, pi3, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event3);
+ addNodeToProcessInstance(pi3, "Technical Interview", "node2");
+
+ // Test: Find processes that DON'T have (HR Interview AND Technical
Interview)
+ List<ProcessInstance> result = storage.query()
+ .filter(List.of(
+ equalTo("processId", processId),
+ not(and(List.of(
+ contains("nodes.name", "HR Interview"),
+ contains("nodes.name", "Technical
Interview"))))))
+ .execute();
+
+ // Verify: Should return PI2 and PI3 (missing at least one)
+ assertThat(result).hasSize(2);
+
assertThat(result).extracting(ProcessInstance::getId).containsExactlyInAnyOrder(pi2,
pi3);
+ }
+
+ /**
+ * Test: NOT with OR containing collection operations
+ * Verifies De Morgan's Law: NOT (A OR B) = (NOT A) AND (NOT B)
+ */
+ @Test
+ @Transactional
+ void testNotWithOrContainingCollectionOperations() {
+ String processId = "testProcess_" + UUID.randomUUID().toString();
+ String pi1 = UUID.randomUUID().toString();
+ String pi2 = UUID.randomUUID().toString();
+ String pi3 = UUID.randomUUID().toString();
+
+ // PI1: Has "HR Interview"
+ ProcessInstanceStateDataEvent event1 =
TestUtils.getProcessCloudEvent(processId, pi1, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event1);
+ addNodeToProcessInstance(pi1, "HR Interview", "node1");
+
+ // PI2: Has "Technical Interview"
+ ProcessInstanceStateDataEvent event2 =
TestUtils.getProcessCloudEvent(processId, pi2, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event2);
+ addNodeToProcessInstance(pi2, "Technical Interview", "node2");
+
+ // PI3: Has neither
+ ProcessInstanceStateDataEvent event3 =
TestUtils.getProcessCloudEvent(processId, pi3, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event3);
+ addNodeToProcessInstance(pi3, "Start", "node3");
+
+ // Test: Find processes that DON'T have (HR Interview OR Technical
Interview)
+ List<ProcessInstance> result = storage.query()
+ .filter(List.of(
+ equalTo("processId", processId),
+ not(or(List.of(
+ contains("nodes.name", "HR Interview"),
+ contains("nodes.name", "Technical
Interview"))))))
+ .execute();
+
+ // Verify: Should return only PI3 (has neither)
+ assertThat(result).hasSize(1);
+
assertThat(result).extracting(ProcessInstance::getId).containsExactly(pi3);
+ }
+
+ /**
+ * Test: NOT with simple field (non-collection)
+ * Verifies regular NOT is used for simple fields (no NOT EXISTS overhead)
+ */
+ @Test
+ @Transactional
+ void testNotWithSimpleField() {
+ String processId1 = "hiringProcess";
+ String processId2 = "onboardingProcess";
+ String pi1 = UUID.randomUUID().toString();
+ String pi2 = UUID.randomUUID().toString();
+
+ // PI1: hiringProcess
+ ProcessInstanceStateDataEvent event1 =
TestUtils.getProcessCloudEvent(processId1, pi1, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event1);
+
+ // PI2: onboardingProcess
+ ProcessInstanceStateDataEvent event2 =
TestUtils.getProcessCloudEvent(processId2, pi2, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event2);
+
+ // Test: Find processes that are NOT "hiringProcess"
+ List<ProcessInstance> result = storage.query()
+ .filter(List.of(
+ not(equalTo("processId", processId1))))
+ .execute();
+
+ // Verify: Should return PI2
+ assertThat(result).extracting(ProcessInstance::getId).contains(pi2);
+
assertThat(result).extracting(ProcessInstance::getId).doesNotContain(pi1);
+ }
+
+ /**
+ * Test: NOT with mixed collection and simple field operations
+ * Verifies correct handling of mixed filter types
+ */
+ @Test
+ @Transactional
+ void testNotWithMixedCollectionAndSimpleFields() {
+ String processId = "testProcess_" + UUID.randomUUID().toString();
+ String pi1 = UUID.randomUUID().toString();
+ String pi2 = UUID.randomUUID().toString();
+
+ // PI1: hiringProcess with "HR Interview" node
+ ProcessInstanceStateDataEvent event1 =
TestUtils.getProcessCloudEvent(processId, pi1, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event1);
+ addNodeToProcessInstance(pi1, "HR Interview", "node1");
+
+ // PI2: hiringProcess without "HR Interview" node
+ ProcessInstanceStateDataEvent event2 =
TestUtils.getProcessCloudEvent(processId, pi2, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event2);
+ addNodeToProcessInstance(pi2, "Start", "node2");
+
+ // Test: Find processes that are NOT (processId = testProcess AND has
HR Interview node)
+ List<ProcessInstance> result = storage.query()
+ .filter(List.of(
+ not(and(List.of(
+ equalTo("processId", processId),
+ contains("nodes.name", "HR Interview"))))))
+ .execute();
+
+ // Verify: Should return PI2 and any other processes
+ assertThat(result).extracting(ProcessInstance::getId).contains(pi2);
+
assertThat(result).extracting(ProcessInstance::getId).doesNotContain(pi1);
+ }
+
+ /**
+ * Test: Backward compatibility - existing queries still work
+ * Verifies no breaking changes to existing functionality
+ */
+ @Test
+ @Transactional
+ void testBackwardCompatibilityWithExistingQueries() {
+ String processId = "testProcess_" + UUID.randomUUID().toString();
+ String pi1 = UUID.randomUUID().toString();
+
+ ProcessInstanceStateDataEvent event1 =
TestUtils.getProcessCloudEvent(processId, pi1, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event1);
+ addNodeToProcessInstance(pi1, "HR Interview", "node1");
+
+ // Test: Old-style positive query still works
+ List<ProcessInstance> result = storage.query()
+ .filter(List.of(
+ equalTo("processId", processId),
+ contains("nodes.name", "HR Interview")))
+ .execute();
+
+ // Verify: Should return PI1
+ assertThat(result).hasSize(1);
+
assertThat(result).extracting(ProcessInstance::getId).containsExactly(pi1);
+ }
+
+ /**
+ * Test: Complex nested NOT operations
+ * Verifies recursive handling of deeply nested filters
+ */
+ @Test
+ @Transactional
+ void testComplexNestedNotOperations() {
+ String processId = "testProcess_" + UUID.randomUUID().toString();
+ String pi1 = UUID.randomUUID().toString();
+ String pi2 = UUID.randomUUID().toString();
+
+ // PI1: Has "HR Interview" and "Technical Interview"
+ ProcessInstanceStateDataEvent event1 =
TestUtils.getProcessCloudEvent(processId, pi1, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event1);
+ addNodeToProcessInstance(pi1, "HR Interview", "node1");
+ addNodeToProcessInstance(pi1, "Technical Interview", "node2");
+
+ // PI2: Has only "Start"
+ ProcessInstanceStateDataEvent event2 =
TestUtils.getProcessCloudEvent(processId, pi2, ACTIVE, null, null, null,
"user1");
+ storage.indexState(event2);
+ addNodeToProcessInstance(pi2, "Start", "node3");
+
+ // Test: Complex nested NOT: NOT (NOT (has HR Interview) OR NOT (has
Technical Interview))
+ // This should return processes that have BOTH interviews
+ List<ProcessInstance> result = storage.query()
+ .filter(List.of(
+ equalTo("processId", processId),
+ not(or(List.of(
+ not(contains("nodes.name", "HR Interview")),
+ not(contains("nodes.name", "Technical
Interview")))))))
+ .execute();
+
+ // Verify: Should return only PI1 (has both)
+ assertThat(result).hasSize(1);
+
assertThat(result).extracting(ProcessInstance::getId).containsExactly(pi1);
+ }
+
+ // Helper method to add nodes to process instances
+ private void addNodeToProcessInstance(String processInstanceId, String
nodeName, String nodeId) {
+ ProcessInstanceNodeDataEvent nodeEvent =
TestUtils.createProcessInstanceNodeDataEvent(
+ processInstanceId,
+ "testProcess",
+ nodeId,
+ UUID.randomUUID().toString(),
+ nodeName,
+ "HumanTaskNode",
+ ProcessInstanceNodeEventBody.EVENT_TYPE_ENTER);
+ storage.indexNode(nodeEvent);
+ }
}
diff --git
a/persistence-commons/persistence-commons-api/src/main/java/org/kie/kogito/persistence/api/query/FilterCondition.java
b/persistence-commons/persistence-commons-api/src/main/java/org/kie/kogito/persistence/api/query/FilterCondition.java
index 5cece3ed6..cbabe11a5 100644
---
a/persistence-commons/persistence-commons-api/src/main/java/org/kie/kogito/persistence/api/query/FilterCondition.java
+++
b/persistence-commons/persistence-commons-api/src/main/java/org/kie/kogito/persistence/api/query/FilterCondition.java
@@ -55,4 +55,16 @@ public enum FilterCondition {
}
return null;
}
+
+ public boolean isCollectionOperation() {
+ return this == CONTAINS || this == CONTAINS_ALL || this ==
CONTAINS_ANY || this == EQUAL;
+ }
+
+ public boolean expectsSingleValue() {
+ return this == CONTAINS || this == EQUAL;
+ }
+
+ public boolean combineValuesWithAnd() {
+ return this != CONTAINS_ANY;
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]