sortExpressions,
+ @JsonIgnore CommandFeatures commandFeatures)
+ implements SingleLeg {
+ public FilterLeg {
+ sortExpressions = List.copyOf(sortExpressions);
+ Objects.requireNonNull(commandFeatures, "commandFeatures must not be null");
+ if (sortExpressions.stream()
+ .anyMatch(expression -> !(expression instanceof SortExpression2.FieldSe))) {
+ throw new IllegalArgumentException("FilterLeg accepts only field expressions");
+ }
+ }
+ }
// Schema-only classes. @SchemaProperty can point at a nested schema only through a class, so
// these empty classes carry the @Schema for each nested part of the sort. Nothing creates them.
@@ -110,52 +188,23 @@ public static class BinaryVectorSchema {}
requiredProperties = {VECTOR_EMBEDDING_FIELD, VECTOR_EMBEDDING_TEXT_FIELD})
public static class ConflictingVectorSortSchema {}
- public static final FindAndRerankSort NO_ARG_SORT =
- new FindAndRerankSort(
- RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.EMPTY);
-
- public FindAndRerankSort {
- Objects.requireNonNull(rerankLegMode, "rerankLegMode must not be null");
- Objects.requireNonNull(commandFeatures, "commandFeatures must not be null");
- }
-
- @Override
- public DataRecorder recordTo(DataRecorder dataRecorder) {
- return dataRecorder
- .append("rerankLegMode", rerankLegMode)
- .append("hybridSort", hybridSort)
- .append("vectorizeSort", vectorizeSort)
- .append("lexicalSort", lexicalSort)
- .append("vectorSort", Arrays.toString(vectorSort))
- .append("commandFeatures", commandFeatures);
- }
-
- /**
- * Override to do a value equality check on the vector
- *
- * @param obj the reference object with which to compare.
- * @return
- */
- @Override
- public boolean equals(Object obj) {
- return (obj instanceof FindAndRerankSort other)
- && rerankLegMode == other.rerankLegMode
- && Objects.equals(hybridSort, other.hybridSort)
- && Objects.equals(vectorizeSort, other.vectorizeSort)
- && Objects.equals(lexicalSort, other.lexicalSort)
- && Arrays.equals(vectorSort, other.vectorSort)
- && Objects.equals(commandFeatures, other.commandFeatures);
- }
+ FindAndRerankSort NO_ARG_SORT = new UnresolvedSingleLeg(NullNode.instance, CommandFeatures.EMPTY);
- /** Override to do a value equality hash on the vector */
@Override
- public int hashCode() {
- return Objects.hash(
- rerankLegMode,
- hybridSort,
- vectorizeSort,
- lexicalSort,
- Arrays.hashCode(vectorSort),
- commandFeatures);
+ default DataRecorder recordTo(DataRecorder dataRecorder) {
+ dataRecorder.append("sortType", getClass().getSimpleName());
+ switch (this) {
+ case HybridText sort -> dataRecorder.append("text", sort.text());
+ case HybridObject sort ->
+ dataRecorder
+ .append("vectorize", sort.vectorize())
+ .append("lexical", sort.lexical())
+ .append("vector", Arrays.toString(sort.vector()));
+ case UnresolvedSingleLeg sort -> dataRecorder.append("sort", sort.sort());
+ case LexicalLeg sort -> dataRecorder.append("sortExpressions", sort.sortExpressions());
+ case VectorLeg sort -> dataRecorder.append("sortExpressions", sort.sortExpressions());
+ case FilterLeg sort -> dataRecorder.append("sortExpressions", sort.sortExpressions());
+ }
+ return dataRecorder.append("commandFeatures", commandFeatures());
}
}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java
index a3294cf9a0..5f669e38ee 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java
@@ -1,6 +1,9 @@
package io.stargate.sgv2.jsonapi.api.model.command.clause.sort;
-import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*;
+import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.HYBRID_FIELD;
+import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.LEXICAL_CONTENT_FIELD;
+import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.VECTOR_EMBEDDING_FIELD;
+import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.VECTOR_EMBEDDING_TEXT_FIELD;
import static io.stargate.sgv2.jsonapi.util.JsonUtil.arrayNodeToVector;
import com.fasterxml.jackson.core.JsonParser;
@@ -13,6 +16,7 @@
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.EJSONWrapper;
+import io.stargate.sgv2.jsonapi.exception.SortException;
import io.stargate.sgv2.jsonapi.metrics.CommandFeature;
import io.stargate.sgv2.jsonapi.metrics.CommandFeatures;
import io.stargate.sgv2.jsonapi.util.JsonFieldMatcher;
@@ -22,32 +26,41 @@
/**
* {@link StdDeserializer} for the {@link FindAndRerankSort}.
*
- * Note: This only checks the shape of the sort clause, the other validation is handled when
- * resolving the command to an operation.
+ *
Checks the shape and value types of $hybrid. Other sorts retain their JSON values for the
+ * planner to validate with find's sort builder.
*/
public class FindAndRerankSortClauseDeserializer extends StdDeserializer {
private static final String ERROR_CONTEXT = "`sort` clause";
- // the only field we accept at top level is $hybrid
- // can be string or object
+ // $hybrid is the only top-level field for a multi-leg sort
// { "sort" : { "$hybrid" : "i like cheese" } }
// { "sort" : { "$hybrid" : { "$vectorize" : "i like cheese", "$lexical" : "cows"} } }
// { "sort" : { "$hybrid" : { "$vector" : [1, 2, 3], "$lexical" : "cows"} } }
- private static final JsonFieldMatcher MATCH_HYBRID_FIELD =
- new JsonFieldMatcher<>(JsonNode.class, List.of(HYBRID_FIELD), List.of());
+ private static final JsonFieldMatcher MATCH_TOP_LEVEL_SORT_FIELDS =
+ new JsonFieldMatcher<>(
+ JsonNode.class,
+ List.of(),
+ List.of(
+ SortField.HYBRID.sortField(),
+ SortField.VECTORIZE.sortField(),
+ SortField.VECTOR.sortField(),
+ SortField.LEXICAL.sortField()));
- // user is specifying the sorts individually
+ // user is specifying the sorts individually inside the $hybrid object
// can be
// { "sort" : { "$hybrid" : { "$vectorize" : "i like cheese", "$lexical" : "cows"} } }
// { "sort" : { "$hybrid" : { "$vector" : [1, 2, 3], "$lexical" : "cows"} } }
// they could also set something to null to skip that read, that is handled by the resolver
// later.
- private static final JsonFieldMatcher MATCH_SORT_FIELDS =
+ private static final JsonFieldMatcher MATCH_HYBRID_OBJECT_FIELDS =
new JsonFieldMatcher<>(
JsonNode.class,
List.of(),
- List.of(LEXICAL_CONTENT_FIELD, VECTOR_EMBEDDING_TEXT_FIELD, VECTOR_EMBEDDING_FIELD));
+ List.of(
+ SortField.LEXICAL.sortField(),
+ SortField.VECTORIZE.sortField(),
+ SortField.VECTOR.sortField()));
public FindAndRerankSortClauseDeserializer() {
super(FindAndRerankSort.class);
@@ -69,31 +82,37 @@ public FindAndRerankSort deserialize(
}
/**
- * Deserialize the sort clause from an object node.
+ * Deserialize the sort clause, retaining non-hybrid values until the collection is available.
*
* @param sort The sort clause as an object node, no check for nulls.
- * @return The {@link FindAndRerankSort} that reflects the request without validation, e.g.
- * checking that a vectorize or vector sort is provided but not both.
*/
private static FindAndRerankSort deserialize(JsonParser jsonParser, ObjectNode sort)
throws JsonMappingException {
- // { "sort" : { } }
- if (sort.isEmpty()) {
- return FindAndRerankSort.NO_ARG_SORT;
+ if (!sort.has(HYBRID_FIELD)) {
+ var commandFeatures = CommandFeatures.create();
+ for (var sortField : SortField.values()) {
+ if (sort.has(sortField.sortField())) {
+ commandFeatures.addFeature(sortField.commandFeature());
+ }
+ }
+ return new FindAndRerankSort.UnresolvedSingleLeg(sort, commandFeatures);
}
- var hybridMatch = MATCH_HYBRID_FIELD.matchAndThrow(sort, jsonParser, ERROR_CONTEXT);
+ var sortMatch = MATCH_TOP_LEVEL_SORT_FIELDS.matchAndThrow(sort, jsonParser, ERROR_CONTEXT);
+ if (sortMatch.matched().size() != 1) {
+ throw SortException.Code.SORT_CLAUSE_INVALID.get(
+ Map.of(
+ "problem",
+ "sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical, but got: "
+ + String.join(", ", sortMatch.matched().keySet())));
+ }
- return switch (hybridMatch.matched().get(HYBRID_FIELD)) {
+ return switch (sort.get(HYBRID_FIELD)) {
case TextNode textNode ->
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
+ new FindAndRerankSort.HybridText(
normalizedText(textNode.asText()),
- null,
- null,
- null,
- CommandFeatures.of(CommandFeature.HYBRID));
+ CommandFeatures.of(SortField.HYBRID.commandFeature()));
case ObjectNode objectNode -> deserializeHybridObject(jsonParser, objectNode);
case JsonNode node ->
throw JsonFieldMatcher.errorForWrongType(
@@ -104,117 +123,154 @@ private static FindAndRerankSort deserialize(JsonParser jsonParser, ObjectNode s
private static FindAndRerankSort deserializeHybridObject(
JsonParser jsonParser, ObjectNode hybridObject) throws JsonMappingException {
- var sortMatch = MATCH_SORT_FIELDS.matchAndThrow(hybridObject, jsonParser, ERROR_CONTEXT);
- CommandFeatures commandFeatures = CommandFeatures.of(CommandFeature.HYBRID);
+ var sortMatch =
+ MATCH_HYBRID_OBJECT_FIELDS.matchAndThrow(hybridObject, jsonParser, ERROR_CONTEXT);
+ // NOTE: commandFeatures are only for metrics, the FindAndRerankPlanner decides what to run
+ // from the sort values
+ CommandFeatures commandFeatures = CommandFeatures.of(SortField.HYBRID.commandFeature());
var vectorizeText =
- switch (sortMatch.matched().get(VECTOR_EMBEDDING_TEXT_FIELD)) {
- case null -> {
- // undefined in the object, up to the resolver to check if both vectorize and vector are
- // not set
- // { "sort" : { "$hybrid" : { }}
- yield null;
- }
- case NullNode ignored -> {
- // explict setting to null is allowed
- // { "sort" : { "$hybrid" : { "$vectorize" : null,
- commandFeatures.addFeature(CommandFeature.VECTORIZE);
- yield null;
- }
- case TextNode textNode -> {
- // { "sort" : { "$hybrid" : { "$vectorize" : "I like cheese",
- commandFeatures.addFeature(CommandFeature.VECTORIZE);
- yield normalizedText(textNode.asText());
- }
- case JsonNode node ->
- throw JsonFieldMatcher.errorForWrongType(
- jsonParser,
- ERROR_CONTEXT,
- VECTOR_EMBEDDING_TEXT_FIELD,
- node,
- NullNode.class,
- TextNode.class);
- };
-
+ textSortValue(
+ jsonParser,
+ SortField.VECTORIZE,
+ sortMatch.matched().get(SortField.VECTORIZE.sortField()),
+ commandFeatures);
var lexicalText =
- switch (sortMatch.matched().get(LEXICAL_CONTENT_FIELD)) {
- case null -> {
- // undefined in the object, up to the resolver to check if valid
- // { "sort" : { "$hybrid" : { }}
- yield null;
- }
- case NullNode ignored -> {
- // explict setting to null is allowed
- // { "sort" : { "$hybrid" : { "$lexical" : null,
- commandFeatures.addFeature(CommandFeature.LEXICAL);
- yield null;
- }
- case TextNode textNode -> {
- // { "sort" : { "$hybrid" : { "$lexical" : "cheese",
- commandFeatures.addFeature(CommandFeature.LEXICAL);
- yield normalizedText(textNode.asText());
- }
- case JsonNode node ->
- throw JsonFieldMatcher.errorForWrongType(
- jsonParser,
- ERROR_CONTEXT,
- LEXICAL_CONTENT_FIELD,
- node,
- NullNode.class,
- TextNode.class);
- };
-
+ textSortValue(
+ jsonParser,
+ SortField.LEXICAL,
+ sortMatch.matched().get(SortField.LEXICAL.sortField()),
+ commandFeatures);
var vector =
- switch (sortMatch.matched().get(VECTOR_EMBEDDING_FIELD)) {
- case null -> {
- // undefined in the object, up to the resolver to check if valid
- // { "sort" : { "$hybrid" : { }}
- yield null;
- }
- case NullNode ignored -> {
- // explict setting to null is allowed
- // { "sort" : { "$hybrid" : { "$vector" : null,
- commandFeatures.addFeature(CommandFeature.VECTOR);
- yield null;
- }
- case ArrayNode arrayNode -> {
- // { "sort" : { "$hybrid" : { "vector" : [1,2,3],
- commandFeatures.addFeature(CommandFeature.VECTOR);
- yield arrayNodeToVector(arrayNode);
- }
- case ObjectNode objectNode -> {
- // binary vector
- // { "sort" : { "$hybrid" : { "$vector" : {"$binary": "c3VyZS4="},
- var ejson = EJSONWrapper.maybeFrom(objectNode);
- if (ejson == null || ejson.type() != EJSONWrapper.EJSONType.BINARY) {
- // not the best error, wont tell people how to do a binary vector
- // and it will say it got an object node and that is not supported
- // TODO: better error message
- throw JsonFieldMatcher.errorForWrongType(
- jsonParser,
- ERROR_CONTEXT + " (binary vector)",
- VECTOR_EMBEDDING_FIELD,
- objectNode,
- NullNode.class,
- ArrayNode.class,
- ObjectNode.class);
- }
- commandFeatures.addFeature(CommandFeature.VECTOR);
- yield ejson.getVectorValueForBinary();
- }
- case JsonNode node ->
- throw JsonFieldMatcher.errorForWrongType(
- jsonParser,
- ERROR_CONTEXT,
- VECTOR_EMBEDDING_FIELD,
- node,
- NullNode.class,
- ArrayNode.class,
- ObjectNode.class);
- };
-
- return new FindAndRerankSort(
- RerankLegMode.MULTI_LEG, null, vectorizeText, lexicalText, vector, commandFeatures);
+ vectorSortValue(
+ jsonParser, sortMatch.matched().get(SortField.VECTOR.sortField()), commandFeatures);
+
+ return new FindAndRerankSort.HybridObject(vectorizeText, lexicalText, vector, commandFeatures);
+ }
+
+ /**
+ * A text sort value from inside the {@code $hybrid} object. The field and its metrics feature
+ * both come from {@code sortField}, so they cannot mismatch.
+ *
+ * @param sortField {@link SortField#VECTORIZE} or {@link SortField#LEXICAL}.
+ * @param node The field value, or {@code null} when the field is missing.
+ * @param commandFeatures The mode's feature is added when the field was present, even if null.
+ * @return The trimmed text, or {@code null} when the field is missing, JSON null or blank. This
+ * skips the leg inside {@code $hybrid}.
+ */
+ private static String textSortValue(
+ JsonParser jsonParser, SortField sortField, JsonNode node, CommandFeatures commandFeatures)
+ throws JsonMappingException {
+ return switch (node) {
+ case null -> {
+ // undefined in the object, up to the resolver to check if valid
+ // { "sort" : { "$hybrid" : { }}
+ yield null;
+ }
+ case NullNode ignored -> {
+ // explicit setting to null is allowed
+ // { "sort" : { "$hybrid" : { "$vectorize" : null } } }
+ commandFeatures.addFeature(sortField.commandFeature());
+ yield null;
+ }
+ case TextNode textNode -> {
+ // { "sort" : { "$hybrid" : { "$vectorize" : "I like cheese" } } }
+ commandFeatures.addFeature(sortField.commandFeature());
+ yield normalizedText(textNode.asText());
+ }
+ case JsonNode wrongType ->
+ throw JsonFieldMatcher.errorForWrongType(
+ jsonParser,
+ ERROR_CONTEXT,
+ sortField.sortField(),
+ wrongType,
+ NullNode.class,
+ TextNode.class);
+ };
+ }
+
+ /**
+ * A {@code $vector} sort value from inside the {@code $hybrid} object.
+ *
+ * @param node The field value, or {@code null} when the field is missing.
+ * @param commandFeatures The vector feature is added when the field was present, even if null.
+ * @return The vector as written, or {@code null} when the field is missing or JSON null. An empty
+ * array fails in {@code arrayNodeToVector} with SHRED_BAD_VECTOR_SIZE.
+ */
+ private static float[] vectorSortValue(
+ JsonParser jsonParser, JsonNode node, CommandFeatures commandFeatures)
+ throws JsonMappingException {
+ return switch (node) {
+ case null -> {
+ // undefined in the object, up to the resolver to check if valid
+ // { "sort" : { "$hybrid" : { }}
+ yield null;
+ }
+ case NullNode ignored -> {
+ // explicit setting to null is allowed
+ // { "sort" : { "$hybrid" : { "$vector" : null } } }
+ commandFeatures.addFeature(SortField.VECTOR.commandFeature());
+ yield null;
+ }
+ case ArrayNode arrayNode -> {
+ // { "sort" : { "$hybrid" : { "$vector" : [1,2,3] } } }
+ commandFeatures.addFeature(SortField.VECTOR.commandFeature());
+ yield arrayNodeToVector(arrayNode);
+ }
+ case ObjectNode objectNode -> {
+ // binary vector
+ // { "sort" : { "$hybrid" : { "$vector" : {"$binary": "c3VyZS4="} } } }
+ var ejson = EJSONWrapper.maybeFrom(objectNode);
+ if (ejson == null || ejson.type() != EJSONWrapper.EJSONType.BINARY) {
+ // not the best error, wont tell people how to do a binary vector
+ // and it will say it got an object node and that is not supported
+ // TODO: better error message
+ throw JsonFieldMatcher.errorForWrongType(
+ jsonParser,
+ ERROR_CONTEXT + " (binary vector)",
+ SortField.VECTOR.sortField(),
+ objectNode,
+ NullNode.class,
+ ArrayNode.class,
+ ObjectNode.class);
+ }
+ commandFeatures.addFeature(SortField.VECTOR.commandFeature());
+ yield ejson.getVectorValueForBinary();
+ }
+ case JsonNode wrongType ->
+ throw JsonFieldMatcher.errorForWrongType(
+ jsonParser,
+ ERROR_CONTEXT,
+ SortField.VECTOR.sortField(),
+ wrongType,
+ NullNode.class,
+ ArrayNode.class,
+ ObjectNode.class);
+ };
+ }
+
+ /** JSON sort field names and their metrics features. */
+ private enum SortField {
+ HYBRID(HYBRID_FIELD, CommandFeature.HYBRID),
+ VECTORIZE(VECTOR_EMBEDDING_TEXT_FIELD, CommandFeature.VECTORIZE),
+ VECTOR(VECTOR_EMBEDDING_FIELD, CommandFeature.VECTOR),
+ LEXICAL(LEXICAL_CONTENT_FIELD, CommandFeature.LEXICAL);
+
+ private final String sortField;
+ private final CommandFeature commandFeature;
+
+ SortField(String sortField, CommandFeature commandFeature) {
+ this.sortField = sortField;
+ this.commandFeature = commandFeature;
+ }
+
+ String sortField() {
+ return sortField;
+ }
+
+ CommandFeature commandFeature() {
+ return commandFeature;
+ }
}
/**
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/RerankLegMode.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/RerankLegMode.java
deleted file mode 100644
index fc2d0d7918..0000000000
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/RerankLegMode.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package io.stargate.sgv2.jsonapi.api.model.command.clause.sort;
-
-/**
- * If a findAndRerank command reads with one leg or with more than one, decided by its sort clause:
- * {@link #MULTI_LEG} when the sort clause uses $hybrid, otherwise {@link #SINGLE_LEG}.
- *
- * A MULTI_LEG command may still read with only one leg, e.g. {"$hybrid": "cheese"}
- * on a collection without a lexical index.
- */
-public enum RerankLegMode {
- SINGLE_LEG,
- MULTI_LEG
-}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/SortExpression2.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/SortExpression2.java
new file mode 100644
index 0000000000..c51df34046
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/SortExpression2.java
@@ -0,0 +1,74 @@
+package io.stargate.sgv2.jsonapi.api.model.command.clause.sort;
+
+import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.LEXICAL_CONTENT_FIELD;
+import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.VECTOR_EMBEDDING_FIELD;
+import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.VECTOR_EMBEDDING_TEXT_FIELD;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+/** A validated expression in a single-leg findAndRerank sort. */
+public sealed interface SortExpression2
+ permits SortExpression2.VectorizeSe,
+ SortExpression2.VectorSe,
+ SortExpression2.LexicalSe,
+ SortExpression2.FieldSe {
+ String path();
+
+ record VectorizeSe(String vectorizeText) implements SortExpression2 {
+ public VectorizeSe {
+ Objects.requireNonNull(vectorizeText, "vectorizeText must not be null");
+ if (vectorizeText.isBlank()) {
+ throw new IllegalArgumentException("vectorizeText must not be blank");
+ }
+ }
+
+ @Override
+ public String path() {
+ return VECTOR_EMBEDDING_TEXT_FIELD;
+ }
+ }
+
+ record VectorSe(float[] vector) implements SortExpression2 {
+ public VectorSe {
+ vector = Objects.requireNonNull(vector, "vector must not be null").clone();
+ }
+
+ @Override
+ public float[] vector() {
+ return vector.clone();
+ }
+
+ @Override
+ public String path() {
+ return VECTOR_EMBEDDING_FIELD;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return obj instanceof VectorSe other && Arrays.equals(vector, other.vector);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(vector);
+ }
+ }
+
+ record LexicalSe(String lexicalText) implements SortExpression2 {
+ public LexicalSe {
+ Objects.requireNonNull(lexicalText, "lexicalText must not be null");
+ }
+
+ @Override
+ public String path() {
+ return LEXICAL_CONTENT_FIELD;
+ }
+ }
+
+ record FieldSe(String path, boolean ascending) implements SortExpression2 {
+ public FieldSe {
+ Objects.requireNonNull(path, "path must not be null");
+ }
+ }
+}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlanner.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlanner.java
index 2d02a5acbc..b3040c9295 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlanner.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlanner.java
@@ -5,7 +5,11 @@
import static io.stargate.sgv2.jsonapi.util.ApiOptionUtils.getOrDefault;
import io.stargate.sgv2.jsonapi.api.model.command.CommandContext;
+import io.stargate.sgv2.jsonapi.api.model.command.builders.CollectionSortClauseBuilder;
import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort;
+import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort.*;
+import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortExpression2;
+import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortExpression2.*;
import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand;
import io.stargate.sgv2.jsonapi.config.IntConfigWithBounds;
import io.stargate.sgv2.jsonapi.config.OperationsConfig;
@@ -45,8 +49,11 @@ static FindAndRerankPlan plan(
Objects.requireNonNull(command, "command must not be null");
Objects.requireNonNull(commandContext, "commandContext must not be null");
- var sort = command.sortClause();
var schemaObject = commandContext.schemaObject();
+ var sort =
+ command.sortClause() instanceof UnresolvedSingleLeg unresolved
+ ? resolveSingleLeg(unresolved, schemaObject)
+ : command.sortClause();
var operationsConfig = commandContext.config().get(OperationsConfig.class);
checkSortSupported(sort, schemaObject);
@@ -56,7 +63,7 @@ static FindAndRerankPlan plan(
RerankingQuery.create(
getOrDefault(command.options(), FindAndRerankCommand.Options::rerankQuery, null),
vectorizeText(sort));
- var passageLocator = passageLocator(command);
+ var passageLocator = passageLocator(command, sort);
var projector = command.buildProjector();
// NOTE: the legs are planned after the options and the projection are checked, to keep the
@@ -66,11 +73,15 @@ static FindAndRerankPlan plan(
// NOTE: a sort with no $vector or $vectorize fails here with a SORT error, not later with an
// UNEXPECTED_SERVER_ERROR, see GH #2577
var legs =
- switch (sort.rerankLegMode()) {
- // TODO: GH #2602, GH #2603 - plan single leg sorts, until then only a command with no
- // sort is SINGLE_LEG
- case SINGLE_LEG -> throw SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT.get();
- case MULTI_LEG -> multiLegs(command, schemaObject);
+ switch (sort) {
+ case MultiLeg multiLeg -> multiLegs(command, multiLeg, schemaObject);
+ // TODO: GH #2602, GH #2603 - execute the validated single-leg sorts.
+ case LexicalLeg ignored ->
+ throw SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT.get();
+ case VectorLeg ignored -> throw SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT.get();
+ case FilterLeg ignored -> throw SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT.get();
+ case UnresolvedSingleLeg ignored ->
+ throw new IllegalStateException("sort was not resolved");
};
var vectorizeStep = isVectorizeSort(sort) ? planVectorizeStep(sort, schemaObject) : null;
@@ -89,6 +100,31 @@ static FindAndRerankPlan plan(
getOrDefault(command.options(), FindAndRerankCommand.Options::includeSortVector, false));
}
+ /** Validate single-leg values with find's builder before assigning their typed form. */
+ static SingleLeg resolveSingleLeg(UnresolvedSingleLeg sort, CollectionSchemaObject schemaObject) {
+ var clause = new CollectionSortClauseBuilder(schemaObject).build(sort.sort());
+ clause.validate(schemaObject);
+ var features = sort.commandFeatures();
+ var lexical = clause.lexicalSortExpression();
+ if (lexical != null) {
+ return new LexicalLeg(List.of(new LexicalSe(lexical.getLexicalQuery())), features);
+ }
+ if (clause.hasVsearchClause()) {
+ return new VectorLeg(
+ List.of(new VectorSe(clause.sortExpressions().getFirst().getVector())), features);
+ }
+ if (clause.hasVectorizeSearchClause()) {
+ return new VectorLeg(
+ List.of(new VectorizeSe(clause.sortExpressions().getFirst().getVectorize())), features);
+ }
+ return new FilterLeg(
+ clause.sortExpressions().stream()
+ .map(
+ expression -> new FieldSe(expression.getPath(), expression.isAscending()))
+ .toList(),
+ features);
+ }
+
/**
* Validate user-supplied hybridLimits against the dynamic OperationsConfig bounds. Cannot be done
* in the Jackson deserializer because it has no access to OperationsConfig (config-file and
@@ -166,9 +202,8 @@ private static void checkSortSupported(
* vector for the vector leg.
*/
private static List multiLegs(
- FindAndRerankCommand command, CollectionSchemaObject schemaObject) {
+ FindAndRerankCommand command, MultiLeg sort, CollectionSchemaObject schemaObject) {
- var sort = command.sortClause();
if (!isVectorizeSort(sort) && !isVectorSort(sort)) {
throw isLexicalSort(sort)
? SortException.Code.MISSING_VECTOR_OR_VECTORIZE_IN_HYBRID_SORT.get()
@@ -189,7 +224,7 @@ private static List multiLegs(
legs.add(
new FindAndRerankPlan.RerankLeg(
RerankLegType.VECTOR_LEG,
- isVectorizeSort(sort) ? null : sort.vectorSort(),
+ isVectorizeSort(sort) ? null : vector(sort),
null,
hybridLimits.vectorLimit()));
@@ -258,14 +293,15 @@ private static CollectionRerankDef.RerankServiceDef resolveRerankServiceDef(
return serviceDef;
}
- private static PathMatchLocator passageLocator(FindAndRerankCommand command) {
+ private static PathMatchLocator passageLocator(
+ FindAndRerankCommand command, FindAndRerankSort sort) {
var rerankOn = getOrDefault(command.options(), FindAndRerankCommand.Options::rerankOn, null);
var isRerankOn = rerankOn != null && !rerankOn.isBlank();
String finalRerankField;
- if (isVectorizeSort(command.sortClause())) {
+ if (isVectorizeSort(sort)) {
// use the vectorize field, unless the user has overridden
finalRerankField = isRerankOn ? rerankOn : VECTOR_EMBEDDING_TEXT_FIELD;
} else if (isRerankOn) {
@@ -286,14 +322,36 @@ private static void throwIfNoLexicalIndex(CollectionSchemaObject schemaObject) {
/** The text to vectorize: the $hybrid text, or the $vectorize value, or null. */
private static String vectorizeText(FindAndRerankSort sort) {
- return sort.hybridSort() != null ? sort.hybridSort() : sort.vectorizeSort();
+ return switch (sort) {
+ case HybridText text -> text.text();
+ case HybridObject object -> object.vectorize();
+ case VectorLeg leg ->
+ switch (leg.sortExpressions().getFirst()) {
+ case VectorizeSe expression -> expression.vectorizeText();
+ case VectorSe ignored -> null;
+ case LexicalSe ignored ->
+ throw new IllegalStateException("lexical expression in vector leg");
+ case FieldSe ignored ->
+ throw new IllegalStateException("field expression in vector leg");
+ };
+ case LexicalLeg ignored -> null;
+ case FilterLeg ignored -> null;
+ case UnresolvedSingleLeg ignored -> throw new IllegalStateException("sort was not resolved");
+ };
}
/**
* The text for the lexical sort: the $hybrid text, or the $lexical value, or null.
*/
private static String lexicalText(FindAndRerankSort sort) {
- return sort.hybridSort() != null ? sort.hybridSort() : sort.lexicalSort();
+ return switch (sort) {
+ case HybridText text -> text.text();
+ case HybridObject object -> object.lexical();
+ case LexicalLeg leg -> ((LexicalSe) leg.sortExpressions().getFirst()).lexicalText();
+ case VectorLeg ignored -> null;
+ case FilterLeg ignored -> null;
+ case UnresolvedSingleLeg ignored -> throw new IllegalStateException("sort was not resolved");
+ };
}
private static boolean isLexicalSort(FindAndRerankSort sort) {
@@ -302,7 +360,14 @@ private static boolean isLexicalSort(FindAndRerankSort sort) {
/** The $hybrid object has $lexical text, so the collection must have a lexical index. */
private static boolean isExplicitLexicalSort(FindAndRerankSort sort) {
- return sort.lexicalSort() != null;
+ return switch (sort) {
+ case HybridObject object -> object.lexical() != null;
+ case LexicalLeg ignored -> true;
+ case HybridText ignored -> false;
+ case VectorLeg ignored -> false;
+ case FilterLeg ignored -> false;
+ case UnresolvedSingleLeg ignored -> throw new IllegalStateException("sort was not resolved");
+ };
}
private static boolean isVectorizeSort(FindAndRerankSort sort) {
@@ -310,6 +375,25 @@ private static boolean isVectorizeSort(FindAndRerankSort sort) {
}
private static boolean isVectorSort(FindAndRerankSort sort) {
- return sort.vectorSort() != null;
+ return vector(sort) != null;
+ }
+
+ private static float[] vector(FindAndRerankSort sort) {
+ return switch (sort) {
+ case HybridObject object -> object.vector();
+ case VectorLeg leg ->
+ switch (leg.sortExpressions().getFirst()) {
+ case VectorSe expression -> expression.vector();
+ case VectorizeSe ignored -> null;
+ case LexicalSe ignored ->
+ throw new IllegalStateException("lexical expression in vector leg");
+ case FieldSe ignored ->
+ throw new IllegalStateException("field expression in vector leg");
+ };
+ case HybridText ignored -> null;
+ case LexicalLeg ignored -> null;
+ case FilterLeg ignored -> null;
+ case UnresolvedSingleLeg ignored -> throw new IllegalStateException("sort was not resolved");
+ };
}
}
diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java
index e3a66280f2..c5110c0b85 100644
--- a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java
+++ b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java
@@ -1,5 +1,6 @@
package io.stargate.sgv2.jsonapi.api.model.command.clause.sort;
+import static io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort.*;
import static io.stargate.sgv2.jsonapi.metrics.CommandFeature.*;
import static io.stargate.sgv2.jsonapi.util.Base64Util.encodeAsMimeBase64;
import static io.stargate.sgv2.jsonapi.util.CqlVectorUtil.floatsToBytes;
@@ -12,6 +13,8 @@
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+import io.stargate.sgv2.jsonapi.exception.DocumentException;
+import io.stargate.sgv2.jsonapi.exception.SortException;
import io.stargate.sgv2.jsonapi.metrics.CommandFeatures;
import io.stargate.sgv2.jsonapi.util.recordable.PrettyPrintable;
import java.util.stream.Stream;
@@ -20,6 +23,7 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.slf4j.Logger;
@@ -43,84 +47,6 @@ void setUp() {
deserializer = new FindAndRerankSortClauseDeserializer();
}
- @Test
- public void testEqualsAndHash() {
- var value1 =
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
- "vectorize sort",
- "lexical sort",
- new float[] {1.1f, 2.2f, 3.3f},
- CommandFeatures.EMPTY);
-
- var diffVectorize =
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
- "vectorize sort 2",
- "lexical sort",
- new float[] {1.1f, 2.2f, 3.3f},
- CommandFeatures.EMPTY);
- var diffLexical =
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
- "vectorize sort",
- "lexical sort 2",
- new float[] {1.1f, 2.2f, 3.3f},
- CommandFeatures.EMPTY);
- var diffVector =
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
- "vectorize sort",
- "lexical sort",
- new float[] {1.1f, 2.2f, 3.3f, 4.4f},
- CommandFeatures.EMPTY);
-
- var diffMode =
- new FindAndRerankSort(
- RerankLegMode.SINGLE_LEG,
- null,
- "vectorize sort",
- "lexical sort",
- new float[] {1.1f, 2.2f, 3.3f},
- CommandFeatures.EMPTY);
- var diffHybridSort =
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- "hybrid sort",
- "vectorize sort",
- "lexical sort",
- new float[] {1.1f, 2.2f, 3.3f},
- CommandFeatures.EMPTY);
-
- assertThat(value1).as("Object equals self").isEqualTo(value1);
- assertThat(value1).as("different rerankLegMode").isNotEqualTo(diffMode);
- assertThat(value1).as("different hybrid sort").isNotEqualTo(diffHybridSort);
- assertThat(value1).as("different vectorize sort").isNotEqualTo(diffVectorize);
- assertThat(value1).as("different lexical sort").isNotEqualTo(diffLexical);
- assertThat(value1).as("different vector").isNotEqualTo(diffVector);
-
- assertThat(value1.hashCode()).as("hash code equals self").isEqualTo(value1.hashCode());
- assertThat(value1.hashCode())
- .as("hash code different vectorize sort")
- .isNotEqualTo(diffVectorize.hashCode());
- assertThat(value1.hashCode())
- .as("hash code different lexical sort")
- .isNotEqualTo(diffLexical.hashCode());
- assertThat(value1.hashCode())
- .as("hash code different vector")
- .isNotEqualTo(diffVector.hashCode());
- assertThat(value1.hashCode())
- .as("hash code different rerankLegMode")
- .isNotEqualTo(diffMode.hashCode());
- assertThat(value1.hashCode())
- .as("hash code different hybrid sort")
- .isNotEqualTo(diffHybridSort.hashCode());
- }
-
@ParameterizedTest
@MethodSource("validSortsTestCases")
public void validSorts(String jsonString, FindAndRerankSort expected) throws Exception {
@@ -147,7 +73,7 @@ private static Stream validSortsTestCases() {
"""
{}
""",
- FindAndRerankSort.NO_ARG_SORT),
+ new UnresolvedSingleLeg(new ObjectMapper().createObjectNode(), CommandFeatures.EMPTY)),
Arguments.of(
"""
null
@@ -159,45 +85,29 @@ private static Stream validSortsTestCases() {
"""
{ "$hybrid" : "same for hybrid and lexical" }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- "same for hybrid and lexical",
- null,
- null,
- null,
- CommandFeatures.of(HYBRID))),
+ new HybridText("same for hybrid and lexical", CommandFeatures.of(HYBRID))),
Arguments.of(
"""
{ "$hybrid" : " padded text " }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- "padded text",
- null,
- null,
- null,
- CommandFeatures.of(HYBRID))),
+ new HybridText("padded text", CommandFeatures.of(HYBRID))),
Arguments.of(
"""
{ "$hybrid" : "" }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG, null, null, null, null, CommandFeatures.of(HYBRID))),
+ new HybridText(null, CommandFeatures.of(HYBRID))),
Arguments.of(
"""
{ "$hybrid" : " " }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG, null, null, null, null, CommandFeatures.of(HYBRID))),
+ new HybridText(null, CommandFeatures.of(HYBRID))),
// ----
// maximum fields, resolver works out the valid combinations
Arguments.of(
"""
{ "$hybrid" : { "$vectorize" : "vectorize sort", "$lexical" : "lexical sort", "$vector" : [1.1, 2.2, 3.3]} }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
+ new HybridObject(
"vectorize sort",
"lexical sort",
new float[] {1.1f, 2.2f, 3.3f},
@@ -206,9 +116,7 @@ private static Stream validSortsTestCases() {
"""
{ "$hybrid" : { "$vectorize" : "vectorize sort", "$lexical" : "lexical sort"} }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
+ new HybridObject(
"vectorize sort",
"lexical sort",
null,
@@ -219,44 +127,26 @@ private static Stream validSortsTestCases() {
"""
{ "$hybrid" : { "$vectorize" : "vectorize sort", "$lexical" : null} }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
- "vectorize sort",
- null,
- null,
- CommandFeatures.of(HYBRID, VECTORIZE, LEXICAL))),
+ new HybridObject(
+ "vectorize sort", null, null, CommandFeatures.of(HYBRID, VECTORIZE, LEXICAL))),
Arguments.of(
"""
{ "$hybrid" : { "$vectorize" : "vectorize sort", "$lexical" : ""} }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
- "vectorize sort",
- null,
- null,
- CommandFeatures.of(HYBRID, VECTORIZE, LEXICAL))),
+ new HybridObject(
+ "vectorize sort", null, null, CommandFeatures.of(HYBRID, VECTORIZE, LEXICAL))),
Arguments.of(
"""
{ "$hybrid" : { "$vectorize" : "vectorize sort"} }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
- "vectorize sort",
- null,
- null,
- CommandFeatures.of(HYBRID, VECTORIZE))),
+ new HybridObject("vectorize sort", null, null, CommandFeatures.of(HYBRID, VECTORIZE))),
// ----
// $vectorize variations
Arguments.of(
"""
{ "$hybrid" : { "$vectorize" : "vectorize sort", "$lexical" : "lexical sort"} }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
+ new HybridObject(
"vectorize sort",
"lexical sort",
null,
@@ -265,44 +155,37 @@ private static Stream validSortsTestCases() {
"""
{ "$hybrid" : { "$vectorize" : null, "$lexical" : "lexical sort"} }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
- null,
- "lexical sort",
- null,
- CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE))),
+ new HybridObject(
+ null, "lexical sort", null, CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE))),
Arguments.of(
"""
{ "$hybrid" : { "$vectorize" : "", "$lexical" : "lexical sort"} }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
- null,
- "lexical sort",
- null,
- CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE))),
+ new HybridObject(
+ null, "lexical sort", null, CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE))),
+ Arguments.of(
+ """
+ { "$hybrid" : { "$vectorize" : " ", "$lexical" : "lexical sort"} }
+ """,
+ new HybridObject(
+ null, "lexical sort", null, CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE))),
Arguments.of(
"""
{ "$hybrid" : {"$lexical" : "lexical sort"} }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
- null,
- "lexical sort",
- null,
- CommandFeatures.of(HYBRID, LEXICAL))),
+ new HybridObject(null, "lexical sort", null, CommandFeatures.of(HYBRID, LEXICAL))),
// ----
// $vector variations
+ Arguments.of(
+ """
+ { "$hybrid" : { "$lexical" : "lexical", "$vector" : null} }
+ """,
+ new HybridObject(null, "lexical", null, CommandFeatures.of(HYBRID, LEXICAL, VECTOR))),
Arguments.of(
"""
{ "$hybrid" : { "$vectorize" : "vectorize", "$lexical" : "lexical", "$vector" : null} }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
+ new HybridObject(
"vectorize",
"lexical",
null,
@@ -311,9 +194,7 @@ private static Stream validSortsTestCases() {
"""
{ "$hybrid" : { "$vectorize" : "vectorize", "$lexical" : "lexical", "$vector" : [0.1, 0.2, 0.3]} }
""",
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
+ new HybridObject(
"vectorize",
"lexical",
new float[] {0.1f, 0.2f, 0.3f},
@@ -323,9 +204,7 @@ private static Stream validSortsTestCases() {
{ "$hybrid" : { "$vectorize" : "vectorize", "$lexical" : "lexical", "$vector" : {"$binary": "%s"}} }
"""
.formatted(emptyVectorBase64),
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
+ new HybridObject(
"vectorize",
"lexical",
emptyVector,
@@ -335,9 +214,7 @@ private static Stream validSortsTestCases() {
{ "$hybrid" : { "$vectorize" : "vectorize", "$lexical" : "lexical", "$vector" : {"$binary": "%s"}} }
"""
.formatted(vectorBase64),
- new FindAndRerankSort(
- RerankLegMode.MULTI_LEG,
- null,
+ new HybridObject(
"vectorize",
"lexical",
vector,
@@ -370,11 +247,6 @@ private static Stream invalidSortsTestCases() {
1
""",
"sort clause must be an object"),
- Arguments.of(
- """
- { "fake" : 1}
- """,
- "Expected fields: $hybrid. Unexpected fields: fake"),
Arguments.of(
"""
{ "$hybrid" : {"fake" : 1} }
@@ -387,8 +259,18 @@ private static Stream invalidSortsTestCases() {
{ "$hybrid" : 1}
""",
"Field $hybrid may only be of types TextNode, ObjectNode, but got: IntNode"),
+ Arguments.of(
+ """
+ { "$hybrid" : null}
+ """,
+ "Field $hybrid may only be of types TextNode, ObjectNode, but got: NullNode"),
// ----
// Combinations
+ Arguments.of(
+ """
+ { "$hybrid" : "cheese", "age" : -1 }
+ """,
+ "Expected fields: $hybrid, $vectorize, $vector, $lexical. Unexpected fields: age"),
Arguments.of(
"""
{ "$hybrid" : { "$vectorize" : 1, "$lexical" : "", "$vector" : [1.1, 2.2, 3.3]} }
@@ -410,4 +292,82 @@ private static Stream invalidSortsTestCases() {
""",
"`sort` clause (binary vector) contained fields with the wrong type. Field $vector may only be of types NullNode, ArrayNode, ObjectNode, but got: ObjectNode"));
}
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "{ \"$hybrid\" : \"cheese\", \"$vectorize\" : \"cheese\" }",
+ "{ \"$hybrid\" : \"cheese\", \"$lexical\" : \"cows\" }"
+ })
+ public void invalidSortWithTwoTopLevelFields(String jsonString) throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+ when(deserializationContext.readTree(jsonParser)).thenReturn(mapper.readTree(jsonString));
+
+ var error =
+ assertThrows(
+ SortException.class,
+ () -> deserializer.deserialize(jsonParser, deserializationContext));
+ assertThat(error.code).isEqualTo(SortException.Code.SORT_CLAUSE_INVALID.name());
+ assertThat(error.body)
+ .contains("sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"{ \"$hybrid\" : { \"$vector\" : [] } }"})
+ public void invalidEmptyVector(String jsonString) throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+ when(deserializationContext.readTree(jsonParser)).thenReturn(mapper.readTree(jsonString));
+
+ var error =
+ assertThrows(
+ DocumentException.class,
+ () -> deserializer.deserialize(jsonParser, deserializationContext));
+ assertThat(error.code).isEqualTo(DocumentException.Code.SHRED_BAD_VECTOR_SIZE.name());
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "{}",
+ "{\"age\": -1}",
+ "{\"age\": 1, \"name\": -1}",
+ "{\"$lexical\": \" cows \"}",
+ "{\"$lexical\": \" \"}",
+ "{\"$lexical\": null}",
+ "{\"$lexical\": 1}",
+ "{\"$lexical\": \"cows\", \"age\": -1}",
+ "{\"$vectorize\": \" cheese \"}",
+ "{\"$vectorize\": null}",
+ "{\"$vectorize\": 1}",
+ "{\"$vector\": [1, 2, 3]}",
+ "{\"$vector\": []}",
+ "{\"$vector\": null}",
+ "{\"$vector\": {\"$binary\": \"P4AAAEAAAABAQAAA\"}}",
+ "{\"$vector\": [1, 2, 3], \"$vectorize\": \"cheese\"}",
+ "{\"age\": 2}",
+ "{\"$unknown\": 1}"
+ })
+ void preservesSingleLegSortUntilPlanning(String jsonString) throws Exception {
+ var rawSort = new ObjectMapper().readTree(jsonString);
+ when(deserializationContext.readTree(jsonParser)).thenReturn(rawSort);
+
+ var actual = deserializer.deserialize(jsonParser, deserializationContext);
+
+ assertThat(actual).isInstanceOf(UnresolvedSingleLeg.class);
+ assertThat(((UnresolvedSingleLeg) actual).sort()).isEqualTo(rawSort);
+ }
+
+ @Test
+ void distinguishesExplicitNullFromEmptySingleLegSort() throws Exception {
+ var mapper = new ObjectMapper();
+ when(deserializationContext.readTree(jsonParser))
+ .thenReturn(mapper.readTree("{\"$lexical\": null}"), mapper.readTree("{}"));
+
+ var explicitNull = deserializer.deserialize(jsonParser, deserializationContext);
+ var empty = deserializer.deserialize(jsonParser, deserializationContext);
+
+ assertThat(explicitNull).isNotEqualTo(empty);
+ assertThat(explicitNull.commandFeatures()).isEqualTo(CommandFeatures.of(LEXICAL));
+ assertThat(empty.commandFeatures()).isEqualTo(CommandFeatures.EMPTY);
+ }
}
diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortTest.java
new file mode 100644
index 0000000000..aebb6d3f36
--- /dev/null
+++ b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortTest.java
@@ -0,0 +1,106 @@
+package io.stargate.sgv2.jsonapi.api.model.command.clause.sort;
+
+import static io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort.*;
+import static io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortExpression2.*;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import io.stargate.sgv2.jsonapi.metrics.CommandFeatures;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class FindAndRerankSortTest {
+ @Test
+ void hybridObjectComparesVectorValues() {
+ var first = new HybridObject("cheese", "cows", new float[] {1, 2, 3}, CommandFeatures.EMPTY);
+ var equal = new HybridObject("cheese", "cows", new float[] {1, 2, 3}, CommandFeatures.EMPTY);
+ var different =
+ new HybridObject("cheese", "cows", new float[] {1, 2, 4}, CommandFeatures.EMPTY);
+
+ assertThat(first).isEqualTo(equal).isNotEqualTo(different);
+ assertThat(first.hashCode()).isEqualTo(equal.hashCode());
+ }
+
+ @Test
+ void vectorExpressionComparesVectorValues() {
+ var first = new VectorSe(new float[] {1, 2, 3});
+ var equal = new VectorSe(new float[] {1, 2, 3});
+ var different = new VectorSe(new float[] {1, 2, 4});
+
+ assertThat(first).isEqualTo(equal).isNotEqualTo(different);
+ assertThat(first.hashCode()).isEqualTo(equal.hashCode());
+ }
+
+ @Test
+ void hybridVectorDoesNotChangeWhenArraysAreModified() {
+ var vector = new float[] {1, 2, 3};
+ var sort = new HybridObject(null, "cows", vector, CommandFeatures.EMPTY);
+ vector[0] = 4;
+ sort.vector()[1] = 5;
+
+ assertThat(sort.vector()).containsExactly(1, 2, 3);
+ }
+
+ @Test
+ void vectorExpressionDoesNotChangeWhenArraysAreModified() {
+ var vector = new float[] {1, 2, 3};
+ var expression = new VectorSe(vector);
+ vector[0] = 4;
+ expression.vector()[1] = 5;
+
+ assertThat(expression.vector()).containsExactly(1, 2, 3);
+ }
+
+ @Test
+ void lexicalLegRequiresOneLexicalExpression() {
+ assertThrows(
+ IllegalArgumentException.class, () -> new LexicalLeg(List.of(), CommandFeatures.EMPTY));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new LexicalLeg(List.of(new FieldSe("age", true)), CommandFeatures.EMPTY));
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new LexicalLeg(
+ List.of(new LexicalSe("cows"), new LexicalSe("cheese")), CommandFeatures.EMPTY));
+ }
+
+ @Test
+ void vectorLegRequiresOneVectorOrVectorizeExpression() {
+ assertThrows(
+ IllegalArgumentException.class, () -> new VectorLeg(List.of(), CommandFeatures.EMPTY));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new VectorLeg(List.of(new LexicalSe("cows")), CommandFeatures.EMPTY));
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new VectorLeg(
+ List.of(new VectorSe(new float[] {1}), new VectorizeSe("cheese")),
+ CommandFeatures.EMPTY));
+ }
+
+ @Test
+ void filterLegRejectsSearchExpressions() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new FilterLeg(
+ List.of(new FieldSe("age", true), new LexicalSe("cows")), CommandFeatures.EMPTY));
+ assertThat(new FilterLeg(List.of(), CommandFeatures.EMPTY).sortExpressions()).isEmpty();
+ }
+
+ @Test
+ void legOwnsItsExpressionList() {
+ var expressions = new ArrayList();
+ expressions.add(new FieldSe("age", true));
+ var leg = new FilterLeg(expressions, CommandFeatures.EMPTY);
+ expressions.add(new FieldSe("name", false));
+
+ assertThat(leg.sortExpressions()).containsExactly(new FieldSe("age", true));
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> leg.sortExpressions().add(new FieldSe("name", false)));
+ }
+}
diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankRequestTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankRequestTest.java
new file mode 100644
index 0000000000..79d4466ad3
--- /dev/null
+++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankRequestTest.java
@@ -0,0 +1,46 @@
+package io.stargate.sgv2.jsonapi.api.v1;
+
+import static io.restassured.RestAssured.given;
+import static io.stargate.sgv2.jsonapi.api.v1.ResponseAssertions.responseIsError;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.TestProfile;
+import io.restassured.http.ContentType;
+import io.stargate.sgv2.jsonapi.config.constants.HttpConstants;
+import io.stargate.sgv2.jsonapi.exception.SortException;
+import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+@QuarkusTest
+@TestProfile(NoGlobalResourcesTestProfile.Impl.class)
+class FindAndRerankRequestTest {
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "{ \"$hybrid\" : \"cheese\", \"$lexical\" : \"cows\" }",
+ "{ \"$hybrid\" : \"cheese\", \"$vectorize\" : \"cheese\" }"
+ })
+ void failOnHybridSortWithOtherTopLevelFields(String sort) {
+ // The request fails during deserialization before any database access.
+ given()
+ .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, "test-token")
+ .contentType(ContentType.JSON)
+ .body("{\"findAndRerank\": {\"sort\": %s}}".formatted(sort))
+ .when()
+ .post(CollectionResource.BASE_PATH, "keyspace", "collection")
+ .then()
+ .statusCode(200)
+ .body("$", responseIsError())
+ .body("errors", hasSize(1))
+ .body("errors[0].errorCode", is(SortException.Code.SORT_CLAUSE_INVALID.name()))
+ .body(
+ "errors[0].message",
+ containsString(
+ "sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical"));
+ }
+}
diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlannerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlannerTest.java
index 9d078e36ac..f256c20bd1 100644
--- a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlannerTest.java
+++ b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlannerTest.java
@@ -2,8 +2,12 @@
import static io.stargate.sgv2.jsonapi.TestConstants.rerankingModelConfig;
import static io.stargate.sgv2.jsonapi.TestConstants.rerankingProvidersConfig;
+import static io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort.*;
+import static io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortExpression2.*;
import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.VECTOR_EMBEDDING_TEXT_FIELD;
+import static io.stargate.sgv2.jsonapi.metrics.CommandFeature.*;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -13,6 +17,7 @@
import io.stargate.sgv2.jsonapi.api.model.command.CommandConfig;
import io.stargate.sgv2.jsonapi.api.model.command.CommandContext;
import io.stargate.sgv2.jsonapi.api.model.command.CommandName;
+import io.stargate.sgv2.jsonapi.api.model.command.builders.CollectionSortClauseBuilder;
import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand;
import io.stargate.sgv2.jsonapi.config.IntConfigWithBounds;
import io.stargate.sgv2.jsonapi.config.OperationsConfig;
@@ -24,6 +29,8 @@
import io.stargate.sgv2.jsonapi.exception.SchemaException;
import io.stargate.sgv2.jsonapi.exception.SortException;
import io.stargate.sgv2.jsonapi.exception.UpdateException;
+import io.stargate.sgv2.jsonapi.metrics.CommandFeature;
+import io.stargate.sgv2.jsonapi.metrics.CommandFeatures;
import io.stargate.sgv2.jsonapi.service.operation.reranking.RerankingQuery;
import io.stargate.sgv2.jsonapi.service.provider.ApiModelSupport;
import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionRerankDef;
@@ -35,6 +42,8 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
/**
* Tests for {@link FindAndRerankPlanner}: the legs it plans for each kind of sort, and the error
@@ -304,6 +313,38 @@ static Stream sortErrorCases() {
{"$hybrid": {"$lexical": "cows", "$vector": null}}
""",
SortException.Code.MISSING_VECTOR_OR_VECTORIZE_IN_HYBRID_SORT),
+ Arguments.of(
+ "$vectorize single leg",
+ "{\"$vectorize\": \"cheese\"}",
+ SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT),
+ Arguments.of(
+ "$vector single leg",
+ "{\"$vector\": [0.1, 0.2, 0.3]}",
+ SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT),
+ Arguments.of(
+ "$lexical single leg",
+ "{\"$lexical\": \"cows\"}",
+ SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT),
+ Arguments.of(
+ "$vectorize single leg null",
+ "{\"$vectorize\": null}",
+ SortException.Code.SORT_CLAUSE_VALUE_INVALID),
+ Arguments.of(
+ "$vector single leg null",
+ "{\"$vector\": null}",
+ SortException.Code.SORT_CLAUSE_VALUE_INVALID),
+ Arguments.of(
+ "$lexical single leg null",
+ "{\"$lexical\": null}",
+ SortException.Code.SORT_CLAUSE_INVALID),
+ Arguments.of(
+ "single field sort",
+ "{\"age\": -1}",
+ SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT),
+ Arguments.of(
+ "multiple field sorts",
+ "{\"age\": 1, \"name\": -1}",
+ SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT),
Arguments.of("no sort", null, SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT),
Arguments.of(
"sort empty",
@@ -319,6 +360,90 @@ static Stream sortErrorCases() {
SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT));
}
+ @ParameterizedTest
+ @MethodSource("resolvedSingleLegCases")
+ void resolvesSingleLegSortWithFindRules(String json, SingleLeg expected) throws Exception {
+ var sort = (UnresolvedSingleLeg) command(json, RERANK_OPTIONS).sortClause();
+
+ var actual = FindAndRerankPlanner.resolveSingleLeg(sort, schema(Collection.VECTORIZE_LEXICAL));
+
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ static Stream resolvedSingleLegCases() {
+ return Stream.of(
+ Arguments.of("{}", new FilterLeg(List.of(), CommandFeatures.EMPTY)),
+ Arguments.of(
+ "{\"age\": -1}",
+ new FilterLeg(List.of(new FieldSe("age", false)), CommandFeatures.EMPTY)),
+ Arguments.of(
+ "{\"age\": 1, \"name\": -1}",
+ new FilterLeg(
+ List.of(new FieldSe("age", true), new FieldSe("name", false)),
+ CommandFeatures.EMPTY)),
+ Arguments.of(
+ "{\"$lexical\": \" cows \"}",
+ new LexicalLeg(List.of(new LexicalSe(" cows ")), CommandFeatures.of(LEXICAL))),
+ Arguments.of(
+ "{\"$lexical\": \" \"}",
+ new LexicalLeg(List.of(new LexicalSe(" ")), CommandFeatures.of(LEXICAL))),
+ Arguments.of(
+ "{\"$vectorize\": \" cheese \"}",
+ new VectorLeg(List.of(new VectorizeSe(" cheese ")), CommandFeatures.of(VECTORIZE))),
+ Arguments.of(
+ "{\"$vector\": [0.1, 0.2, 0.3]}",
+ new VectorLeg(
+ List.of(new VectorSe(VECTOR)), CommandFeatures.of(CommandFeature.VECTOR))),
+ Arguments.of(
+ "{\"$vector\": {\"$binary\": \"PczMzT5MzM0+mZma\"}}",
+ new VectorLeg(
+ List.of(new VectorSe(VECTOR)), CommandFeatures.of(CommandFeature.VECTOR))));
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "{\"$lexical\": null}",
+ "{\"$lexical\": 1}",
+ "{\"$lexical\": \"cows\", \"age\": -1}",
+ "{\"$vectorize\": null}",
+ "{\"$vectorize\": \" \"}",
+ "{\"$vectorize\": 1}",
+ "{\"$vector\": null}",
+ "{\"$vector\": 1}",
+ "{\"$vector\": []}",
+ "{\"$vector\": [1, \"bad\"]}",
+ "{\"$vector\": {\"$date\": \"2026-01-01\"}}",
+ "{\"$vector\": [1, 2, 3], \"$vectorize\": \"cheese\"}",
+ "{\"$vectorize\": \"cheese\", \"$lexical\": \"cows\"}",
+ "{\"age\": 0}",
+ "{\"age\": 2}",
+ "{\"age\": -2}",
+ "{\"age\": 1.0}",
+ "{\"age\": true}",
+ "{\"age\": null}",
+ "{\"$unknown\": 1}"
+ })
+ void rejectsInvalidSingleLegSortWithFindError(String json) throws Exception {
+ var schema = schema(Collection.VECTORIZE_LEXICAL);
+ var rawSort = objectMapper.readTree(json);
+ var expected =
+ assertThrows(
+ APIException.class, () -> new CollectionSortClauseBuilder(schema).build(rawSort));
+
+ var actual =
+ assertThrows(
+ APIException.class,
+ () ->
+ FindAndRerankPlanner.plan(
+ command(json, RERANK_OPTIONS), commandContext(Collection.VECTORIZE_LEXICAL)));
+
+ assertThat(actual.getClass()).isEqualTo(expected.getClass());
+ assertThat(actual.code).isEqualTo(expected.code);
+ assertThat(actual.title).isEqualTo(expected.title);
+ assertThat(actual.body).isEqualTo(expected.body);
+ }
+
// ===========================================================================================
// Sorts the collection cannot run
// ===========================================================================================
@@ -610,6 +735,18 @@ void reportsMissingRerankQueryBeforeHybridSortWithNoValue() throws Exception {
RequestException.Code.MISSING_RERANK_QUERY_TEXT);
}
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = {"{}", "null"})
+ void reportsMissingRerankQueryWhenSortIsEmptyOrMissing(String sort) throws Exception {
+ assertPlanFails(
+ "no rerank query and no sort expressions",
+ command(sort, null),
+ Collection.VECTOR_LEXICAL,
+ RequestException.class,
+ RequestException.Code.MISSING_RERANK_QUERY_TEXT);
+ }
+
// ===========================================================================================
// Whole plan
// ===========================================================================================