io.evitadb.api.requestResponse.extraResult.FacetSummary Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of evita_api Show documentation
Show all versions of evita_api Show documentation
Module contains external API of the evitaDB.
/*
*
* _ _ ____ ____
* _____ _(_) |_ __ _| _ \| __ )
* / _ \ \ / / | __/ _` | | | | _ \
* | __/\ V /| | || (_| | |_| | |_) |
* \___| \_/ |_|\__\__,_|____/|____/
*
* Copyright (c) 2023-2024
*
* Licensed under the Business Source License, Version 1.1 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/FgForrest/evitaDB/blob/master/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.evitadb.api.requestResponse.extraResult;
import io.evitadb.api.query.filter.UserFilter;
import io.evitadb.api.requestResponse.EvitaResponse;
import io.evitadb.api.requestResponse.EvitaResponseExtraResult;
import io.evitadb.api.requestResponse.data.EntityClassifier;
import io.evitadb.api.requestResponse.data.SealedEntity;
import io.evitadb.api.requestResponse.data.structure.EntityDecorator;
import io.evitadb.api.requestResponse.data.structure.Reference;
import io.evitadb.api.requestResponse.data.structure.predicate.AttributeValueSerializablePredicate;
import io.evitadb.api.requestResponse.schema.EntityAttributeSchemaContract;
import io.evitadb.api.requestResponse.schema.EntitySchemaContract;
import io.evitadb.api.requestResponse.schema.NamedSchemaContract;
import io.evitadb.api.requestResponse.schema.ReferenceSchemaContract;
import io.evitadb.dataType.EvitaDataTypes;
import io.evitadb.exception.GenericEvitaInternalError;
import io.evitadb.utils.Assert;
import io.evitadb.utils.PrettyPrintable;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.ThreadSafe;
import java.io.Serial;
import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static io.evitadb.utils.CollectionUtils.createLinkedHashMap;
import static java.util.Optional.ofNullable;
/**
* This DTO allows returning summary of all facets that match query filter excluding those inside {@link UserFilter}.
* DTO contains information about facet groups and individual facets in them as well as appropriate statistics for them.
*
* Instance of this class is returned in {@link EvitaResponse#getExtraResult(Class)} when
* {@link io.evitadb.api.query.require.FacetSummary} require query is used in the query.
*
* @author Jan Novotný ([email protected]), FG Forrest a.s. (c) 2021
*/
@ThreadSafe
public class FacetSummary implements EvitaResponseExtraResult, PrettyPrintable {
@Serial private static final long serialVersionUID = -5622027322997919409L;
/**
* Contains statistics of facets aggregated into facet groups ({@link Reference#getGroup()}).
*/
@Getter(value = AccessLevel.NONE)
@Setter(value = AccessLevel.NONE)
@Nonnull
private final Map referenceStatistics;
public FacetSummary(@Nonnull Map> referenceStatistics) {
final Map result = createLinkedHashMap(referenceStatistics.size());
for (Entry> entry : referenceStatistics.entrySet()) {
final FacetGroupStatistics nonGroupedStatistics = entry.getValue()
.stream()
.filter(it -> it.getGroupEntity() == null)
.findFirst()
.orElse(null);
result.put(
entry.getKey(),
new ReferenceStatistics(
nonGroupedStatistics,
entry.getValue().stream()
.filter(it -> it.getGroupEntity() != null)
.collect(
Collectors.toMap(
it -> it.getGroupEntity().getPrimaryKey(),
Function.identity(),
(o, o2) -> {
throw new GenericEvitaInternalError(
"Unexpected duplicate facet group statistics."
);
},
LinkedHashMap::new
)
)
)
);
}
this.referenceStatistics = Collections.unmodifiableMap(result);
}
public FacetSummary(@Nonnull Collection referenceStatistics) {
this.referenceStatistics = Collections.unmodifiableMap(
referenceStatistics
.stream()
.collect(
Collectors.groupingBy(
FacetGroupStatistics::getReferenceName
)
)
.entrySet()
.stream()
.collect(
Collectors.toMap(
Entry::getKey,
it -> new ReferenceStatistics(
it.getValue().stream().filter(group -> group.getGroupEntity() == null).findFirst().orElse(null),
it.getValue().stream().filter(group -> group.getGroupEntity() != null)
.collect(
Collectors.toMap(
group -> group.getGroupEntity().getPrimaryKey(),
Function.identity(),
(o, o2) -> {
throw new GenericEvitaInternalError(
"There is already facet group for reference `" + it.getKey() +
"` with id `" + o.getGroupEntity().getPrimaryKey() + "`."
);
},
LinkedHashMap::new
)
)
)
)
)
);
}
/**
* Returns statistics for facet group with passed referenced type.
*/
@Nullable
public FacetGroupStatistics getFacetGroupStatistics(@Nonnull String referencedEntityType) {
return ofNullable(referenceStatistics.get(referencedEntityType))
.map(ReferenceStatistics::nonGroupedStatistics)
.orElse(null);
}
/**
* Returns statistics for facet group with passed referenced type and primary key of the group.
*/
@Nullable
public FacetGroupStatistics getFacetGroupStatistics(@Nonnull String referencedEntityType, int groupId) {
return ofNullable(referenceStatistics.get(referencedEntityType))
.map(it -> it.getFacetGroupStatistics(groupId))
.orElse(null);
}
/**
* Returns collection of all facet statistics aggregated by their group.
*/
@Nonnull
public Collection getReferenceStatistics() {
return referenceStatistics.values()
.stream()
.flatMap(
it -> Stream.concat(
it.nonGroupedStatistics() == null ? Stream.empty() : Stream.of(it.nonGroupedStatistics()),
it.groupedStatistics().values().stream()
)
)
.toList();
}
@Override
public int hashCode() {
return Objects.hash(referenceStatistics);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
FacetSummary that = (FacetSummary) o;
for (Entry referenceEntry : referenceStatistics.entrySet()) {
final ReferenceStatistics statistics = referenceEntry.getValue();
final ReferenceStatistics thatStatistics = that.referenceStatistics.get(referenceEntry.getKey());
if (!statistics.equals(thatStatistics)) {
return false;
}
}
return true;
}
@Nonnull
@Override
public String prettyPrint() {
final PrettyPrintingContext context = new PrettyPrintingContext();
return prettyPrint(
statistics -> ofNullable(statistics.getGroupEntity())
.filter(SealedEntity.class::isInstance)
.map(SealedEntity.class::cast)
.map(it -> printRepresentative(it, context))
.orElse(""),
facetStatistics -> ofNullable(facetStatistics.getFacetEntity())
.filter(SealedEntity.class::isInstance)
.map(SealedEntity.class::cast)
.map(it -> printRepresentative(it, context))
.orElse("")
);
}
@Override
public String toString() {
return "Facet summary with: " + this.referenceStatistics.size() + " references";
}
/**
* Prints a {@link SealedEntity} in a convenient way.
* @param entity Entity to print.
* @return String representation of the entity.
*/
@Nonnull
private static String printRepresentative(@Nonnull SealedEntity entity, @Nonnull PrettyPrintingContext context) {
final AttributeValueSerializablePredicate attributePredicate = ((EntityDecorator) entity).getAttributePredicate();
if (!attributePredicate.wasFetched()) {
return "";
}
final Set set = attributePredicate.getAttributeSet();
if (set.isEmpty()) {
final Set representativeAttributes = context.getRepresentativeAttribute(entity.getSchema());
return representativeAttributes.stream()
.map(attribute -> EvitaDataTypes.formatValue(entity.getAttribute(attribute).toString()))
.collect(Collectors.joining(", "));
} else if (set.size() == 1) {
return EvitaDataTypes.formatValue(entity.getAttribute(set.iterator().next()));
} else {
final Set representativeAttributes = context.getRepresentativeAttribute(entity.getSchema());
return set.stream()
.filter(representativeAttributes::contains)
.map(attribute -> EvitaDataTypes.formatValue(entity.getAttribute(attribute).toString()))
.collect(Collectors.joining(", "));
}
}
public String prettyPrint(
@Nonnull Function groupRenderer,
@Nonnull Function facetRenderer
) {
return "Facet summary:\n" +
referenceStatistics
.entrySet()
.stream()
.sorted(Entry.comparingByKey())
.flatMap(
refStats -> {
final ReferenceStatistics refStatsValue = refStats.getValue();
return Stream.concat(
refStatsValue.nonGroupedStatistics() == null ?
Stream.empty() :
Stream.of(refStatsValue.nonGroupedStatistics()),
refStatsValue
.groupedStatistics()
.values()
.stream()
)
.map(statistics -> "\t" + refStats.getKey() + ": " +
ofNullable(groupRenderer.apply(statistics)).filter(it -> !it.isBlank())
.orElseGet(() -> ofNullable(statistics.getGroupEntity()).map(EntityClassifier::getPrimaryKey).map(Object::toString).orElse("non-grouped")) +
" [" + statistics.getCount() + "]:\n" +
statistics
.getFacetStatistics()
.stream()
.map(facet -> "\t\t[" + (facet.isRequested() ? "X" : (ofNullable(facet.getImpact()).map(RequestImpact::hasSense).orElse(true) ? " " : "-")) + "] " +
ofNullable(facetRenderer.apply(facet)).filter(it -> !it.isBlank()).orElseGet(() -> String.valueOf(facet.getFacetEntity().getPrimaryKey())) +
" (" + facet.getCount() + ")" +
ofNullable(facet.getImpact()).map(RequestImpact::toString).map(it -> " " + it).orElse(""))
.collect(Collectors.joining("\n"))
);
}
)
.collect(Collectors.joining("\n"));
}
/**
* This DTO contains statistics for particular referenced entity - both grouped and non-grouped.
*/
private record ReferenceStatistics(
@Nullable FacetGroupStatistics nonGroupedStatistics,
@Nonnull Map groupedStatistics
) {
@Nullable
public FacetGroupStatistics getFacetGroupStatistics(int groupId) {
return this.groupedStatistics.get(groupId);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ReferenceStatistics that = (ReferenceStatistics) o;
if (!Objects.equals(nonGroupedStatistics, that.nonGroupedStatistics))
return false;
final Map statistics = groupedStatistics();
final Map thatStatistics = that.groupedStatistics();
if (statistics.size() != thatStatistics.size()) {
return false;
} else {
final Iterator> it = statistics.entrySet().iterator();
final Iterator> thatIt = thatStatistics.entrySet().iterator();
while (it.hasNext()) {
final Entry entry = it.next();
final Entry thatEntry = thatIt.next();
if (!Objects.equals(entry.getKey(), thatEntry.getKey()) || !Objects.equals(entry.getValue(), thatEntry.getValue())) {
return false;
}
}
}
return true;
}
@Override
public int hashCode() {
int result = nonGroupedStatistics != null ? nonGroupedStatistics.hashCode() : 0;
result = 31 * result + groupedStatistics.hashCode();
return result;
}
}
/**
* This DTO contains information about the impact of adding respective facet into the filtering query. This
* would lead to expanding or shrinking the result response in certain way, that is described in this DTO.
* This implementation contains only the bare difference and the match count.
*
* @param difference Projected number of entities that are added or removed from result if the query is altered by adding this
* facet to filtering query in comparison to current result.
* @param matchCount Projected number of filtered entities if the query is altered by adding this facet to filtering query.
* @param hasSense Selection has sense - TRUE if there is at least one entity still present in the result if
* the query is altered by adding this facet to filtering query. In case of OR relation between
* facets it's also true only if there is at least one entity present in the result when all other
* facets in the same group are removed and only this facet is requested.
*/
public record RequestImpact(int difference, int matchCount, boolean hasSense) implements Serializable {
@Serial private static final long serialVersionUID = 8332603848272953977L;
/**
* Returns either positive or negative number when the result expands or shrinks.
*/
@Override
public int difference() {
return difference;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof RequestImpact that)) return false;
return difference() == that.difference() && this.matchCount() == that.matchCount() && this.hasSense() == that.hasSense();
}
@Override
public int hashCode() {
return Objects.hash(difference(), matchCount(), hasSense());
}
@Override
public String toString() {
if (difference > 0) {
return "+" + difference;
} else if (difference < 0) {
return String.valueOf(difference);
} else {
return "0";
}
}
}
/**
* This DTO contains information about single facet statistics of the entities that are present in the response.
*/
@SuppressWarnings("ClassCanBeRecord")
public static final class FacetStatistics implements Comparable, Serializable {
@Serial private static final long serialVersionUID = -575288624429566680L;
/**
* Contains entity (or reference to it) representing the facet.
*/
@Getter @Nonnull private final EntityClassifier facetEntity;
/**
* Contains TRUE if the facet was part of the query filtering constraints.
*/
@Getter private final boolean requested;
/**
* Contains number of distinct entities in the response that possess of this reference.
*/
@Getter private final int count;
/**
* This field is not null only when this facet is not requested - {@link #requested ()} is FALSE.
* Contains projected impact on the current response if this facet is also requested in filtering constraints.
*/
@Getter @Nullable private final RequestImpact impact;
public FacetStatistics(
@Nonnull EntityClassifier facetEntity,
boolean requested,
int count,
@Nullable RequestImpact impact
) {
this.facetEntity = facetEntity;
this.requested = requested;
this.count = count;
this.impact = impact;
}
@Override
public int compareTo(FacetStatistics o) {
//noinspection ConstantConditions
return Integer.compare(getFacetEntity().getPrimaryKey(), o.getFacetEntity().getPrimaryKey());
}
@Override
public int hashCode() {
return Objects.hash(facetEntity, requested, count, impact);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final FacetStatistics that = (FacetStatistics) o;
return Objects.equals(getFacetEntity(), that.getFacetEntity()) &&
requested == that.requested &&
count == that.count &&
Objects.equals(impact, that.impact);
}
@Override
public String toString() {
return "FacetStatistics[" +
"facetEntity=" + facetEntity + ", " +
"requested=" + requested + ", " +
"count=" + count + ", " +
"impact=" + impact + ']';
}
}
/**
* This DTO contains information about single facet group and statistics of the facets that relates to it.
*/
@Data
public static class FacetGroupStatistics implements Serializable {
@Serial private static final long serialVersionUID = 6527695818988488638L;
/**
* Contains reference name of the facet. This type relates to {@link Reference#getReferenceName()}.
*/
@Nonnull
private final String referenceName;
/**
* Contains entity representing this group.
*/
@Nullable
private final EntityClassifier groupEntity;
/**
* Contains number of distinct entities in the response that possess any reference in this group.
*/
@Getter
private final int count;
/**
* Contains statistics of individual facets.
*/
@Getter(value = AccessLevel.NONE)
@Setter(value = AccessLevel.NONE)
@Nonnull
private final Map facetStatistics;
/**
* Method checks that the group entity type matches the group specified in schema.
*/
private static void verifyGroupType(@Nonnull ReferenceSchemaContract referenceSchema, @Nullable EntityClassifier groupEntity) {
if (groupEntity != null) {
final String schemaGroupType = ofNullable(referenceSchema.getReferencedGroupType())
.orElse(referenceSchema.getReferencedEntityType());
Assert.isPremiseValid(
groupEntity.getType().equals(schemaGroupType),
() -> "Group entity is from different collection (`" + groupEntity.getType() + "`) than the group or entity (`" + schemaGroupType + "`)."
);
}
}
/**
* This constructor should be used only for deserialization.
*/
public FacetGroupStatistics(
@Nonnull String referenceName,
@Nullable EntityClassifier groupEntity,
int count,
@Nonnull Map facetStatistics
) {
this.referenceName = referenceName;
this.groupEntity = groupEntity;
this.count = count;
this.facetStatistics = facetStatistics;
}
public FacetGroupStatistics(
@Nonnull ReferenceSchemaContract referenceSchema,
@Nullable EntityClassifier groupEntity,
int count,
@Nonnull Map facetStatistics
) {
verifyGroupType(referenceSchema, groupEntity);
this.referenceName = referenceSchema.getName();
this.groupEntity = groupEntity;
this.count = count;
this.facetStatistics = facetStatistics;
}
public FacetGroupStatistics(
@Nonnull ReferenceSchemaContract referenceSchema,
@Nullable EntityClassifier groupEntity,
int count,
@Nonnull Collection facetStatistics
) {
verifyGroupType(referenceSchema, groupEntity);
this.referenceName = referenceSchema.getName();
this.groupEntity = groupEntity;
this.count = count;
this.facetStatistics = facetStatistics
.stream()
.collect(
Collectors.toMap(
it -> it.getFacetEntity().getPrimaryKey(),
Function.identity(),
(facetStatistics1, facetStatistics2) -> {
throw new GenericEvitaInternalError("Statistics are expected to be unique!");
},
LinkedHashMap::new
)
);
}
/**
* Returns statistics for facet with passed primary key.
*/
@Nullable
public FacetStatistics getFacetStatistics(int facetId) {
return facetStatistics.get(facetId);
}
/**
* Returns collection of all facet statistics in this group.
*/
@Nonnull
public Collection getFacetStatistics() {
return Collections.unmodifiableCollection(facetStatistics.values());
}
@Override
public int hashCode() {
return Objects.hash(
referenceName,
ofNullable(groupEntity)
.map(EntityClassifier::getPrimaryKey)
.orElse(null),
count,
facetStatistics
);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final FacetGroupStatistics that = (FacetGroupStatistics) o;
if (!referenceName.equals(that.referenceName) ||
count != that.count ||
!Objects.equals(groupEntity, that.getGroupEntity()) ||
facetStatistics.size() != that.facetStatistics.size()) {
return false;
}
final Iterator> it = facetStatistics.entrySet().iterator();
final Iterator> thatIt = that.facetStatistics.entrySet().iterator();
while (it.hasNext()) {
final Entry entry = it.next();
final Entry thatEntry = thatIt.next();
if (!Objects.equals(entry.getKey(), thatEntry.getKey()) || !Objects.equals(entry.getValue(), thatEntry.getValue())) {
return false;
}
}
return true;
}
}
/**
* Context used by {@link #prettyPrint()} methods.
*/
private final static class PrettyPrintingContext {
/**
* Contains set of representative attribute names for each entity type.
*/
private final Map> representativeAttributes = new HashMap<>();
/**
* Returns set of {@link EntityAttributeSchemaContract#isRepresentative()} names for passed entity schema.
* @param entitySchema Entity schema to get representative attributes for.
* @return Set of representative attribute names.
*/
@Nonnull
public Set getRepresentativeAttribute(@Nonnull EntitySchemaContract entitySchema) {
return this.representativeAttributes.computeIfAbsent(
entitySchema.getName(),
entityType -> entitySchema
.getAttributes()
.values()
.stream()
.filter(EntityAttributeSchemaContract::isRepresentative)
.map(NamedSchemaContract::getName)
.collect(Collectors.toCollection(LinkedHashSet::new))
);
}
}
}