diff --git a/link-rest-sencha/src/main/java/com/nhl/link/rest/sencha/SenchaEncoderService.java b/link-rest-sencha/src/main/java/com/nhl/link/rest/sencha/SenchaEncoderService.java index fc5566b61..3e8670dce 100644 --- a/link-rest-sencha/src/main/java/com/nhl/link/rest/sencha/SenchaEncoderService.java +++ b/link-rest-sencha/src/main/java/com/nhl/link/rest/sencha/SenchaEncoderService.java @@ -3,7 +3,12 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.nhl.link.rest.EntityProperty; import com.nhl.link.rest.ResourceEntity; -import com.nhl.link.rest.encoder.*; +import com.nhl.link.rest.encoder.CollectionEncoder; +import com.nhl.link.rest.encoder.DataResponseEncoder; +import com.nhl.link.rest.encoder.Encoder; +import com.nhl.link.rest.encoder.EncoderFilter; +import com.nhl.link.rest.encoder.GenericEncoder; +import com.nhl.link.rest.encoder.PropertyMetadataEncoder; import com.nhl.link.rest.meta.LrRelationship; import com.nhl.link.rest.runtime.encoder.EncoderService; import com.nhl.link.rest.runtime.encoder.IAttributeEncoderFactory; @@ -28,8 +33,7 @@ public SenchaEncoderService(@Inject List filters, } @Override - public Encoder dataEncoder(ResourceEntity entity) { - CollectionEncoder resultEncoder = resultEncoder(entity); + protected DataResponseEncoder toDataResponseEncoder(CollectionEncoder resultEncoder) { return new DataResponseEncoder("data", resultEncoder, "total", GenericEncoder.encoder()) { @Override protected void encodeObjectBody(Object object, JsonGenerator out) throws IOException { diff --git a/link-rest-sencha/src/test/java/com/nhl/link/rest/sencha/SenchaEncoderServiceTest.java b/link-rest-sencha/src/test/java/com/nhl/link/rest/sencha/SenchaEncoderServiceTest.java index 8bf2f3c91..aaa5244f8 100644 --- a/link-rest-sencha/src/test/java/com/nhl/link/rest/sencha/SenchaEncoderServiceTest.java +++ b/link-rest-sencha/src/test/java/com/nhl/link/rest/sencha/SenchaEncoderServiceTest.java @@ -98,6 +98,7 @@ public boolean willEncode(String propertyName, Object object, Encoder delegate) ResourceEntity e2Descriptor = getResourceEntity(E2.class); e2Descriptor.includeId(); + e2Descriptor.setIncoming(metadataService.getLrRelationship(E3.class, E3.E2.getName())); ResourceEntity e3Descriptor = getResourceEntity(E3.class); e3Descriptor.includeId(); diff --git a/link-rest/src/main/java/com/nhl/link/rest/AggregationType.java b/link-rest/src/main/java/com/nhl/link/rest/AggregationType.java new file mode 100644 index 000000000..4f5a86dce --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/AggregationType.java @@ -0,0 +1,22 @@ +package com.nhl.link.rest; + +public enum AggregationType { + + AVERAGE("avg"), + + SUM("sum"), + + MINIMUM("min"), + + MAXIMUM("max"); + + private String functionName; + + AggregationType(String functionName) { + this.functionName = functionName; + } + + public String functionName() { + return functionName; + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/ResourceEntity.java b/link-rest/src/main/java/com/nhl/link/rest/ResourceEntity.java index 6271ccdb3..db2b70aa4 100644 --- a/link-rest/src/main/java/com/nhl/link/rest/ResourceEntity.java +++ b/link-rest/src/main/java/com/nhl/link/rest/ResourceEntity.java @@ -27,15 +27,19 @@ public class ResourceEntity { private boolean idIncluded; + private boolean countIncluded; private LrEntity lrEntity; private Map attributes; private Collection defaultProperties; + private Map> aggregatedAttributes; + private String applicationBase; private String mapByPath; private ResourceEntity mapBy; private Map> children; + private Map> aggregateChildren; private LrRelationship incoming; private List orderings; private Expression qualifier; @@ -48,7 +52,9 @@ public ResourceEntity(LrEntity lrEntity) { this.idIncluded = false; this.attributes = new HashMap<>(); this.defaultProperties = new HashSet<>(); + this.aggregatedAttributes = new HashMap<>(); this.children = new HashMap<>(); + this.aggregateChildren = new HashMap<>(); this.orderings = new ArrayList<>(2); this.extraProperties = new HashMap<>(); this.lrEntity = lrEntity; @@ -70,6 +76,10 @@ public LrRelationship getIncoming() { return incoming; } + public void setIncoming(LrRelationship incoming) { + this.incoming = incoming; + } + public Expression getQualifier() { return qualifier; } @@ -128,6 +138,10 @@ public ResourceEntity getChild(String name) { return children.get(name); } + public Map> getAggregateChildren() { + return aggregateChildren; + } + public Map getExtraProperties() { return extraProperties; } @@ -238,4 +252,30 @@ public boolean isFiltered() { public void setFiltered(boolean filtered) { this.filtered = filtered; } + + public void includeCount() { + this.countIncluded = true; + } + + public boolean isCountIncluded() { + return countIncluded; + } + + public List getAggregatedAttributes(AggregationType aggregationType) { + return aggregatedAttributes.computeIfAbsent(aggregationType, it -> new ArrayList<>()); + } + + public boolean isAggregate() { + if (countIncluded) { + return true; + } + + for (Collection attributes : aggregatedAttributes.values()) { + if (attributes.size() > 0) { + return true; + } + } + + return false; + } } diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/CayenneAssembleQueryStage.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/CayenneAssembleQueryStage.java index f9c60c621..12a5590e8 100644 --- a/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/CayenneAssembleQueryStage.java +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/CayenneAssembleQueryStage.java @@ -1,28 +1,30 @@ package com.nhl.link.rest.runtime.cayenne.processor.select; +import com.nhl.link.rest.AggregationType; import com.nhl.link.rest.LinkRestException; -import com.nhl.link.rest.LrObjectId; import com.nhl.link.rest.ResourceEntity; import com.nhl.link.rest.meta.LrAttribute; -import com.nhl.link.rest.meta.LrEntity; -import com.nhl.link.rest.meta.LrPersistentAttribute; import com.nhl.link.rest.meta.LrPersistentEntity; +import com.nhl.link.rest.meta.LrRelationship; import com.nhl.link.rest.processor.Processor; import com.nhl.link.rest.processor.ProcessorOutcome; +import com.nhl.link.rest.property.DataObjectPropertyReader; +import com.nhl.link.rest.property.PropertyReader; import com.nhl.link.rest.runtime.cayenne.ICayennePersister; +import com.nhl.link.rest.runtime.encoder.IEncoderService; import com.nhl.link.rest.runtime.processor.select.SelectContext; import org.apache.cayenne.di.Inject; import org.apache.cayenne.exp.Expression; -import org.apache.cayenne.exp.ExpressionFactory; +import org.apache.cayenne.exp.Property; import org.apache.cayenne.map.EntityResolver; import org.apache.cayenne.query.Ordering; import org.apache.cayenne.query.PrefetchTreeNode; import org.apache.cayenne.query.SelectQuery; -import javax.ws.rs.core.Response; -import java.util.ArrayList; -import java.util.Collection; +import javax.ws.rs.core.Response.Status; +import java.util.ListIterator; import java.util.Map; +import java.util.Optional; /** * @since 2.7 @@ -30,9 +32,12 @@ public class CayenneAssembleQueryStage implements Processor> { private EntityResolver entityResolver; + private Optional encoderService; - public CayenneAssembleQueryStage(@Inject ICayennePersister persister) { + public CayenneAssembleQueryStage(@Inject ICayennePersister persister, + @Inject IEncoderService encoderService) { this.entityResolver = persister.entityResolver(); + this.encoderService = Optional.ofNullable(encoderService); // can be absent in tests } @Override @@ -43,32 +48,46 @@ public ProcessorOutcome execute(SelectContext context) { protected void doExecute(SelectContext context) { context.setSelect(buildQuery(context)); + + // create a new encoder, based on augmented entity (possibly overriding the custom encoder, which is bad) + encoderService.ifPresent(service -> { + context.setEncoder(service.dataEncoder(context.getEntity())); + }); } SelectQuery buildQuery(SelectContext context) { ResourceEntity entity = context.getEntity(); - SelectQuery query = basicSelect(context); + QueryBuilder query = new QueryBuilder<>(context); + + if (appendAggregateColumns(entity, query, null)) { + appendGroupByColumns(entity, query, null); + if (!entity.isAggregate() && !hasGroupByColumns(entity)) { + query.includeSelf(); + swapAttributeReadersToSelf(entity); + swapChildrenToSelf(entity); + } + } if (!entity.isFiltered()) { int limit = context.getEntity().getFetchLimit(); if (limit > 0) { - query.setPageSize(limit); + query.pageSize(limit); } } if (context.getParent() != null) { Expression qualifier = context.getParent().qualifier(entityResolver); - query.andQualifier(qualifier); + query.qualifier(qualifier); } if (entity.getQualifier() != null) { - query.andQualifier(entity.getQualifier()); + query.qualifier(entity.getQualifier()); } for (Ordering o : entity.getOrderings()) { - query.addOrdering(o); + query.ordering(o); } if (!entity.getChildren().isEmpty()) { @@ -83,51 +102,189 @@ SelectQuery buildQuery(SelectContext context) { } appendPrefetches(root, entity, prefetchSemantics); - query.setPrefetchTree(root); + query.prefetch(root); } - return query; + return query.buildQuery(); } - SelectQuery basicSelect(SelectContext context) { + private boolean hasGroupByColumns(ResourceEntity entity) { + for (Map.Entry e : entity.getAttributes().entrySet()) { + LrAttribute attribute = e.getValue(); + if (!entity.isDefault(attribute.getName())) { + return true; + } + } + return false; + } + + /** + * @return true if some of the resource entities is aggregate, + * which means that all explicit includes should be treated as GROUP BY columns + */ + @SuppressWarnings("unchecked") + private boolean appendAggregateColumns(ResourceEntity entity, QueryBuilder query, Property context) { + boolean shouldAppendGroupByColumns = false; - // selecting by ID overrides any explicit SelectQuery... - if (context.isById()) { + if (entity.isAggregate()) { + shouldAppendGroupByColumns = true; + + for (AggregationType aggregationType : AggregationType.values()) { + ListIterator iter = entity.getAggregatedAttributes(aggregationType).listIterator(); + while (iter.hasNext()) { + LrAttribute attribute = iter.next(); + Property property = createProperty(context, attribute.getName(), attribute.getType()); + switch (aggregationType) { + case AVERAGE: { + query.avg(property); + break; + } + case SUM: { + query.sum(castProperty(property, Number.class)); + break; + } + case MINIMUM: { + query.min(property); + break; + } + case MAXIMUM: { + query.max(property); + break; + } + default: { + throw new LinkRestException(Status.INTERNAL_SERVER_ERROR, + "Unsupported aggregation type: " + aggregationType.name()); + } + } + + iter.set(currentColumnAttribute(attribute, query)); + } + } + } - Class root = context.getType(); - SelectQuery query = new SelectQuery<>(root); - query.andQualifier(buildIdQualifer(context.getEntity().getLrEntity(), context.getId())); - return query; + for (Map.Entry> e : entity.getAggregateChildren().entrySet()) { + String relationshipName = e.getKey(); + ResourceEntity child = e.getValue(); + Property relationship = createProperty(context, relationshipName, child.getType()); + shouldAppendGroupByColumns = shouldAppendGroupByColumns || appendAggregateColumns(child, query, relationship); } - return context.getSelect() != null ? context.getSelect() : new SelectQuery<>(context.getType()); + return shouldAppendGroupByColumns; } - private Expression buildIdQualifer(LrEntity entity, LrObjectId id) { + @SuppressWarnings("unchecked") + private void appendGroupByColumns(ResourceEntity entity, QueryBuilder query, Property context) { + for (Map.Entry e : entity.getAttributes().entrySet()) { + LrAttribute attribute = e.getValue(); + if (!entity.isDefault(attribute.getName())) { + Property property = createProperty(context, attribute.getName(), attribute.getType()); + query.column(property); - Collection idAttributes = entity.getIds(); - if (idAttributes.size() != id.size()) { - throw new LinkRestException(Response.Status.BAD_REQUEST, - "Wrong ID size: expected " + idAttributes.size() + ", got: " + id.size()); + e.setValue(currentColumnAttribute(attribute, query)); + } } - Collection qualifiers = new ArrayList<>(); - for (LrAttribute idAttribute : idAttributes) { - Object idValue = id.get(idAttribute.getName()); - if (idValue == null) { - throw new LinkRestException(Response.Status.BAD_REQUEST, - "Failed to build a Cayenne qualifier for entity " + entity.getName() - + ": one of the entity's ID parts is missing in this ID: " + idAttribute.getName()); - } - if (idAttribute instanceof LrPersistentAttribute) { - qualifiers.add(ExpressionFactory.matchDbExp( - ((LrPersistentAttribute) idAttribute).getColumnName(), idValue)); + // do this after all attributes have been added, because we'll add one more fictional attribute for encoding purposes + if (entity.isCountIncluded()) { + if (context == null) { + query.count(); } else { - // can be non-persistent attribute if assembled from @LrId by LrEntityBuilder - qualifiers.add(ExpressionFactory.matchDbExp(idAttribute.getName(), idValue)); + query.count(context); + } + entity.getAttributes().put("count()", currentColumnAttribute(CountAttribute.instance(), query)); + } + + // this method is called only when there is aggregation, but it's not known in which subtree, so need to track + + entity.getChildren().forEach((relationshipName, child) -> { + Property relationship = createProperty(context, relationshipName, child.getType()); + appendGroupByColumns(child, query, relationship); + }); + + entity.getAggregateChildren().forEach((relationshipName, child) -> { + Property relationship = createProperty(context, relationshipName, child.getType()); + appendGroupByColumns(child, query, relationship); + }); + } + + private static void swapAttributeReadersToSelf(ResourceEntity entity) { + for (Map.Entry e : entity.getAttributes().entrySet()) { + // prevent double swap, which can happen, depending on when this method is called + if (!(e.getValue() instanceof DecoratedLrAttribute)) { + e.setValue(selfAttribute(e.getValue())); } } - return ExpressionFactory.and(qualifiers); + } + + private static void swapChildrenToSelf(ResourceEntity entity) { + entity.getChildren().values().forEach(CayenneAssembleQueryStage::swapRelationshipReaderToSelf); + entity.getAggregateChildren().values().forEach(CayenneAssembleQueryStage::swapRelationshipReaderToSelf); + } + + private static void swapRelationshipReaderToSelf(ResourceEntity child) { + LrRelationship incoming = child.getIncoming(); + // prevent double swap, which can happen, depending on when this method is called + if (incoming != null && !(incoming instanceof DecoratedLrRelationship)) { + child.setIncoming(selfRelationship(incoming)); + } + } + + @SuppressWarnings("unchecked") + private static Property createProperty(Property context, String name, Class type) { + Property property = Property.create(name, (Class) type); + if (context != null) { + property = context.dot(property); + } + return property; + } + + @SuppressWarnings("unchecked") + private static Property castProperty(Property property, Class type) { + if (!type.isAssignableFrom(property.getType())) { + throw new LinkRestException(Status.BAD_REQUEST, + "Property '" + property.getName() + "' can not be cast to Property<" + type.getSimpleName() + ">"); + } + return (Property) property; + } + + private static LrAttribute currentColumnAttribute(LrAttribute attribute, QueryBuilder query) { + int columnIndex = query.columnCount() - 1; // use current column + PropertyReader reader = PropertyReader.forValueProducer((Object[] row) -> { + if (query.isSelfIncluded()) { + return row[columnIndex + 1]; + } else { + return row[columnIndex]; + } + }); + return decoratedAttribute(attribute, reader); + } + + private static LrAttribute selfAttribute(LrAttribute attribute) { + PropertyReader delegate = (attribute.getPropertyReader() == null) ? + DataObjectPropertyReader.reader() : attribute.getPropertyReader(); + PropertyReader reader = (root, name) -> { + Object[] row = (Object[]) root; + return delegate.value(row[0], name); + }; + return decoratedAttribute(attribute, reader); + } + + private static LrRelationship selfRelationship(LrRelationship relationship) { + PropertyReader delegate = (relationship.getPropertyReader() == null) ? + DataObjectPropertyReader.reader() : relationship.getPropertyReader(); + PropertyReader reader = (root, name) -> { + Object[] row = (Object[]) root; + return delegate.value(row[0], name); + }; + return decoratedRelationship(relationship, reader); + } + + private static LrAttribute decoratedAttribute(LrAttribute delegate, PropertyReader reader) { + return new DecoratedLrAttribute(delegate, reader); + } + + private static LrRelationship decoratedRelationship(LrRelationship delegate, PropertyReader reader) { + return new DecoratedLrRelationship(delegate, reader); } private void appendPrefetches(PrefetchTreeNode root, ResourceEntity entity, int prefetchSemantics) { @@ -151,4 +308,8 @@ private void appendPrefetches(PrefetchTreeNode root, ResourceEntity entity, i appendPrefetches(root, entity.getMapBy(), prefetchSemantics); } } + + SelectQuery basicSelect(SelectContext context) { + return new QueryBuilder<>(context).buildQuery(); + } } diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/CountAttribute.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/CountAttribute.java new file mode 100644 index 000000000..93c46a91e --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/CountAttribute.java @@ -0,0 +1,34 @@ +package com.nhl.link.rest.runtime.cayenne.processor.select; + +import com.nhl.link.rest.meta.LrAttribute; +import com.nhl.link.rest.property.PropertyReader; +import org.apache.cayenne.exp.parser.ASTPath; + +class CountAttribute implements LrAttribute { + + private static final CountAttribute instance = new CountAttribute(); + + public static CountAttribute instance() { + return instance; + } + + @Override + public String getName() { + return "count()"; + } + + @Override + public Class getType() { + return Long.class; + } + + @Override + public ASTPath getPathExp() { + return null; + } + + @Override + public PropertyReader getPropertyReader() { + return null; + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/DecoratedLrAttribute.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/DecoratedLrAttribute.java new file mode 100644 index 000000000..b07c2e644 --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/DecoratedLrAttribute.java @@ -0,0 +1,36 @@ +package com.nhl.link.rest.runtime.cayenne.processor.select; + +import com.nhl.link.rest.meta.LrAttribute; +import com.nhl.link.rest.property.PropertyReader; +import org.apache.cayenne.exp.parser.ASTPath; + +class DecoratedLrAttribute implements LrAttribute { + + private LrAttribute delegate; + private PropertyReader reader; + + public DecoratedLrAttribute(LrAttribute delegate, PropertyReader reader) { + this.delegate = delegate; + this.reader = reader; + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public Class getType() { + return delegate.getType(); + } + + @Override + public ASTPath getPathExp() { + return delegate.getPathExp(); + } + + @Override + public PropertyReader getPropertyReader() { + return reader; + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/DecoratedLrRelationship.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/DecoratedLrRelationship.java new file mode 100644 index 000000000..f932fb2de --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/DecoratedLrRelationship.java @@ -0,0 +1,36 @@ +package com.nhl.link.rest.runtime.cayenne.processor.select; + +import com.nhl.link.rest.meta.LrEntity; +import com.nhl.link.rest.meta.LrRelationship; +import com.nhl.link.rest.property.PropertyReader; + +class DecoratedLrRelationship implements LrRelationship { + + private LrRelationship delegate; + private PropertyReader reader; + + public DecoratedLrRelationship(LrRelationship delegate, PropertyReader reader) { + this.delegate = delegate; + this.reader = reader; + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public LrEntity getTargetEntity() { + return delegate.getTargetEntity(); + } + + @Override + public boolean isToMany() { + return delegate.isToMany(); + } + + @Override + public PropertyReader getPropertyReader() { + return reader; + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/QueryBuilder.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/QueryBuilder.java new file mode 100644 index 000000000..d66558711 --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/cayenne/processor/select/QueryBuilder.java @@ -0,0 +1,159 @@ +package com.nhl.link.rest.runtime.cayenne.processor.select; + +import com.nhl.link.rest.LinkRestException; +import com.nhl.link.rest.LrObjectId; +import com.nhl.link.rest.meta.LrAttribute; +import com.nhl.link.rest.meta.LrEntity; +import com.nhl.link.rest.meta.LrPersistentAttribute; +import com.nhl.link.rest.runtime.processor.select.SelectContext; +import org.apache.cayenne.Persistent; +import org.apache.cayenne.exp.Expression; +import org.apache.cayenne.exp.ExpressionFactory; +import org.apache.cayenne.exp.Property; +import org.apache.cayenne.query.Ordering; +import org.apache.cayenne.query.PrefetchTreeNode; +import org.apache.cayenne.query.SelectQuery; + +import javax.ws.rs.core.Response.Status; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +public class QueryBuilder { + + private SelectQuery query; + private boolean selfIncluded; + + public QueryBuilder(SelectContext context) { + Class root = context.getType(); + // use existing query or create a new one + SelectQuery query = context.getSelect(); + // selecting by ID overrides any explicit SelectQuery... + if (query == null || context.isById()) { + query = new SelectQuery<>(root); + query.setColumns(new ArrayList<>()); + if (context.isById()) { + query.setQualifier((buildIdQualifer(context.getEntity().getLrEntity(), context.getId()))); + } + } + this.query = query; + } + + private Expression buildIdQualifer(LrEntity entity, LrObjectId id) { + Collection idAttributes = entity.getIds(); + if (idAttributes.size() != id.size()) { + throw new LinkRestException(Status.BAD_REQUEST, + "Wrong ID size: expected " + idAttributes.size() + ", got: " + id.size()); + } + + Collection qualifiers = new ArrayList<>(); + for (LrAttribute idAttribute : idAttributes) { + Object idValue = id.get(idAttribute.getName()); + if (idValue == null) { + throw new LinkRestException(Status.BAD_REQUEST, + "Failed to build a Cayenne qualifier for entity " + entity.getName() + + ": one of the entity's ID parts is missing in this ID: " + idAttribute.getName()); + } + if (idAttribute instanceof LrPersistentAttribute) { + qualifiers.add(ExpressionFactory.matchDbExp( + ((LrPersistentAttribute) idAttribute).getColumnName(), idValue)); + } else { + // can be non-persistent attribute if assembled from @LrId by LrEntityBuilder + qualifiers.add(ExpressionFactory.matchDbExp(idAttribute.getName(), idValue)); + } + } + return ExpressionFactory.and(qualifiers); + } + + public QueryBuilder pageSize(int pageSize) { + query.setPageSize(pageSize); + return this; + } + + public QueryBuilder qualifier(Expression expression) { + query.andQualifier(expression); + return this; + } + + public QueryBuilder ordering(Ordering ordering) { + query.addOrdering(ordering); + return this; + } + + public QueryBuilder prefetch(PrefetchTreeNode prefetch) { + query.addPrefetch(prefetch); + return this; + } + + public QueryBuilder count() { + column(Property.COUNT); + return this; + } + + public QueryBuilder count(Property property) { + column(property.count()); + return this; + } + + @SuppressWarnings("unchecked") + public QueryBuilder avg(Property property) { + column(property.avg()); + return this; + } + + @SuppressWarnings("unchecked") + public QueryBuilder sum(Property property) { + column(property.sum()); + return this; + } + + @SuppressWarnings("unchecked") + public QueryBuilder min(Property property) { + column(property.min()); + return this; + } + + @SuppressWarnings("unchecked") + public QueryBuilder max(Property property) { + column(property.max()); + return this; + } + + public QueryBuilder column(Property property) { + query.getColumns().add(property); + return this; + } + + @SuppressWarnings("unchecked") + public QueryBuilder includeSelf() { + selfIncluded = true; + + Property self = Property.createSelf((Class) query.getRoot()); + + List> columns = (List>) query.getColumns(); + if (columns.isEmpty()) { + columns.add(self); + } else { + Property[] array = columns.toArray(new Property[columns.size() + 1]); + System.arraycopy(array, 0, array, 1, columns.size()); + array[0] = self; + query.setColumns(new ArrayList<>(Arrays.asList(array))); + } + + return this; + } + + public int columnCount() { + return Objects.requireNonNull(query.getColumns()).size(); + } + + public boolean isSelfIncluded() { + return selfIncluded; + } + + public SelectQuery buildQuery() { + return query; + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/encoder/AttributeEncoderFactory.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/encoder/AttributeEncoderFactory.java index c69543954..725749f84 100644 --- a/link-rest/src/main/java/com/nhl/link/rest/runtime/encoder/AttributeEncoderFactory.java +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/encoder/AttributeEncoderFactory.java @@ -3,13 +3,6 @@ import com.nhl.link.rest.EntityProperty; import com.nhl.link.rest.ResourceEntity; import com.nhl.link.rest.encoder.Encoder; -import com.nhl.link.rest.encoder.GenericEncoder; -import com.nhl.link.rest.encoder.ISODateEncoder; -import com.nhl.link.rest.encoder.ISODateTimeEncoder; -import com.nhl.link.rest.encoder.ISOLocalDateEncoder; -import com.nhl.link.rest.encoder.ISOLocalDateTimeEncoder; -import com.nhl.link.rest.encoder.ISOLocalTimeEncoder; -import com.nhl.link.rest.encoder.ISOTimeEncoder; import com.nhl.link.rest.encoder.IdEncoder; import com.nhl.link.rest.meta.LrAttribute; import com.nhl.link.rest.meta.LrEntity; @@ -66,15 +59,9 @@ public AttributeEncoderFactory(Map, Encoder> knownEncoders, @Override public EntityProperty getAttributeProperty(LrEntity entity, LrAttribute attribute) { - String key = entity.getName() + "." + attribute.getName(); - - EntityProperty property = attributePropertiesByPath.get(key); - if (property == null) { - property = buildAttributeProperty(entity, attribute); - attributePropertiesByPath.put(key, property); - } - - return property; + // can't cache encoders for attributes, because we're using ad-hoc "decorated" attributes for aggregation purposes + // e.g. see com.nhl.link.rest.runtime.cayenne.processor.select.CayenneAssembleQueryStage#currentColumnAttribute() + return buildAttributeProperty(entity, attribute); } @Override diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/encoder/EncoderService.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/encoder/EncoderService.java index 35bc0878f..c96beb3d2 100644 --- a/link-rest/src/main/java/com/nhl/link/rest/runtime/encoder/EncoderService.java +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/encoder/EncoderService.java @@ -1,14 +1,30 @@ package com.nhl.link.rest.runtime.encoder; +import com.fasterxml.jackson.core.JsonGenerator; +import com.nhl.link.rest.AggregationType; import com.nhl.link.rest.EntityProperty; import com.nhl.link.rest.ResourceEntity; -import com.nhl.link.rest.encoder.*; +import com.nhl.link.rest.encoder.CollectionEncoder; +import com.nhl.link.rest.encoder.DataResponseEncoder; +import com.nhl.link.rest.encoder.Encoder; +import com.nhl.link.rest.encoder.EncoderFilter; +import com.nhl.link.rest.encoder.EncoderVisitor; +import com.nhl.link.rest.encoder.EntityEncoder; +import com.nhl.link.rest.encoder.EntityMetadataEncoder; +import com.nhl.link.rest.encoder.EntityToOneEncoder; +import com.nhl.link.rest.encoder.FilterChainEncoder; +import com.nhl.link.rest.encoder.GenericEncoder; +import com.nhl.link.rest.encoder.ListEncoder; +import com.nhl.link.rest.encoder.MapByEncoder; +import com.nhl.link.rest.encoder.PropertyMetadataEncoder; +import com.nhl.link.rest.encoder.ResourceEncoder; import com.nhl.link.rest.meta.LrAttribute; import com.nhl.link.rest.meta.LrRelationship; import com.nhl.link.rest.property.PropertyBuilder; import com.nhl.link.rest.runtime.semantics.IRelationshipMapper; import org.apache.cayenne.di.Inject; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -27,7 +43,8 @@ public class EncoderService implements IEncoderService { public EncoderService(@Inject List filters, @Inject IAttributeEncoderFactory attributeEncoderFactory, - @Inject IStringConverterFactory stringConverterFactory, @Inject IRelationshipMapper relationshipMapper, + @Inject IStringConverterFactory stringConverterFactory, + @Inject IRelationshipMapper relationshipMapper, @Inject Map propertyMetadataEncoders) { this.attributeEncoderFactory = attributeEncoderFactory; this.relationshipMapper = relationshipMapper; @@ -44,18 +61,25 @@ public Encoder metadataEncoder(ResourceEntity entity) { @Override public Encoder dataEncoder(ResourceEntity entity) { - CollectionEncoder resultEncoder = resultEncoder(entity); + return buildDataEncoder(entity, entityEncoder(entity)); + } + + private Encoder buildDataEncoder(ResourceEntity entity, Encoder entityEncoder) { + CollectionEncoder resultEncoder = resultEncoder(entity, entityEncoder); + return toDataResponseEncoder(resultEncoder); + } + + protected DataResponseEncoder toDataResponseEncoder(CollectionEncoder resultEncoder) { return new DataResponseEncoder("data", resultEncoder, "total", GenericEncoder.encoder()); } - protected CollectionEncoder resultEncoder(ResourceEntity entity) { - Encoder elementEncoder = collectionElementEncoder(entity); + protected CollectionEncoder resultEncoder(ResourceEntity entity, Encoder elementEncoder) { boolean isMapBy = entity.getMapBy() != null; // notice that we are not passing either qualifier or ordering to the encoder, as those are presumably applied // at the query level.. (unlike with #nestedToManyEncoder) - CollectionEncoder encoder = new ListEncoder(elementEncoder) + CollectionEncoder encoder = new ListEncoder(filteredEncoder(elementEncoder, entity)) .withOffset(entity.getFetchOffset()) .withLimit(entity.getFetchLimit()) .shouldFilter(entity.isFiltered()); @@ -73,16 +97,17 @@ protected CollectionEncoder resultEncoder(ResourceEntity entity) { protected Encoder nestedToManyEncoder(ResourceEntity resourceEntity) { - Encoder elementEncoder = collectionElementEncoder(resourceEntity); + Encoder elementEncoder = entityEncoder(resourceEntity); boolean isMapBy = resourceEntity.getMapBy() != null; - // if mapBy is involved, apply filters at MapBy level, not inside sublists... + // if mapBy is involved, apply filters at MapBy level, not inside + // sublists... ListEncoder listEncoder = new ListEncoder( - elementEncoder, + filteredEncoder(elementEncoder, resourceEntity), isMapBy ? null : resourceEntity.getQualifier(), - resourceEntity.getOrderings()) - .withOffset(resourceEntity.getFetchOffset()) - .withLimit(resourceEntity.getFetchLimit()); + resourceEntity.getOrderings()); + + listEncoder.withOffset(resourceEntity.getFetchOffset()).withLimit(resourceEntity.getFetchLimit()); if (resourceEntity.isFiltered()) { listEncoder.shouldFilter(); @@ -99,11 +124,6 @@ protected Encoder nestedToManyEncoder(ResourceEntity resourceEntity) { : listEncoder; } - protected Encoder collectionElementEncoder(ResourceEntity resourceEntity) { - Encoder encoder = entityEncoder(resourceEntity); - return filteredEncoder(encoder, resourceEntity); - } - protected Encoder toOneEncoder(ResourceEntity resourceEntity, LrRelationship relationship) { // to-one encoder is made of the following decorator layers (from outer @@ -132,7 +152,7 @@ protected Encoder entityEncoder(ResourceEntity resourceEntity) { // ensure we sort property encoders alphabetically for cleaner JSON // output - Map attributeEncoders = new TreeMap(); + Map attributeEncoders = new TreeMap<>(); for (LrAttribute attribute : resourceEntity.getAttributes().values()) { EntityProperty property = attributeEncoderFactory.getAttributeProperty(resourceEntity.getLrEntity(), @@ -140,24 +160,46 @@ protected Encoder entityEncoder(ResourceEntity resourceEntity) { attributeEncoders.put(attribute.getName(), property); } - Map relationshipEncoders = new TreeMap(); - for (Entry> e : resourceEntity.getChildren().entrySet()) { - LrRelationship relationship = resourceEntity.getLrEntity().getRelationship(e.getKey()); - - Encoder encoder = relationship.isToMany() ? nestedToManyEncoder(e.getValue()) - : toOneEncoder(e.getValue(), relationship); + for (AggregationType aggregationType : AggregationType.values()) { + resourceEntity.getAggregatedAttributes(aggregationType).forEach(attribute -> { + EntityProperty property = attributeEncoderFactory.getAttributeProperty(resourceEntity.getLrEntity(), + attribute); + String key = toFunctionName(aggregationType, attribute.getName()); + attributeEncoders.put(key, property); + }); + } - EntityProperty property = attributeEncoderFactory.getRelationshipProperty(resourceEntity.getLrEntity(), - relationship, encoder); - relationshipEncoders.put(e.getKey(), property); + Map relationshipEncoders = new TreeMap<>(); + for (Entry> e : resourceEntity.getChildren().entrySet()) { + ResourceEntity child = e.getValue(); + + // TODO: same when the parent's parent is aggregate (need to pass context throughout the hierarchy) + if (resourceEntity.isAggregate()) { + String propertyName = e.getKey(); + relationshipEncoders.put(propertyName, new PropertyEncoder(entityEncoder(child), propertyName)); + + } else { + LrRelationship relationship = child.getIncoming(); + Encoder encoder = relationship.isToMany() ? nestedToManyEncoder(e.getValue()) + : toOneEncoder(e.getValue(), relationship); + EntityProperty property = attributeEncoderFactory.getRelationshipProperty(resourceEntity.getLrEntity(), + relationship, encoder); + relationshipEncoders.put(e.getKey(), property); + } } - Map extraEncoders = new TreeMap(); + for (Entry> e : resourceEntity.getAggregateChildren().entrySet()) { + ResourceEntity child = e.getValue(); + String propertyName = "@aggregated:" + e.getKey(); + relationshipEncoders.put(propertyName, new PropertyEncoder(entityEncoder(child), propertyName)); + } + Map extraEncoders = new TreeMap<>(); extraEncoders.putAll(resourceEntity.getExtraProperties()); EntityProperty idEncoder = resourceEntity.isIdIncluded() ? attributeEncoderFactory.getIdProperty(resourceEntity) : PropertyBuilder.doNothingProperty(); + return new EntityEncoder(idEncoder, attributeEncoders, relationshipEncoders, extraEncoders); } @@ -177,4 +219,33 @@ protected Encoder filteredEncoder(Encoder encoder, ResourceEntity resourceEnt return matchingFilters != null ? new FilterChainEncoder(encoder, matchingFilters) : encoder; } + private static String toFunctionName(AggregationType aggregationType, String attributeName) { + return aggregationType.functionName().toLowerCase() + "(" + attributeName + ")"; + } + + private static class PropertyEncoder implements EntityProperty { + + private Encoder encoder; + private String name; + + public PropertyEncoder(Encoder encoder, String name) { + this.encoder = encoder; + this.name = name; + } + + @Override + public void encode(Object root, String propertyName, JsonGenerator out) throws IOException { + encoder.encode(name, root, out); + } + + @Override + public Object read(Object root, String propertyName) { + throw new UnsupportedOperationException(); + } + + @Override + public int visit(Object object, String propertyName, EncoderVisitor visitor) { + throw new UnsupportedOperationException(); + } + } } diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/PathConstants.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/PathConstants.java index 497ff1ed3..9c2f8d15c 100644 --- a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/PathConstants.java +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/PathConstants.java @@ -2,7 +2,19 @@ public interface PathConstants { - public static final char DOT = '.'; - public static final String ID_PK_ATTRIBUTE = "id"; - public static final int MAX_PATH_LENGTH = 300; + String DOT = "."; + + /** + * @since 2.10 + */ + String OPEN_PARENTHESIS = "("; + + /** + * @since 2.10 + */ + String CLOSE_PARENTHESIS = ")"; + + String ID_PK_ATTRIBUTE = "id"; + + int MAX_PATH_LENGTH = 300; } diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/RequestParser.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/RequestParser.java index ba9576c2c..55df59b27 100644 --- a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/RequestParser.java +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/RequestParser.java @@ -7,7 +7,8 @@ import com.nhl.link.rest.runtime.parser.filter.IKeyValueExpProcessor; import com.nhl.link.rest.runtime.parser.sort.ISortProcessor; import com.nhl.link.rest.runtime.parser.tree.ITreeProcessor; -import com.nhl.link.rest.runtime.parser.tree.IncludeWorker; +import com.nhl.link.rest.runtime.parser.tree.IncludeVisitor; +import com.nhl.link.rest.runtime.parser.tree.PathProcessor; import org.apache.cayenne.di.Inject; import org.apache.cayenne.exp.Expression; import org.slf4j.Logger; @@ -86,7 +87,8 @@ private void processMapBy(ResourceEntity descriptor, Map descriptor.mapBy(mapBy, attribute.getName()); } else { ResourceEntity mapBy = new ResourceEntity<>(descriptor.getLrEntity()); - IncludeWorker.processIncludePath(mapBy, mapByPath); + // using standard include visitor, because functions don't make sense in the context of mapBy + PathProcessor.processor().processPath(mapBy, mapByPath, IncludeVisitor.visitor()); descriptor.mapBy(mapBy, mapByPath); } } diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/IncludeExcludeProcessor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/IncludeExcludeProcessor.java index 107fe22e2..edde3d49a 100644 --- a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/IncludeExcludeProcessor.java +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/IncludeExcludeProcessor.java @@ -5,8 +5,15 @@ import com.nhl.link.rest.runtime.parser.BaseRequestProcessor; import com.nhl.link.rest.runtime.parser.filter.ICayenneExpProcessor; import com.nhl.link.rest.runtime.parser.sort.ISortProcessor; +import com.nhl.link.rest.runtime.parser.tree.function.AverageProcessor; +import com.nhl.link.rest.runtime.parser.tree.function.CountProcessor; +import com.nhl.link.rest.runtime.parser.tree.function.FunctionProcessor; +import com.nhl.link.rest.runtime.parser.tree.function.MaximumProcessor; +import com.nhl.link.rest.runtime.parser.tree.function.MinimumProcessor; +import com.nhl.link.rest.runtime.parser.tree.function.SumProcessor; import org.apache.cayenne.di.Inject; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -18,12 +25,25 @@ public class IncludeExcludeProcessor extends BaseRequestProcessor implements ITr private static final String INCLUDE = "include"; private static final String EXCLUDE = "exclude"; + private static final String COUNT_FN = "count"; + private static final String AVERAGE_FN = "avg"; + private static final String SUM_FN = "sum"; + private static final String MINIMUM_FN = "min"; + private static final String MAXIMUM_FN = "max"; + private IncludeWorker includeWorker; private ExcludeWorker excludeWorker; public IncludeExcludeProcessor(@Inject IJacksonService jacksonService, @Inject ISortProcessor sortProcessor, @Inject ICayenneExpProcessor expProcessor) { - this.includeWorker = new IncludeWorker(jacksonService, sortProcessor, expProcessor); + Map functionProcessors = new HashMap<>(); + functionProcessors.put(COUNT_FN, new CountProcessor()); + functionProcessors.put(AVERAGE_FN, new AverageProcessor()); + functionProcessors.put(SUM_FN, new SumProcessor()); + functionProcessors.put(MINIMUM_FN, new MinimumProcessor()); + functionProcessors.put(MAXIMUM_FN, new MaximumProcessor()); + + this.includeWorker = new IncludeWorker(jacksonService, sortProcessor, expProcessor, functionProcessors); this.excludeWorker = new ExcludeWorker(jacksonService); } diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/IncludeVisitor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/IncludeVisitor.java new file mode 100644 index 000000000..2b75dcd42 --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/IncludeVisitor.java @@ -0,0 +1,39 @@ +package com.nhl.link.rest.runtime.parser.tree; + +import com.nhl.link.rest.LinkRestException; +import com.nhl.link.rest.ResourceEntity; +import com.nhl.link.rest.meta.LrAttribute; +import com.nhl.link.rest.meta.LrRelationship; + +import javax.ws.rs.core.Response.Status; + +public class IncludeVisitor implements PathVisitor { + + private static final IncludeVisitor instance = new IncludeVisitor(); + + public static IncludeVisitor visitor() { + return instance; + } + + @Override + public void visitAttribute(ResourceEntity entity, LrAttribute attribute) { + entity.getAttributes().put(attribute.getName(), attribute); + } + + @Override + public void visitRelationship(ResourceEntity parent, ResourceEntity child, LrRelationship relationship) { + IncludeWorker.applyDefaultIncludes(child); + // Id should be included implicitly + child.includeId(); + } + + @Override + public void visitId(ResourceEntity entity) { + entity.includeId(); + } + + @Override + public void visitFunction(ResourceEntity context, String functionName, String callExpression) { + throw new LinkRestException(Status.BAD_REQUEST, "Functions are not allowed in the current context"); + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/IncludeWorker.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/IncludeWorker.java index 8aaf65613..a4e811ee2 100644 --- a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/IncludeWorker.java +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/IncludeWorker.java @@ -4,18 +4,18 @@ import com.nhl.link.rest.LinkRestException; import com.nhl.link.rest.ResourceEntity; import com.nhl.link.rest.meta.LrAttribute; -import com.nhl.link.rest.meta.LrEntity; -import com.nhl.link.rest.meta.LrRelationship; import com.nhl.link.rest.runtime.jackson.IJacksonService; -import com.nhl.link.rest.runtime.parser.PathConstants; import com.nhl.link.rest.runtime.parser.filter.ICayenneExpProcessor; import com.nhl.link.rest.runtime.parser.sort.ISortProcessor; +import com.nhl.link.rest.runtime.parser.tree.function.FunctionProcessor; +import com.nhl.link.rest.runtime.parser.tree.function.FunctionalIncludeVisitor; import org.apache.cayenne.exp.Expression; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.ws.rs.core.Response.Status; import java.util.List; +import java.util.Map; public class IncludeWorker { @@ -31,11 +31,18 @@ public class IncludeWorker { private IJacksonService jsonParser; private ISortProcessor sortProcessor; private ICayenneExpProcessor expProcessor; + private PathProcessor pathProcessor; + private FunctionalIncludeVisitor functionalIncludeVisitor; - public IncludeWorker(IJacksonService jsonParser, ISortProcessor sortProcessor, ICayenneExpProcessor expProcessor) { + public IncludeWorker(IJacksonService jsonParser, + ISortProcessor sortProcessor, + ICayenneExpProcessor expProcessor, + Map functionProcessors) { this.jsonParser = jsonParser; this.sortProcessor = sortProcessor; this.expProcessor = expProcessor; + this.pathProcessor = PathProcessor.processor(); + this.functionalIncludeVisitor = new FunctionalIncludeVisitor(functionProcessors); } public void process(ResourceEntity resourceEntity, List includes) { @@ -47,11 +54,11 @@ public void process(ResourceEntity resourceEntity, List includes) { JsonNode root = jsonParser.parseJson(include); processIncludeObject(resourceEntity, root); } else { - processIncludePath(resourceEntity, include); + pathProcessor.processPath(resourceEntity, include, functionalIncludeVisitor); } } - processDefaultIncludes(resourceEntity); + applyDefaultIncludes(resourceEntity); } private void processIncludeArray(ResourceEntity resourceEntity, String include) { @@ -64,7 +71,7 @@ private void processIncludeArray(ResourceEntity resourceEntity, String includ if (child.isObject()) { processIncludeObject(resourceEntity, child); } else if (child.isTextual()) { - processIncludePath(resourceEntity, child.asText()); + pathProcessor.processPath(resourceEntity, child.asText(), functionalIncludeVisitor); } else { throw new LinkRestException(Status.BAD_REQUEST, "Bad include spec: " + child); } @@ -84,7 +91,7 @@ private void processIncludeObject(ResourceEntity rootEntity, JsonNode root) { includeEntity = rootEntity; } else { String path = pathNode.asText(); - includeEntity = processIncludePath(rootEntity, path); + includeEntity = pathProcessor.processPath(rootEntity, path, functionalIncludeVisitor); if (includeEntity == null) { throw new LinkRestException(Status.BAD_REQUEST, "Bad include spec, non-relationship 'path' in include object: " + path); @@ -135,8 +142,9 @@ private void processMapBy(ResourceEntity descriptor, String mapByPath) { // either root list, or to-many relationship if (descriptor.getIncoming() == null || descriptor.getIncoming().isToMany()) { - ResourceEntity mapByRoot = new ResourceEntity(descriptor.getLrEntity()); - processIncludePath(mapByRoot, mapByPath); + ResourceEntity mapByRoot = new ResourceEntity<>(descriptor.getLrEntity()); + // using standard include visitor, because functions don't make sense in the context of mapBy + pathProcessor.processPath(mapByRoot, mapByPath, IncludeVisitor.visitor()); descriptor.mapBy(mapByRoot, mapByPath); } else { @@ -144,72 +152,10 @@ private void processMapBy(ResourceEntity descriptor, String mapByPath) { } } - /** - * Records include path, returning null for the path corresponding to an - * attribute, and a child {@link ResourceEntity} for the path corresponding - * to relationship. - */ - @SuppressWarnings({ "unchecked", "rawtypes" }) - public static ResourceEntity processIncludePath(ResourceEntity parent, String path) { - - ExcludeWorker.checkTooLong(path); - - int dot = path.indexOf(PathConstants.DOT); - - if (dot == 0) { - throw new LinkRestException(Status.BAD_REQUEST, "Include starts with dot: " + path); - } - - if (dot == path.length() - 1) { - throw new LinkRestException(Status.BAD_REQUEST, "Include ends with dot: " + path); - } - - String property = dot > 0 ? path.substring(0, dot) : path; - LrEntity lrEntity = parent.getLrEntity(); - LrAttribute attribute = lrEntity.getAttribute(property); - if (attribute != null) { - - if (dot > 0) { - throw new LinkRestException(Status.BAD_REQUEST, "Invalid include path: " + path); - } - - parent.getAttributes().put(property, attribute); - return null; - } - - LrRelationship relationship = lrEntity.getRelationship(property); - if (relationship != null) { - - ResourceEntity childEntity = parent.getChild(property); - if (childEntity == null) { - LrEntity targetType = relationship.getTargetEntity(); - childEntity = new ResourceEntity(targetType, relationship); - parent.getChildren().put(property, childEntity); - } - - if (dot > 0) { - return processIncludePath(childEntity, path.substring(dot + 1)); - } else { - processDefaultIncludes(childEntity); - // Id should be included implicitly - childEntity.includeId(); - return childEntity; - } - } - - // this is root entity id and it's included explicitly - if (property.equals(PathConstants.ID_PK_ATTRIBUTE)) { - parent.includeId(); - return null; - } - - throw new LinkRestException(Status.BAD_REQUEST, "Invalid include path: " + path); - } - - private static void processDefaultIncludes(ResourceEntity resourceEntity) { + static void applyDefaultIncludes(ResourceEntity resourceEntity) { // either there are no includes (taking into account Id) or all includes // are relationships - if (!resourceEntity.isIdIncluded() && resourceEntity.getAttributes().isEmpty()) { + if (!resourceEntity.isIdIncluded() && resourceEntity.getAttributes().isEmpty() && !resourceEntity.isAggregate()) { for (LrAttribute a : resourceEntity.getLrEntity().getAttributes()) { resourceEntity.getAttributes().put(a.getName(), a); diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/PathProcessor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/PathProcessor.java new file mode 100644 index 000000000..9e6a36890 --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/PathProcessor.java @@ -0,0 +1,121 @@ +package com.nhl.link.rest.runtime.parser.tree; + +import com.nhl.link.rest.LinkRestException; +import com.nhl.link.rest.ResourceEntity; +import com.nhl.link.rest.meta.LrAttribute; +import com.nhl.link.rest.meta.LrEntity; +import com.nhl.link.rest.meta.LrRelationship; +import com.nhl.link.rest.runtime.parser.PathConstants; + +import javax.ws.rs.core.Response; +import java.util.Objects; + +public class PathProcessor { + + private static final PathProcessor instance = new PathProcessor(); + + public static PathProcessor processor() { + return instance; + } + + /** + * Records include path, returning null for the path corresponding to an + * attribute, and a child {@link ResourceEntity} for the path corresponding + * to relationship. + */ + public ResourceEntity processPath(ResourceEntity root, String path, PathVisitor visitor) { + ExcludeWorker.checkTooLong(path); + + int dot = path.indexOf(PathConstants.DOT); + checkDotIndex(path, dot); + + String property = dot > 0 ? path.substring(0, dot) : path; + LrEntity lrEntity = root.getLrEntity(); + + // first we must check if the path is a relationship + LrRelationship relationship = lrEntity.getRelationship(property); + if (relationship != null) { + ResourceEntity childEntity = root.getChildren().get(property); + if (childEntity == null && dot > 0) { + childEntity = root.getAggregateChildren().get(property); + } + + if (childEntity == null) { + LrEntity targetType = relationship.getTargetEntity(); + childEntity = new ResourceEntity<>(targetType, relationship); + root.getChildren().put(property, childEntity); + } + + ResourceEntity result; + if (dot > 0) { + result = processPath(childEntity, path.substring(dot + 1), visitor); + } else { + visitor.visitRelationship(root, childEntity, relationship); + result = childEntity; + } + + if (childEntity.isAggregate()) { + root.getChildren().remove(property); + root.getAggregateChildren().put(property, childEntity); + } + return result; + } + + // if the path is not a relationship, then we must check it does not contain separators (dots) + // TODO: this also effectively restricts attribute and function names, that contain dots, + // but it's still possible to add attributes with such malformed names programmatically + // Might be nice to add some additional checks, where needed + if (dot > 0) { + throw new LinkRestException(Response.Status.BAD_REQUEST, "Path contains dots, but is not a relationship: " + path); + } + + LrAttribute attribute = lrEntity.getAttribute(property); + if (attribute != null) { + visitor.visitAttribute(root, attribute); + return null; + } + + // this is root entity id and it's included explicitly + if (property.equals(PathConstants.ID_PK_ATTRIBUTE)) { + visitor.visitId(root); + return null; + } + + String functionName = readFunctionName(property); + if (functionName != null) { + visitor.visitFunction(root, functionName, property.substring(functionName.length())); + return null; + } + + throw new LinkRestException(Response.Status.BAD_REQUEST, "Invalid include path: " + path); + } + + private static void checkDotIndex(String path, int dot) { + if (dot == 0) { + throw new LinkRestException(Response.Status.BAD_REQUEST, "Path starts with dot: " + path); + } + if (dot == path.length() - 1) { + throw new LinkRestException(Response.Status.BAD_REQUEST, "Path ends with dot: " + path); + } + } + + /** + * @param property Function call expression (e.g. {@code count()}) + * @return Function name or null, if {@code property} is not a function call expression + */ + private static String readFunctionName(String property) { + if (Objects.requireNonNull(property, "Missing property").isEmpty()) { + return null; + } + + int parenthesis = property.indexOf(PathConstants.OPEN_PARENTHESIS); + if (parenthesis == 0) { + throw new LinkRestException(Response.Status.BAD_REQUEST, + "Function call expression starts with parenthesis (missing function name): " + property); + } else if (parenthesis < 0) { + return null; + } + + return property.substring(0, parenthesis); + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/PathVisitor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/PathVisitor.java new file mode 100644 index 000000000..b1d869edd --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/PathVisitor.java @@ -0,0 +1,16 @@ +package com.nhl.link.rest.runtime.parser.tree; + +import com.nhl.link.rest.ResourceEntity; +import com.nhl.link.rest.meta.LrAttribute; +import com.nhl.link.rest.meta.LrRelationship; + +public interface PathVisitor { + + void visitAttribute(ResourceEntity entity, LrAttribute attribute); + + void visitRelationship(ResourceEntity parent, ResourceEntity child, LrRelationship relationship); + + void visitId(ResourceEntity entity); + + void visitFunction(ResourceEntity context, String functionName, String callExpression); +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/AggregateByAttributeProcessor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/AggregateByAttributeProcessor.java new file mode 100644 index 000000000..a19a1f3a2 --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/AggregateByAttributeProcessor.java @@ -0,0 +1,27 @@ +package com.nhl.link.rest.runtime.parser.tree.function; + +import com.nhl.link.rest.AggregationType; +import com.nhl.link.rest.LinkRestException; +import com.nhl.link.rest.ResourceEntity; +import com.nhl.link.rest.meta.LrAttribute; + +import javax.ws.rs.core.Response.Status; + +public class AggregateByAttributeProcessor implements FunctionProcessor { + + private final AggregationType aggregationType; + + public AggregateByAttributeProcessor(AggregationType aggregationType) { + this.aggregationType = aggregationType; + } + + @Override + public void apply(ResourceEntity context) { + throw new LinkRestException(Status.BAD_REQUEST, "Function is not applicable in this context"); + } + + @Override + public void apply(ResourceEntity context, LrAttribute attribute) { + context.getAggregatedAttributes(aggregationType).add(attribute); + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/AverageProcessor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/AverageProcessor.java new file mode 100644 index 000000000..1dfc0d190 --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/AverageProcessor.java @@ -0,0 +1,10 @@ +package com.nhl.link.rest.runtime.parser.tree.function; + +import com.nhl.link.rest.AggregationType; + +public class AverageProcessor extends AggregateByAttributeProcessor { + + public AverageProcessor() { + super(AggregationType.AVERAGE); + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/CountProcessor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/CountProcessor.java new file mode 100644 index 000000000..c612584ab --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/CountProcessor.java @@ -0,0 +1,20 @@ +package com.nhl.link.rest.runtime.parser.tree.function; + +import com.nhl.link.rest.LinkRestException; +import com.nhl.link.rest.ResourceEntity; +import com.nhl.link.rest.meta.LrAttribute; + +import javax.ws.rs.core.Response; + +public class CountProcessor implements FunctionProcessor { + + @Override + public void apply(ResourceEntity context) { + context.includeCount(); + } + + @Override + public void apply(ResourceEntity context, LrAttribute attribute) { + throw new LinkRestException(Response.Status.BAD_REQUEST, "Function is not applicable in this context"); + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/FunctionProcessor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/FunctionProcessor.java new file mode 100644 index 000000000..d34549929 --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/FunctionProcessor.java @@ -0,0 +1,56 @@ +package com.nhl.link.rest.runtime.parser.tree.function; + +import com.nhl.link.rest.LinkRestException; +import com.nhl.link.rest.ResourceEntity; +import com.nhl.link.rest.meta.LrAttribute; +import com.nhl.link.rest.meta.LrRelationship; +import com.nhl.link.rest.runtime.parser.PathConstants; +import com.nhl.link.rest.runtime.parser.tree.PathProcessor; +import com.nhl.link.rest.runtime.parser.tree.PathVisitor; + +import javax.ws.rs.core.Response.Status; +import java.util.Objects; + +public interface FunctionProcessor { + + default void processCallExpression(ResourceEntity context, String expression) { + Objects.requireNonNull(expression, "Missing expression"); + if (!expression.startsWith(PathConstants.OPEN_PARENTHESIS) || !expression.endsWith(PathConstants.CLOSE_PARENTHESIS)) { + throw new LinkRestException(Status.BAD_REQUEST, "Expression must start and end with parentheses: " + expression); + } + String arguments = expression.substring(1, expression.length() - 1); + if (arguments.isEmpty()) { + apply(context); + } else { + PathVisitor visitor = new PathVisitor() { + @Override + public void visitAttribute(ResourceEntity entity, LrAttribute attribute) { + apply(entity, attribute); + } + + @Override + public void visitRelationship(ResourceEntity parent, ResourceEntity child, LrRelationship relationship) { + apply(child); + } + + @Override + public void visitId(ResourceEntity entity) { + // TODO: some functions might need to know, that the argument is ID... like avg(id) + // but in the context of LR the utility of such usage is questionable + apply(entity); + } + + @Override + public void visitFunction(ResourceEntity context, String functionName, String callExpression) { + throw new LinkRestException(Status.BAD_REQUEST, "Nested functions are not allowed"); + } + }; + + PathProcessor.processor().processPath(context, arguments, visitor); + } + } + + void apply(ResourceEntity context); + + void apply(ResourceEntity context, LrAttribute attribute); +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/FunctionalIncludeVisitor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/FunctionalIncludeVisitor.java new file mode 100644 index 000000000..4d73b5119 --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/FunctionalIncludeVisitor.java @@ -0,0 +1,43 @@ +package com.nhl.link.rest.runtime.parser.tree.function; + +import com.nhl.link.rest.LinkRestException; +import com.nhl.link.rest.ResourceEntity; +import com.nhl.link.rest.runtime.parser.tree.IncludeVisitor; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import java.util.HashMap; +import java.util.Map; + +public class FunctionalIncludeVisitor extends IncludeVisitor { + + private final Map functionProcessors; + + public FunctionalIncludeVisitor(Map functionProcessors) { + // sanity check + if (functionProcessors.isEmpty()) { + throw new LinkRestException(Status.INTERNAL_SERVER_ERROR, "No function processors provided"); + } + this.functionProcessors = withNormalizedNames(functionProcessors); + } + + /** + * Converts all keys to lower case + */ + private Map withNormalizedNames(Map functionProcessors) { + Map result = new HashMap<>(); + functionProcessors.forEach((k, v) -> result.put(k.toLowerCase(), v)); + return result; + } + + @Override + public void visitFunction(ResourceEntity context, String functionName, String callExpression) { + // case-insensitive names + functionName = functionName.toLowerCase(); + FunctionProcessor functionProcessor = functionProcessors.get(functionName); + if (functionProcessor == null) { + throw new LinkRestException(Response.Status.BAD_REQUEST, "Unknown function: " + functionName); + } + functionProcessor.processCallExpression(context, callExpression); + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/MaximumProcessor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/MaximumProcessor.java new file mode 100644 index 000000000..c87323155 --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/MaximumProcessor.java @@ -0,0 +1,10 @@ +package com.nhl.link.rest.runtime.parser.tree.function; + +import com.nhl.link.rest.AggregationType; + +public class MaximumProcessor extends AggregateByAttributeProcessor { + + public MaximumProcessor() { + super(AggregationType.MAXIMUM); + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/MinimumProcessor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/MinimumProcessor.java new file mode 100644 index 000000000..895c82e67 --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/MinimumProcessor.java @@ -0,0 +1,10 @@ +package com.nhl.link.rest.runtime.parser.tree.function; + +import com.nhl.link.rest.AggregationType; + +public class MinimumProcessor extends AggregateByAttributeProcessor { + + public MinimumProcessor() { + super(AggregationType.MINIMUM); + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/SumProcessor.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/SumProcessor.java new file mode 100644 index 000000000..9d3420dfa --- /dev/null +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/parser/tree/function/SumProcessor.java @@ -0,0 +1,10 @@ +package com.nhl.link.rest.runtime.parser.tree.function; + +import com.nhl.link.rest.AggregationType; + +public class SumProcessor extends AggregateByAttributeProcessor { + + public SumProcessor() { + super(AggregationType.SUM); + } +} diff --git a/link-rest/src/main/java/com/nhl/link/rest/runtime/processor/select/ApplyServerParamsStage.java b/link-rest/src/main/java/com/nhl/link/rest/runtime/processor/select/ApplyServerParamsStage.java index 9dbe63449..88056b90d 100644 --- a/link-rest/src/main/java/com/nhl/link/rest/runtime/processor/select/ApplyServerParamsStage.java +++ b/link-rest/src/main/java/com/nhl/link/rest/runtime/processor/select/ApplyServerParamsStage.java @@ -52,8 +52,9 @@ protected void doExecute(SelectContext context) { } } - // make sure we create the encoder, even if we end up with an empty - // list, as we need to encode the totals + // make sure we create the default encoder, even if we end up with an empty + // list, as we need to encode the totals; + // the default encoder will also come in handy for Pojos and when the processing chain is terminated by a listener if (context.getEncoder() == null) { context.setEncoder(encoderService.dataEncoder(entity)); diff --git a/link-rest/src/test/java/com/nhl/link/rest/it/GET_AggregateIT.java b/link-rest/src/test/java/com/nhl/link/rest/it/GET_AggregateIT.java new file mode 100644 index 000000000..e2e99d374 --- /dev/null +++ b/link-rest/src/test/java/com/nhl/link/rest/it/GET_AggregateIT.java @@ -0,0 +1,230 @@ +package com.nhl.link.rest.it; + +import com.nhl.link.rest.DataResponse; +import com.nhl.link.rest.LinkRest; +import com.nhl.link.rest.it.fixture.JerseyTestOnDerby; +import com.nhl.link.rest.it.fixture.cayenne.E2; +import com.nhl.link.rest.it.fixture.cayenne.E20; +import com.nhl.link.rest.it.fixture.cayenne.E21; +import org.junit.Test; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Configuration; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.FeatureContext; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; + +public class GET_AggregateIT extends JerseyTestOnDerby { + + @Override + protected void doAddResources(FeatureContext context) { + context.register(Resource.class); + } + + /** + # Aggregation on the root entity (employee) + # ?include=count()&include=lastName + + data: + - count() : 10 + lastName: Smith + - count() : 1 + lastName: Adamchik + total: 2 + */ + @Test + public void test_Select_AggregationOnRootEntity() { + + insert("e2", "id, name", "1, 'xxx'"); + insert("e2", "id, name", "2, 'yyy'"); + insert("e2", "id, name", "3, 'yyy'"); + + Response response = target("/e2") + .queryParam("include", "count()") + .queryParam("include", "name") + .queryParam("sort", "name") + .request() + .get(); + + onSuccess(response).bodyEquals(2, "{\"count()\":1,\"name\":\"xxx\"},{\"count()\":2,\"name\":\"yyy\"}"); + } + + /** + # Aggregation on the root entity (employee) , including related property + # ?include=avg(salary)&include=department.name + + data: + - avg(salary) : 10000 + department: + name: accounting + - avg(salary) : 20000 + department: + name: it + total: 2 + */ + @Test + public void test_Select_AggregationOnRootEntity_GroupByRelated() { + + insert("e21", "id, name", "1, 'xxx'"); + insert("e21", "id, name", "2, 'yyy'"); + insert("e20", "id, e21_id, age, name", "1, 1, 10, 'aaa'"); + insert("e20", "id, e21_id, age, name", "2, 1, 20, 'bbb'"); + insert("e20", "id, e21_id, age, name", "3, 2, 5, 'ccc'"); + + Response response = target("/e20") + .queryParam("include", "avg(age)") + .queryParam("include", "e21.name") + .queryParam("sort", "e21.name") + .request() + .get(); + + onSuccess(response).bodyEquals(2, + "{\"avg(age)\":15,\"e21\":{\"name\":\"xxx\"}}," + + "{\"avg(age)\":5,\"e21\":{\"name\":\"yyy\"}}"); + } + + /** + # Aggregation on a related entity (root is department) + # ?include=employees.avg(salary)&include=name + + data: + - name: accounting + "@aggregated:employees": + avg(salary) : 10000 + - name: it + "@aggregated:employees": + employees.avg(salary) : 20000 + total: 2 + */ + @Test + public void test_Select_AggregationOnRelatedEntity_GroupByRoot() { + + insert("e21", "id, name", "1, 'xxx'"); + insert("e21", "id, name", "2, 'yyy'"); + insert("e20", "id, e21_id, age, name", "1, 1, 10, 'aaa'"); + insert("e20", "id, e21_id, age, name", "2, 1, 20, 'bbb'"); + insert("e20", "id, e21_id, age, name", "3, 2, 5, 'ccc'"); + + Response response = target("/e21") + .queryParam("include", "e20s.sum(age)") + .queryParam("include", "name") + .queryParam("sort", "name") + .request() + .get(); + + onSuccess(response).bodyEquals(2, + "{\"@aggregated:e20s\":{\"sum(age)\":30},\"name\":\"xxx\"}," + + "{\"@aggregated:e20s\":{\"sum(age)\":5},\"name\":\"yyy\"}"); + } + + /** + # Aggregation on a related entity, grouping by property from that entity (root is department) + # ?include=employees.avg(salary)&include=employees.lastName + + data: + - name: accounting + "@aggregated:employees": + - avg(salary) : 10000 + lastName: Smith + - avg(salary) : 20000 + lastName: Doe + - name: it + ... + total: 2 + */ + @Test + public void test_Select_AggregationOnRelatedEntity_GroupByBoth() { + + insert("e21", "id, name", "1, 'xxx'"); + insert("e21", "id, name", "2, 'yyy'"); + insert("e20", "id, e21_id, age, name", "1, 1, 10, 'aaa'"); + insert("e20", "id, e21_id, age, name", "2, 1, 20, 'bbb'"); + insert("e20", "id, e21_id, age, name", "3, 2, 5, 'ccc'"); + + Response response = target("/e21") + .queryParam("include", "e20s.sum(age)") + .queryParam("include", "e20s.name") + .queryParam("include", "name") + .queryParam("sort", "name") + .request() + .get(); + + onSuccess(response).bodyEquals(3, + "{\"@aggregated:e20s\":{\"name\":\"bbb\",\"sum(age)\":20},\"name\":\"xxx\"}," + + "{\"@aggregated:e20s\":{\"name\":\"aaa\",\"sum(age)\":10},\"name\":\"xxx\"}," + + "{\"@aggregated:e20s\":{\"name\":\"ccc\",\"sum(age)\":5},\"name\":\"yyy\"}"); + } + + /** + # Aggregation on a related entity, grouping by property from that entity (root is department) + # ?include=employees.avg(salary)&include=employees.lastName&include=name + + data: + - id: ... + name: accounting + ... + "@aggregated:employees": + - avg(salary) : 10000 + lastName: Smith + - avg(salary) : 20000 + lastName: Doe + - id: ... + name: it + ... + total: 2 + */ +// @Test + public void test_Select_AggregationOnRelatedEntity_GroupRelated_IncludeRoot() { + + insert("e21", "id, name, age, description", "1, 'xxx', 99, 'xxx_desc'"); + insert("e21", "id, name, age, description", "2, 'yyy', 77, 'yyy_desc'"); + insert("e20", "id, e21_id, age, name", "1, 1, 10, 'aaa'"); + insert("e20", "id, e21_id, age, name", "2, 1, 20, 'aaa'"); + insert("e20", "id, e21_id, age, name", "3, 2, 5, 'bbb'"); + insert("e20", "id, e21_id, age, name", "4, 2, 15, 'ccc'"); + + Response response = target("/e21") + .queryParam("include", "e20s.sum(age)") + .queryParam("include", "e20s.name") + .queryParam("sort", "name") + .queryParam("sort", "e20s.name") + .request() + .get(); + + // need to map by self (and recursively by related to-one entity in general case) + onSuccess(response).bodyEquals(3, + "{\"@aggregated:e20s\":{\"name\":\"aaa\",\"sum(age)\":30},\"age\":99,\"description\":\"xxx_desc\",\"name\":\"xxx\"}," + + "{\"@aggregated:e20s\":{\"name\":\"ccc\",\"sum(age)\":15},\"age\":77,\"description\":\"yyy_desc\",\"name\":\"yyy\"}," + + "{\"@aggregated:e20s\":{\"name\":\"bbb\",\"sum(age)\":5},\"age\":77,\"description\":\"yyy_desc\",\"name\":\"yyy\"}"); + } + + @Path("") + @Produces(MediaType.APPLICATION_JSON) + public static class Resource { + + @Context + private Configuration config; + + @GET + @Path("e2") + public DataResponse getE2(@Context UriInfo uriInfo) { + return LinkRest.service(config).select(E2.class).uri(uriInfo).get(); + } + + @GET + @Path("e20") + public DataResponse getE20(@Context UriInfo uriInfo) { + return LinkRest.service(config).select(E20.class).uri(uriInfo).get(); + } + + @GET + @Path("e21") + public DataResponse getE21(@Context UriInfo uriInfo) { + return LinkRest.service(config).select(E21.class).uri(uriInfo).get(); + } + } +} diff --git a/link-rest/src/test/java/com/nhl/link/rest/it/GET_EncoderFilters_IT.java b/link-rest/src/test/java/com/nhl/link/rest/it/GET_EncoderFilters_IT.java index 7bc00ca46..555a6949e 100644 --- a/link-rest/src/test/java/com/nhl/link/rest/it/GET_EncoderFilters_IT.java +++ b/link-rest/src/test/java/com/nhl/link/rest/it/GET_EncoderFilters_IT.java @@ -109,7 +109,7 @@ public void testFilteredPagination3() { public void testFilteredPagination4_Listeners() { CayennePaginationListener.RESOURCE_ENTITY_IS_FILTERED = false; - CayennePaginationListener.QUERY_PAGE_SIZE = 0; + CayennePaginationListener.QUERY_PAGE_SIZE = 1; target("/e4/pagination_listener") .queryParam("include", "id") @@ -127,7 +127,7 @@ public void testFilteredPagination4_Listeners() { public void testFilteredPagination4_CustomStage() { Resource.RESOURCE_ENTITY_IS_FILTERED = false; - Resource.QUERY_PAGE_SIZE = 0; + Resource.QUERY_PAGE_SIZE = 1; target("/e4/pagination_stage") .queryParam("include", "id") diff --git a/link-rest/src/test/java/com/nhl/link/rest/it/GET_PojoIT.java b/link-rest/src/test/java/com/nhl/link/rest/it/GET_PojoIT.java index ab8d01c41..709613a32 100644 --- a/link-rest/src/test/java/com/nhl/link/rest/it/GET_PojoIT.java +++ b/link-rest/src/test/java/com/nhl/link/rest/it/GET_PojoIT.java @@ -13,14 +13,12 @@ import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.PathParam; -import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Configuration; import javax.ws.rs.core.Context; import javax.ws.rs.core.FeatureContext; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.UriInfo; -import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.List; diff --git a/link-rest/src/test/java/com/nhl/link/rest/runtime/cayenne/processor/select/CayenneAssembleQueryStageTest.java b/link-rest/src/test/java/com/nhl/link/rest/runtime/cayenne/processor/select/CayenneAssembleQueryStageTest.java index c74fd06e8..b663ce5bb 100644 --- a/link-rest/src/test/java/com/nhl/link/rest/runtime/cayenne/processor/select/CayenneAssembleQueryStageTest.java +++ b/link-rest/src/test/java/com/nhl/link/rest/runtime/cayenne/processor/select/CayenneAssembleQueryStageTest.java @@ -9,14 +9,20 @@ import com.nhl.link.rest.runtime.processor.select.SelectContext; import com.nhl.link.rest.unit.TestWithCayenneMapping; import org.apache.cayenne.exp.Expression; +import org.apache.cayenne.query.SelectQuery; import org.apache.cayenne.query.Ordering; import org.apache.cayenne.query.PrefetchTreeNode; -import org.apache.cayenne.query.SelectQuery; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; -import static org.junit.Assert.*; +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; public class CayenneAssembleQueryStageTest extends TestWithCayenneMapping { @@ -24,7 +30,7 @@ public class CayenneAssembleQueryStageTest extends TestWithCayenneMapping { @Before public void before() { - this.makeQueryStage = new CayenneAssembleQueryStage(mockCayennePersister); + this.makeQueryStage = new CayenneAssembleQueryStage(mockCayennePersister, null); } @Test @@ -33,26 +39,25 @@ public void testBuildQuery_Ordering() { Ordering o1 = E1.NAME.asc(); Ordering o2 = E1.NAME.desc(); - SelectQuery query = new SelectQuery(E1.class); + SelectQuery query = SelectQuery.query(E1.class); query.addOrdering(o1); ResourceEntity resourceEntity = getResourceEntity(E1.class); resourceEntity.getOrderings().add(o2); - SelectContext context = new SelectContext(E1.class); + SelectContext context = new SelectContext<>(E1.class); context.setSelect(query); context.setEntity(resourceEntity); SelectQuery amended = makeQueryStage.buildQuery(context); assertSame(query, amended); assertEquals(2, amended.getOrderings().size()); - assertSame(o1, amended.getOrderings().get(0)); - assertSame(o2, amended.getOrderings().get(1)); + assertTrue(amended.getOrderings().containsAll(Arrays.asList(o1, o2))); } @Test public void testBuildQuery_Prefetches() { - SelectQuery query = new SelectQuery(E2.class); + SelectQuery query = SelectQuery.query(E2.class); ResourceEntity resultFilter = getResourceEntity(E2.class); LrRelationship incoming = resultFilter.getLrEntity().getRelationship(E2.E3S.getName()); @@ -60,7 +65,7 @@ public void testBuildQuery_Prefetches() { LrPersistentEntity target = Mockito.mock(LrPersistentEntity.class); resultFilter.getChildren().put(E2.E3S.getName(), new ResourceEntity(target, incoming)); - SelectContext context = new SelectContext(E2.class); + SelectContext context = new SelectContext<>(E2.class); context.setEntity(resultFilter); context.setSelect(query); @@ -82,7 +87,7 @@ public void testBuildQuery_Pagination() { resourceEntity.setFetchLimit(10); resourceEntity.setFetchOffset(0); - SelectContext c = new SelectContext(E1.class); + SelectContext c = new SelectContext<>(E1.class); c.setEntity(resourceEntity); SelectQuery q1 = makeQueryStage.buildQuery(c); @@ -121,7 +126,7 @@ public void testBuildQuery_Qualifier() { SelectQuery query = makeQueryStage.buildQuery(c1); assertEquals(extraQualifier, query.getQualifier()); - SelectQuery query2 = new SelectQuery(E1.class); + SelectQuery query2 = SelectQuery.query(E1.class); query2.setQualifier(E1.NAME.in("a", "b")); SelectContext c2 = new SelectContext<>(E1.class); @@ -146,7 +151,7 @@ public void testById() { @Test public void testById_WithQuery() { - SelectQuery select = new SelectQuery(E1.class); + SelectQuery select = SelectQuery.query(E1.class); SelectContext c = new SelectContext<>(E1.class); c.setId(1); diff --git a/link-rest/src/test/java/com/nhl/link/rest/runtime/encoder/EncoderServiceTest.java b/link-rest/src/test/java/com/nhl/link/rest/runtime/encoder/EncoderServiceTest.java index f21f19f11..4b5ecb81e 100644 --- a/link-rest/src/test/java/com/nhl/link/rest/runtime/encoder/EncoderServiceTest.java +++ b/link-rest/src/test/java/com/nhl/link/rest/runtime/encoder/EncoderServiceTest.java @@ -63,6 +63,7 @@ public void testGetRootEncoder_ExcludedAttributes() throws IOException { public void testGetRootEncoder_ExcludedRelationshipAttributes() throws IOException { ResourceEntity e3Descriptor = getResourceEntity(E3.class); e3Descriptor.includeId(); + e3Descriptor.setIncoming(metadataService.getLrRelationship(E2.class, E2.E3S.getName())); appendAttribute(e3Descriptor, E3.NAME, String.class); @@ -195,6 +196,7 @@ public boolean willEncode(String propertyName, Object object, Encoder delegate) ResourceEntity e2Descriptor = getResourceEntity(E2.class); e2Descriptor.includeId(); + e2Descriptor.setIncoming(metadataService.getLrRelationship(E3.class, E3.E2.getName())); ResourceEntity e3Descriptor = getResourceEntity(E3.class); e3Descriptor.includeId();