Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "(not) exists" expression #670

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,38 @@
import io.agrest.converter.jsonvalue.SqlTimeConverter;
import io.agrest.converter.jsonvalue.SqlTimestampConverter;
import io.agrest.converter.jsonvalue.UtilDateConverter;
import org.apache.cayenne.Persistent;
import org.apache.cayenne.di.Inject;
import org.apache.cayenne.exp.Expression;
import org.apache.cayenne.exp.ExpressionFactory;
import org.apache.cayenne.exp.TraversalHelper;
import org.apache.cayenne.exp.parser.ASTDbPath;
import org.apache.cayenne.exp.parser.ASTExists;
import org.apache.cayenne.exp.parser.ASTNotExists;
import org.apache.cayenne.exp.parser.ASTObjPath;
import org.apache.cayenne.exp.parser.ASTPath;
import org.apache.cayenne.exp.parser.ASTSubquery;
import org.apache.cayenne.exp.parser.ConditionNode;
import org.apache.cayenne.exp.parser.Node;
import org.apache.cayenne.exp.parser.SimpleNode;
import org.apache.cayenne.map.DbJoin;
import org.apache.cayenne.map.DbRelationship;
import org.apache.cayenne.map.EntityResolver;
import org.apache.cayenne.map.ObjEntity;
import org.apache.cayenne.map.ObjRelationship;
import org.apache.cayenne.query.FluentSelect;
import org.apache.cayenne.query.ObjectSelect;

import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class CayenneExpPostProcessor implements ICayenneExpPostProcessor {

private static final String EMPTY_PATH = "";

private final EntityResolver entityResolver;
private final IPathResolver pathCache;
private final Map<String, JsonValueConverter<?>> converters;
Expand Down Expand Up @@ -73,13 +87,28 @@ private Expression validateAndCleanup(ObjEntity entity, Expression exp) {
exp = pathCache.resolve(entity.getName(), ((ASTObjPath) exp).getPath()).getPathExp();
}

// process root ASTExits|ASTNotExists that can't be properly handled by ExpressionProcessor.
if (exp instanceof ASTExists || exp instanceof ASTNotExists) {
return optimizeExistsExp(exp);
}

return exp;
}

private ExpressionProcessor getOrCreateExpressionProcessor(ObjEntity entity) {
return postProcessors.computeIfAbsent(entity.getName(), e -> new ExpressionProcessor(entity));
}

private static Expression optimizeExistsExp(Expression exp) {
Expression pathExistExp = ((Expression) exp.getOperand(0));
if (pathExistExp instanceof ASTSubquery) {
return exp;
}
return exp instanceof ASTExists
? pathExistExp
: pathExistExp.notExp();
}

private class ExpressionProcessor extends TraversalHelper {

private final ObjEntity entity;
Expand All @@ -96,7 +125,6 @@ public void startNode(Expression node, Expression parentNode) {
"Expression contains a DB_PATH expression that is not allowed here: %s",
parentNode);
}

}

@Override
Expand All @@ -113,6 +141,22 @@ public void finishedChild(Expression parentNode, int childIndex, boolean hasMore
parentNode.setOperand(childIndex, replacement);
}
}
if (parentNode instanceof ASTExists || parentNode instanceof ASTNotExists) {
m-dzianishchyts marked this conversation as resolved.
Show resolved Hide resolved
if (!(childNode instanceof ASTPath)) {
throw AgException.badRequest("%s only supports path value", parentNode.expName());
}
ObjPathMarker marker = createPathMarker(entity, (ASTPath) childNode);
Expression pathExistExp = markerToExpression(marker);
((ConditionNode) parentNode).jjtAddChild(
marker.relationship != null
? new ASTSubquery(subquery(marker.relationship, pathExistExp))
: (Node) pathExistExp,
childIndex
);
}
if (childNode instanceof ASTExists || childNode instanceof ASTNotExists) {
parentNode.setOperand(childIndex, optimizeExistsExp((Expression) childNode));
}
}

@Override
Expand Down Expand Up @@ -167,6 +211,53 @@ private Object convert(SimpleNode parentExp, JsonNode node) {
return node.asText();
}

private ObjPathMarker createPathMarker(ObjEntity entity, ASTPath o) {
String path = o.getPath();
String newPath;
String firstSegment;
int dotIndex = path.indexOf(".");
if (dotIndex == -1) {
firstSegment = path;
newPath = EMPTY_PATH;
} else {
firstSegment = path.substring(0, dotIndex);
newPath = path.substring(dotIndex + 1);
}
// mark relationship that this path relates to and transform path
ObjRelationship relationship = entity.getRelationship(firstSegment);
if (relationship == null) {
newPath = path;
}
return new ObjPathMarker(newPath, relationship);
}

private Expression markerToExpression(ObjPathMarker marker) {
// special case for an empty path
// we don't need additional qualifier, just plain exists subquery
if (marker.getPath().equals(EMPTY_PATH)) {
return null;
}
return ExpressionFactory.noMatchExp(marker, null);
}

private FluentSelect<?> subquery(ObjRelationship relationship, Expression exp) {
List<DbRelationship> dbRelationships = relationship.getDbRelationships();
for (DbRelationship dbRelationship : dbRelationships) {
for (DbJoin join : dbRelationship.getJoins()) {
Expression joinMatchExp = ExpressionFactory.matchDbExp(join.getTargetName(),
ExpressionFactory.enclosingObjectExp(ExpressionFactory.dbPathExp(join.getSourceName())));
if (exp == null) {
exp = joinMatchExp;
} else {
exp = exp.andExp(joinMatchExp);
}
}
}
return ObjectSelect.query(Persistent.class)
.dbEntityName(dbRelationships.get(0).getTargetEntityName())
.where(exp);
}

private String findPeerPath(SimpleNode exp, Object child) {

if (exp == null) {
Expand Down Expand Up @@ -215,4 +306,29 @@ private String findChildPath(Expression exp) {
return null;
}
}

static class ObjPathMarker extends ASTObjPath {

final ObjRelationship relationship;

ObjPathMarker(String path, ObjRelationship relationship) {
super(path);
this.relationship = relationship;
}

@Override
m-dzianishchyts marked this conversation as resolved.
Show resolved Hide resolved
public Expression shallowCopy() {
return new ObjPathMarker(getPath(), relationship);
}

@Override
public boolean equals(Object object) {
return this == object;
}

@Override
public int hashCode() {
return System.identityHashCode(this);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,21 @@ public Expression visit(ExpEqual node, Expression parent) {
return process(node, parent, new ASTEqual());
}

@Override
public Expression visit(ExpExists node, Expression parent) {
return process(node, parent, constructExpression(ASTExists.class));
}

@Override
public Expression visit(ExpNotEqual node, Expression data) {
return process(node, data, new ASTNotEqual());
}

@Override
public Expression visit(ExpNotExists node, Expression parent) {
return process(node, parent, constructExpression(ASTNotExists.class));
}

@Override
public Expression visit(ExpLessOrEqual node, Expression data) {
return process(node, data, new ASTLessOrEqual());
Expand Down Expand Up @@ -340,10 +350,10 @@ private Expression addToLikeNode(Expression parent, Expression child) {
// A hack - must use reflection to create Cayenne expressions, as the common int constructor is not public
// in any of them.
// TODO: refactor this in Cayenne to provide public constructors
private Expression constructExpression(Class<? extends Expression> expClass) {
Expression exp;
private <T extends Expression> T constructExpression(Class<T> expClass) {
T exp;
try {
Constructor<? extends Expression> constructor = expClass.getDeclaredConstructor(int.class);
Constructor<T> constructor = expClass.getDeclaredConstructor(int.class);
constructor.setAccessible(true);
exp = constructor.newInstance(0);
} catch (Exception e) {
Expand Down
48 changes: 48 additions & 0 deletions agrest-cayenne/src/test/java/io/agrest/cayenne/GET/ExpIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import jakarta.ws.rs.core.Configuration;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.UriInfo;

import java.time.LocalDateTime;

public class ExpIT extends MainDbTest {
Expand Down Expand Up @@ -379,6 +380,53 @@ public void like_SingleChar_Pattern_Escape() {
.wasOk().bodyEquals(1, "{\"id\":4}");
}

@Test
public void exists_Path_Relationship() {

tester.e2().insertColumns("id_", "name")
.values(1, "qwe")
.values(2, "try")
.exec();

tester.e3().insertColumns("id_", "name", "e2_id")
.values(1, "xxx", 1)
.values(2, "yxy", 2)
.values(3, "y_y", 2)
.values(4, "y_ay", null)
.exec();

tester.target("/e3")
.queryParam("include", "id")
.queryParam("exp", "exists e2")
.queryParam("sort", "id")
.get()
.wasOk()
.bodyEquals(3, "{\"id\":1}", "{\"id\":2}", "{\"id\":3}");
}

@Test
public void exists_Path_NoRelationship() {

tester.e2().insertColumns("id_", "name")
.values(1, "qwe")
.values(2, "try")
.exec();

tester.e3().insertColumns("id_", "name")
.values(1, "xxx")
.values(2, "yxy")
.values(3, null)
.exec();

tester.target("/e3")
.queryParam("include", "id")
.queryParam("exp", "exists name")
.queryParam("sort", "id")
.get()
.wasOk()
.bodyEquals(2, "{\"id\":1}", "{\"id\":2}");
}

@Test
public void like_MultiChar_Pattern_Escape() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;


public class CayenneExpressionVisitorTest {

Expand All @@ -32,6 +33,7 @@ public class CayenneExpressionVisitorTest {
"currentTimestamp()_|org.apache.cayenne.exp.parser.ASTCurrentTimestamp",
"t.value / 2_|org.apache.cayenne.exp.parser.ASTDivide",
"t.v1 = t.v2_|org.apache.cayenne.exp.parser.ASTEqual",
"exists details_|org.apache.cayenne.exp.parser.ASTExists",
"day(t.dateTime)_|org.apache.cayenne.exp.parser.ASTExtract",
"false_|org.apache.cayenne.exp.parser.ASTFalse",
"t.v > 0_|org.apache.cayenne.exp.parser.ASTGreater",
Expand All @@ -51,6 +53,7 @@ public class CayenneExpressionVisitorTest {
"!(t.a = 1 and t.b = 3)_|org.apache.cayenne.exp.parser.ASTNot",
"t.value !between 10 and 20_|org.apache.cayenne.exp.parser.ASTNotBetween",
"t.v1 != t.v2_|org.apache.cayenne.exp.parser.ASTNotEqual",
"not exists details_|org.apache.cayenne.exp.parser.ASTNotExists",
"t.v !in (0, 5)_|org.apache.cayenne.exp.parser.ASTNotIn",
"t.name !like '%s'_|org.apache.cayenne.exp.parser.ASTNotLike",
"t.name !likeIgnoreCase '%s'_|org.apache.cayenne.exp.parser.ASTNotLikeIgnoreCase",
Expand Down Expand Up @@ -86,7 +89,7 @@ public void accept_ReturnedType(String agrestExp, Class<? extends Expression> ca
"a not likeIgnoreCase 'bcd' escape '$'"})
public void accept_escapeChar(String agrestExp) {
Expression cayenneExp = Exp.parse(agrestExp).accept(visitor, null);
assertTrue(cayenneExp instanceof PatternMatchNode);
assertInstanceOf(PatternMatchNode.class, cayenneExp);
PatternMatchNode matchNode = (PatternMatchNode) cayenneExp;
assertEquals('$', matchNode.getEscapeChar());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.agrest.cayenne.processor;

import io.agrest.access.PathChecker;
import io.agrest.cayenne.cayenne.main.E3;
import io.agrest.id.AgObjectId;
import io.agrest.AgRequestBuilder;
import io.agrest.RootResourceEntity;
Expand All @@ -13,6 +14,9 @@
import io.agrest.runtime.meta.RequestSchema;
import io.agrest.runtime.processor.select.SelectContext;
import org.apache.cayenne.di.Injector;
import org.apache.cayenne.exp.Expression;
import org.apache.cayenne.exp.ExpressionException;
import org.apache.cayenne.exp.ExpressionFactory;
import org.apache.cayenne.query.ObjectSelect;
import org.junit.jupiter.api.Test;

Expand Down Expand Up @@ -103,6 +107,46 @@ public void createRootQuery_Qualifier() {
assertEquals(E1.NAME.eq("X").andExp(E1.NAME.in("a", "b")), q2.getWhere());
}

@Test
public void createRootQuery_Qualifier_Exists() {
RootResourceEntity<E3> entity = getResourceEntity(E3.class);

SelectContext<E3> c = new SelectContext<>(
E3.class,
new RequestSchema(mock(AgSchema.class)),
mock(AgRequestBuilder.class),
PathChecker.ofDefault(),
mock(Injector.class));

c.setEntity(entity);

entity.andExp(Exp.parse("exists e2"));
ObjectSelect<E3> q1 = queryAssembler.createRootQuery(c);
Expression expectedQ1 = ExpressionFactory.exists(ObjectSelect.query(E3.class).column(E3.E2));
assertEquals(expectedQ1, q1.getWhere());

entity.andExp(Exp.parse("not exists e5"));
ObjectSelect<E3> q2 = queryAssembler.createRootQuery(c);
assertEquals(expectedQ1.andExp(ExpressionFactory.notExists(ObjectSelect.query(E3.class).column(E3.E5))), q2.getWhere());
}

@Test
public void createRootQuery_Qualifier_Exists_Invalid_Condition() {
RootResourceEntity<E3> entity = getResourceEntity(E3.class);

SelectContext<E3> c = new SelectContext<>(
E3.class,
new RequestSchema(mock(AgSchema.class)),
mock(AgRequestBuilder.class),
PathChecker.ofDefault(),
mock(Injector.class));

c.setEntity(entity);

entity.andExp(Exp.exists("name = 'test1'"));
assertThrows(ExpressionException.class, () -> queryAssembler.createRootQuery(c));
}
m-dzianishchyts marked this conversation as resolved.
Show resolved Hide resolved

@Test
public void createRootQuery_ById() {

Expand Down
Loading
Loading