From 3cb096a7fcf82b2dbd10a7e159e396056761eca0 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 15 Oct 2026 10:22:03 -0700 Subject: [PATCH 1/5] Fix #2601 parse single-leg findAndRerank sorts --- .../FindAndRerankSortClauseDeserializer.java | 282 ++++++++++-------- ...ndAndRerankSortClauseDeserializerTest.java | 138 +++++++-- 2 files changed, 270 insertions(+), 150 deletions(-) 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..097315766e 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 @@ -22,20 +22,29 @@ /** * {@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. + *

Note: There is no validation, this is handled when resolving the command to an operation. The + * sort records what the user wrote: the text of {"$hybrid": "text"} is kept as the + * hybrid text, the planner decides which legs read it. */ 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 + // top level has exactly one of $hybrid, $vectorize, $vector or $lexical // { "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()); + // { "sort" : { "$vectorize" : "i like cheese" } } + // { "sort" : { "$vector" : [1, 2, 3] } } + // { "sort" : { "$lexical" : "cows" } } + private static final JsonFieldMatcher MATCH_SORT_CLAUSE_FIELDS = + new JsonFieldMatcher<>( + JsonNode.class, + List.of(), + List.of( + HYBRID_FIELD, + VECTOR_EMBEDDING_TEXT_FIELD, + VECTOR_EMBEDDING_FIELD, + LEXICAL_CONTENT_FIELD)); // user is specifying the sorts individually // can be @@ -72,8 +81,9 @@ public FindAndRerankSort deserialize( * Deserialize the sort clause from an object node. * * @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. + * @return The {@link FindAndRerankSort} that reflects the request without validation. For example + * a sort with both $vectorize and $vector keeps both values, and + * the planner then validates. */ private static FindAndRerankSort deserialize(JsonParser jsonParser, ObjectNode sort) throws JsonMappingException { @@ -83,138 +93,150 @@ private static FindAndRerankSort deserialize(JsonParser jsonParser, ObjectNode s return FindAndRerankSort.NO_ARG_SORT; } - var hybridMatch = MATCH_HYBRID_FIELD.matchAndThrow(sort, jsonParser, ERROR_CONTEXT); - - return switch (hybridMatch.matched().get(HYBRID_FIELD)) { - case TextNode textNode -> - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - normalizedText(textNode.asText()), - null, - null, - null, - CommandFeatures.of(CommandFeature.HYBRID)); - case ObjectNode objectNode -> deserializeHybridObject(jsonParser, objectNode); - case JsonNode node -> - throw JsonFieldMatcher.errorForWrongType( - jsonParser, ERROR_CONTEXT, HYBRID_FIELD, node, TextNode.class, ObjectNode.class); - }; + var sortMatch = MATCH_SORT_CLAUSE_FIELDS.matchAndThrow(sort, jsonParser, ERROR_CONTEXT); + if (sortMatch.matched().size() != 1) { + throw new JsonMappingException( + jsonParser, + "sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical", + jsonParser.currentLocation()); + } + + if (sortMatch.matched().containsKey(HYBRID_FIELD)) { + return switch (sortMatch.matched().get(HYBRID_FIELD)) { + case TextNode textNode -> + new FindAndRerankSort( + RerankLegMode.MULTI_LEG, normalizedText(textNode.asText()), null, null, null, + CommandFeatures.of(CommandFeature.HYBRID)); + case ObjectNode objectNode -> deserializeHybridObject(jsonParser, objectNode); + case JsonNode node -> + throw JsonFieldMatcher.errorForWrongType( + jsonParser, ERROR_CONTEXT, HYBRID_FIELD, node, TextNode.class, ObjectNode.class); + }; + } + if (sortMatch.matched().containsKey(VECTOR_EMBEDDING_TEXT_FIELD)) { + var commandFeatures = CommandFeatures.create(); + var vectorizeText = + textSortValue( + jsonParser, + VECTOR_EMBEDDING_TEXT_FIELD, + sortMatch.matched().get(VECTOR_EMBEDDING_TEXT_FIELD), + commandFeatures, + CommandFeature.VECTORIZE); + return new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, vectorizeText, null, null, commandFeatures); + } + if (sortMatch.matched().containsKey(LEXICAL_CONTENT_FIELD)) { + var commandFeatures = CommandFeatures.create(); + var lexicalText = + textSortValue( + jsonParser, + LEXICAL_CONTENT_FIELD, + sortMatch.matched().get(LEXICAL_CONTENT_FIELD), + commandFeatures, + CommandFeature.LEXICAL); + return new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, null, lexicalText, null, commandFeatures); + } + // only $vector is left, the matcher has checked the field names + var commandFeatures = CommandFeatures.create(); + var vector = + vectorSortValue( + jsonParser, sortMatch.matched().get(VECTOR_EMBEDDING_FIELD), commandFeatures); + return new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, null, null, vector, commandFeatures); } private static FindAndRerankSort deserializeHybridObject( JsonParser jsonParser, ObjectNode hybridObject) throws JsonMappingException { var sortMatch = MATCH_SORT_FIELDS.matchAndThrow(hybridObject, jsonParser, ERROR_CONTEXT); + // NOTE: commandFeatures are only for metrics, the FindAndRerankPlanner decides what to run + // from the rerankLegMode and the sort values CommandFeatures commandFeatures = CommandFeatures.of(CommandFeature.HYBRID); 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, + VECTOR_EMBEDDING_TEXT_FIELD, + sortMatch.matched().get(VECTOR_EMBEDDING_TEXT_FIELD), + commandFeatures, + CommandFeature.VECTORIZE); 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, + LEXICAL_CONTENT_FIELD, + sortMatch.matched().get(LEXICAL_CONTENT_FIELD), + commandFeatures, + CommandFeature.LEXICAL); 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(VECTOR_EMBEDDING_FIELD), commandFeatures); + + return new FindAndRerankSort(RerankLegMode.MULTI_LEG, null, vectorizeText, lexicalText, vector, commandFeatures); + } + + // a text sort value, null when the field is missing, null or blank + private static String textSortValue( + JsonParser jsonParser, + String field, + JsonNode node, + CommandFeatures commandFeatures, + CommandFeature feature) + throws JsonMappingException { + return switch (node) { + case null -> null; + case NullNode ignored -> { + commandFeatures.addFeature(feature); + yield null; + } + case TextNode textNode -> { + commandFeatures.addFeature(feature); + yield normalizedText(textNode.asText().trim()); + } + case JsonNode wrongType -> + throw JsonFieldMatcher.errorForWrongType( + jsonParser, ERROR_CONTEXT, field, wrongType, NullNode.class, TextNode.class); + }; + } + + // a $vector sort value, null when the field is missing or null + private static float[] vectorSortValue( + JsonParser jsonParser, JsonNode node, CommandFeatures commandFeatures) + throws JsonMappingException { + return switch (node) { + case null -> null; + case NullNode ignored -> { + commandFeatures.addFeature(CommandFeature.VECTOR); + yield null; + } + case ArrayNode arrayNode -> { + commandFeatures.addFeature(CommandFeature.VECTOR); + yield arrayNodeToVector(arrayNode); + } + case ObjectNode objectNode -> { + // binary vector, {"$binary": "c3VyZS4="} + var ejson = EJSONWrapper.maybeFrom(objectNode); + if (ejson == null || ejson.type() != EJSONWrapper.EJSONType.BINARY) { + // 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 wrongType -> + throw JsonFieldMatcher.errorForWrongType( + jsonParser, + ERROR_CONTEXT, + VECTOR_EMBEDDING_FIELD, + wrongType, + NullNode.class, + ArrayNode.class, + ObjectNode.class); + }; } /** 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..b53b699508 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 @@ -166,29 +166,12 @@ private static Stream validSortsTestCases() { null, null, CommandFeatures.of(HYBRID))), - Arguments.of( - """ - { "$hybrid" : " padded text " } - """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - "padded text", - null, - null, - null, - CommandFeatures.of(HYBRID))), Arguments.of( """ { "$hybrid" : "" } """, new FindAndRerankSort( RerankLegMode.MULTI_LEG, null, null, null, null, CommandFeatures.of(HYBRID))), - Arguments.of( - """ - { "$hybrid" : " " } - """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, null, null, null, null, CommandFeatures.of(HYBRID))), // ---- // maximum fields, resolver works out the valid combinations Arguments.of( @@ -283,6 +266,17 @@ private static Stream validSortsTestCases() { "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))), Arguments.of( """ { "$hybrid" : {"$lexical" : "lexical sort"} } @@ -296,6 +290,17 @@ private static Stream validSortsTestCases() { CommandFeatures.of(HYBRID, LEXICAL))), // ---- // $vector variations + Arguments.of( + """ + { "$hybrid" : { "$lexical" : "lexical", "$vector" : null} } + """, + new FindAndRerankSort( + RerankLegMode.MULTI_LEG, + null, + null, + "lexical", + null, + CommandFeatures.of(HYBRID, LEXICAL, VECTOR))), Arguments.of( """ { "$hybrid" : { "$vectorize" : "vectorize", "$lexical" : "lexical", "$vector" : null} } @@ -341,7 +346,68 @@ private static Stream validSortsTestCases() { "vectorize", "lexical", vector, - CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE, VECTOR)))); + CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE, VECTOR))), + // ---- + // single leg sorts + Arguments.of( + """ + { "$vectorize" : "vectorize sort" } + """, + new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, + null, + "vectorize sort", + null, + null, + CommandFeatures.of(VECTORIZE))), + Arguments.of( + """ + { "$vectorize" : "" } + """, + new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.of(VECTORIZE))), + Arguments.of( + """ + { "$vectorize" : null } + """, + new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.of(VECTORIZE))), + Arguments.of( + """ + { "$lexical" : "lexical sort" } + """, + new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, null, null, "lexical sort", null, CommandFeatures.of(LEXICAL))), + Arguments.of( + """ + { "$lexical" : " " } + """, + new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.of(LEXICAL))), + Arguments.of( + """ + { "$vector" : [1.1, 2.2, 3.3] } + """, + new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, + null, + null, + null, + new float[] {1.1f, 2.2f, 3.3f}, + CommandFeatures.of(VECTOR))), + Arguments.of( + """ + { "$vector" : {"$binary": "%s"} } + """ + .formatted(vectorBase64), + new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, null, null, null, vector, CommandFeatures.of(VECTOR))), + Arguments.of( + """ + { "$vector" : null } + """, + new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.of(VECTOR)))); } @ParameterizedTest @@ -374,7 +440,7 @@ private static Stream invalidSortsTestCases() { """ { "fake" : 1} """, - "Expected fields: $hybrid. Unexpected fields: fake"), + "Expected fields: $hybrid, $vectorize, $vector, $lexical. Unexpected fields: fake"), Arguments.of( """ { "$hybrid" : {"fake" : 1} } @@ -387,6 +453,11 @@ 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( @@ -408,6 +479,33 @@ private static Stream invalidSortsTestCases() { """ { "$hybrid" : { "$vector" : "", "$lexical" : "", "$vector" : {"$date": "2023-01-01"}} } """, - "`sort` clause (binary vector) contained fields with the wrong type. Field $vector may only be of types NullNode, ArrayNode, ObjectNode, but got: ObjectNode")); + "`sort` clause (binary vector) contained fields with the wrong type. Field $vector may only be of types NullNode, ArrayNode, ObjectNode, but got: ObjectNode"), + // ---- + // single leg sorts + Arguments.of( + """ + { "$vectorize" : "cheese", "$lexical" : "cows" } + """, + "sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical"), + Arguments.of( + """ + { "$hybrid" : "cheese", "$vectorize" : "cheese" } + """, + "sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical"), + Arguments.of( + """ + { "$vectorize" : 1 } + """, + "Field $vectorize may only be of types NullNode, TextNode, but got: IntNode"), + Arguments.of( + """ + { "$lexical" : 1 } + """, + "Field $lexical may only be of types NullNode, TextNode, but got: IntNode"), + Arguments.of( + """ + { "$vector" : 1 } + """, + "Field $vector may only be of types NullNode, ArrayNode, ObjectNode, but got: IntNode")); } } From 8a0bacdaa6212cdc1d2ab049bc0c4743243131b5 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 16 Oct 2026 15:44:12 -0700 Subject: [PATCH 2/5] Address review on single-leg findAndRerank sort parsing --- .../FindAndRerankSortClauseDeserializer.java | 244 ++++++++++++------ ...ndAndRerankSortClauseDeserializerTest.java | 45 +++- .../resolver/FindAndRerankPlannerTest.java | 6 + 3 files changed, 200 insertions(+), 95 deletions(-) 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 097315766e..ee20255f66 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; @@ -31,32 +35,37 @@ public class FindAndRerankSortClauseDeserializer extends StdDeserializer MATCH_SORT_CLAUSE_FIELDS = + private static final JsonFieldMatcher MATCH_TOP_LEVEL_SORT_FIELDS = new JsonFieldMatcher<>( JsonNode.class, List.of(), List.of( - HYBRID_FIELD, - VECTOR_EMBEDDING_TEXT_FIELD, - VECTOR_EMBEDDING_FIELD, - LEXICAL_CONTENT_FIELD)); + 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); @@ -83,7 +92,9 @@ public FindAndRerankSort deserialize( * @param sort The sort clause as an object node, no check for nulls. * @return The {@link FindAndRerankSort} that reflects the request without validation. For example * a sort with both $vectorize and $vector keeps both values, and - * the planner then validates. + * the planner then validates. A single-leg sort with no value, e.g. + * {"$lexical": null}, is recorded as that mode with no value, the planner rejects it + * (for now every single-leg mode fails with UNSUPPORTED_FIND_AND_RERANK_SORT, GH #2602). */ private static FindAndRerankSort deserialize(JsonParser jsonParser, ObjectNode sort) throws JsonMappingException { @@ -93,145 +104,177 @@ private static FindAndRerankSort deserialize(JsonParser jsonParser, ObjectNode s return FindAndRerankSort.NO_ARG_SORT; } - var sortMatch = MATCH_SORT_CLAUSE_FIELDS.matchAndThrow(sort, jsonParser, ERROR_CONTEXT); + var sortMatch = MATCH_TOP_LEVEL_SORT_FIELDS.matchAndThrow(sort, jsonParser, ERROR_CONTEXT); if (sortMatch.matched().size() != 1) { - throw new JsonMappingException( - jsonParser, - "sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical", - jsonParser.currentLocation()); + 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()))); } - if (sortMatch.matched().containsKey(HYBRID_FIELD)) { - return switch (sortMatch.matched().get(HYBRID_FIELD)) { - case TextNode textNode -> - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, normalizedText(textNode.asText()), null, null, null, - CommandFeatures.of(CommandFeature.HYBRID)); - case ObjectNode objectNode -> deserializeHybridObject(jsonParser, objectNode); - case JsonNode node -> - throw JsonFieldMatcher.errorForWrongType( - jsonParser, ERROR_CONTEXT, HYBRID_FIELD, node, TextNode.class, ObjectNode.class); - }; - } - if (sortMatch.matched().containsKey(VECTOR_EMBEDDING_TEXT_FIELD)) { - var commandFeatures = CommandFeatures.create(); - var vectorizeText = - textSortValue( - jsonParser, - VECTOR_EMBEDDING_TEXT_FIELD, - sortMatch.matched().get(VECTOR_EMBEDDING_TEXT_FIELD), - commandFeatures, - CommandFeature.VECTORIZE); - return new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, vectorizeText, null, null, commandFeatures); - } - if (sortMatch.matched().containsKey(LEXICAL_CONTENT_FIELD)) { - var commandFeatures = CommandFeatures.create(); - var lexicalText = - textSortValue( - jsonParser, - LEXICAL_CONTENT_FIELD, - sortMatch.matched().get(LEXICAL_CONTENT_FIELD), - commandFeatures, - CommandFeature.LEXICAL); - return new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, null, lexicalText, null, commandFeatures); - } - // only $vector is left, the matcher has checked the field names - var commandFeatures = CommandFeatures.create(); - var vector = - vectorSortValue( - jsonParser, sortMatch.matched().get(VECTOR_EMBEDDING_FIELD), commandFeatures); - return new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, null, null, vector, commandFeatures); + // the matcher has checked the field name, so fromSortField cannot fail here + var topLevelField = sortMatch.matched().entrySet().iterator().next(); + var sortField = SortField.fromSortField(topLevelField.getKey()); + var topLevelValue = topLevelField.getValue(); + + return switch (sortField) { + case HYBRID -> + switch (topLevelValue) { + case TextNode textNode -> + new FindAndRerankSort(RerankLegMode.MULTI_LEG, normalizedText(textNode.asText()), null, null, null, CommandFeatures.of(SortField.HYBRID.commandFeature())); + case ObjectNode objectNode -> deserializeHybridObject(jsonParser, objectNode); + case JsonNode node -> + throw JsonFieldMatcher.errorForWrongType( + jsonParser, + ERROR_CONTEXT, + SortField.HYBRID.sortField(), + node, + TextNode.class, + ObjectNode.class); + }; + case VECTORIZE -> { + var commandFeatures = CommandFeatures.create(); + yield new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, textSortValue(jsonParser, sortField, topLevelValue, commandFeatures), null, null, commandFeatures); + } + case LEXICAL -> { + var commandFeatures = CommandFeatures.create(); + yield new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, null, textSortValue(jsonParser, sortField, topLevelValue, commandFeatures), null, commandFeatures); + } + case VECTOR -> { + var commandFeatures = CommandFeatures.create(); + yield new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, null, null, vectorSortValue(jsonParser, topLevelValue, commandFeatures), commandFeatures); + } + }; } private static FindAndRerankSort deserializeHybridObject( JsonParser jsonParser, ObjectNode hybridObject) throws JsonMappingException { - var sortMatch = MATCH_SORT_FIELDS.matchAndThrow(hybridObject, jsonParser, ERROR_CONTEXT); + 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 rerankLegMode and the sort values - CommandFeatures commandFeatures = CommandFeatures.of(CommandFeature.HYBRID); + CommandFeatures commandFeatures = CommandFeatures.of(SortField.HYBRID.commandFeature()); var vectorizeText = textSortValue( jsonParser, - VECTOR_EMBEDDING_TEXT_FIELD, - sortMatch.matched().get(VECTOR_EMBEDDING_TEXT_FIELD), - commandFeatures, - CommandFeature.VECTORIZE); + SortField.VECTORIZE, + sortMatch.matched().get(SortField.VECTORIZE.sortField()), + commandFeatures); var lexicalText = textSortValue( jsonParser, - LEXICAL_CONTENT_FIELD, - sortMatch.matched().get(LEXICAL_CONTENT_FIELD), - commandFeatures, - CommandFeature.LEXICAL); + SortField.LEXICAL, + sortMatch.matched().get(SortField.LEXICAL.sortField()), + commandFeatures); var vector = vectorSortValue( - jsonParser, sortMatch.matched().get(VECTOR_EMBEDDING_FIELD), commandFeatures); + jsonParser, sortMatch.matched().get(SortField.VECTOR.sortField()), commandFeatures); return new FindAndRerankSort(RerankLegMode.MULTI_LEG, null, vectorizeText, lexicalText, vector, commandFeatures); } - // a text sort value, null when the field is missing, null or blank + /** + * A text sort value for {@code sortField}'s field, from inside the {@code $hybrid} object or from a + * top-level single-leg sort. 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. Inside + * {@code $hybrid} that skips the leg, at the top level it is a single-leg sort with no value, + * the planner rejects it. + */ private static String textSortValue( - JsonParser jsonParser, - String field, - JsonNode node, - CommandFeatures commandFeatures, - CommandFeature feature) + JsonParser jsonParser, SortField sortField, JsonNode node, CommandFeatures commandFeatures) throws JsonMappingException { return switch (node) { - case null -> null; + case null -> { + // undefined in the object, up to the resolver to check if valid + // { "sort" : { "$hybrid" : { }} + yield null; + } case NullNode ignored -> { - commandFeatures.addFeature(feature); + // explicit setting to null is allowed + // { "sort" : { "$hybrid" : { "$vectorize" : null } } } or { "sort" : { "$lexical" : null } + // } + commandFeatures.addFeature(sortField.commandFeature()); yield null; } case TextNode textNode -> { - commandFeatures.addFeature(feature); - yield normalizedText(textNode.asText().trim()); + // { "sort" : { "$hybrid" : { "$vectorize" : "I like cheese" } } } or + // { "sort" : { "$lexical" : "cheese" } } + commandFeatures.addFeature(sortField.commandFeature()); + yield normalizedText(textNode.asText()); } case JsonNode wrongType -> throw JsonFieldMatcher.errorForWrongType( - jsonParser, ERROR_CONTEXT, field, wrongType, NullNode.class, TextNode.class); + jsonParser, + ERROR_CONTEXT, + sortField.sortField(), + wrongType, + NullNode.class, + TextNode.class); }; } - // a $vector sort value, null when the field is missing or null + /** + * A {@code $vector} sort value, from inside the {@code $hybrid} object or from a top-level + * single-leg sort. + * + * @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 -> null; + case null -> { + // undefined in the object, up to the resolver to check if valid + // { "sort" : { "$hybrid" : { }} + yield null; + } case NullNode ignored -> { - commandFeatures.addFeature(CommandFeature.VECTOR); + // explicit setting to null is allowed + // { "sort" : { "$hybrid" : { "$vector" : null } } } or { "sort" : { "$vector" : null } } + commandFeatures.addFeature(SortField.VECTOR.commandFeature()); yield null; } case ArrayNode arrayNode -> { - commandFeatures.addFeature(CommandFeature.VECTOR); + // { "sort" : { "$hybrid" : { "$vector" : [1,2,3] } } } or { "sort" : { "$vector" : [] } } + commandFeatures.addFeature(SortField.VECTOR.commandFeature()); yield arrayNodeToVector(arrayNode); } case ObjectNode objectNode -> { - // binary vector, {"$binary": "c3VyZS4="} + // 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, + SortField.VECTOR.sortField(), objectNode, NullNode.class, ArrayNode.class, ObjectNode.class); } - commandFeatures.addFeature(CommandFeature.VECTOR); + commandFeatures.addFeature(SortField.VECTOR.commandFeature()); yield ejson.getVectorValueForBinary(); } case JsonNode wrongType -> throw JsonFieldMatcher.errorForWrongType( jsonParser, ERROR_CONTEXT, - VECTOR_EMBEDDING_FIELD, + SortField.VECTOR.sortField(), wrongType, NullNode.class, ArrayNode.class, @@ -239,6 +282,37 @@ private static float[] vectorSortValue( }; } + /** 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; + } + + static SortField fromSortField(String sortField) { + return Arrays.stream(values()) + .filter(field -> field.sortField.equals(sortField)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("unknown sort field: " + sortField)); + } + } + /** * Normalise the sort string from the user. * 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 b53b699508..e272379974 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 @@ -14,6 +14,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.util.recordable.PrettyPrintable; +import io.stargate.sgv2.jsonapi.exception.DocumentException; +import io.stargate.sgv2.jsonapi.exception.SortException; +import org.junit.jupiter.params.provider.ValueSource; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -482,16 +485,6 @@ 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"), // ---- // single leg sorts - Arguments.of( - """ - { "$vectorize" : "cheese", "$lexical" : "cows" } - """, - "sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical"), - Arguments.of( - """ - { "$hybrid" : "cheese", "$vectorize" : "cheese" } - """, - "sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical"), Arguments.of( """ { "$vectorize" : 1 } @@ -508,4 +501,36 @@ private static Stream invalidSortsTestCases() { """, "Field $vector may only be of types NullNode, ArrayNode, ObjectNode, but got: IntNode")); } + + @ParameterizedTest + @ValueSource( + strings = { + "{ \"$vectorize\" : \"cheese\", \"$lexical\" : \"cows\" }", + "{ \"$hybrid\" : \"cheese\", \"$vectorize\" : \"cheese\" }" + }) + 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 = {"{ \"$vector\" : [] }", "{ \"$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()); + } } 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..6a3cca0f39 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 @@ -304,6 +304,12 @@ static Stream sortErrorCases() { {"$hybrid": {"$lexical": "cows", "$vector": null}} """, SortException.Code.MISSING_VECTOR_OR_VECTORIZE_IN_HYBRID_SORT), + Arguments.of("$vectorize single leg False", "{\"$vectorize\": \"cheese\"}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + Arguments.of("$vector single leg False", "{\"$vector\": [0.1, 0.2, 0.3]}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + Arguments.of("$lexical single leg False", "{\"$lexical\": \"cows\"}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + Arguments.of("$vectorize single leg True", "{\"$vectorize\": null}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + Arguments.of("$vector single leg True", "{\"$vector\": null}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + Arguments.of("$lexical single leg True", "{\"$lexical\": null}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), Arguments.of("no sort", null, SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), Arguments.of( "sort empty", From 9216994698f60dd7ee17e2b9ccdf984fbdd65987 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 16 Oct 2026 20:09:07 -0700 Subject: [PATCH 3/5] test: verify findAndRerank sort errors over HTTP --- .../FindAndRerankSortClauseDeserializer.java | 13 +++--- .../api/v1/FindAndRerankRequestTest.java | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankRequestTest.java 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 ee20255f66..31cb4287a7 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 @@ -26,9 +26,10 @@ /** * {@link StdDeserializer} for the {@link FindAndRerankSort}. * - *

Note: There is no validation, this is handled when resolving the command to an operation. The - * sort records what the user wrote: the text of {"$hybrid": "text"} is kept as the - * hybrid text, the planner decides which legs read it. + *

Checks the JSON structure and value types. A nonempty sort must have exactly one top-level + * field to select its mode. The planner validates the values against the collection and decides + * which legs run. For example, the text of {"$hybrid": "text"} is kept as hybrid text + * for the planner to use. */ public class FindAndRerankSortClauseDeserializer extends StdDeserializer { @@ -90,9 +91,9 @@ public FindAndRerankSort deserialize( * Deserialize the sort clause from an object node. * * @param sort The sort clause as an object node, no check for nulls. - * @return The {@link FindAndRerankSort} that reflects the request without validation. For example - * a sort with both $vectorize and $vector keeps both values, and - * the planner then validates. A single-leg sort with no value, e.g. + * @return The {@link FindAndRerankSort} after checking its JSON structure and value types. Inside + * $hybrid, both $vectorize and $vector are kept for + * the planner to validate. A single-leg sort with no value, e.g. * {"$lexical": null}, is recorded as that mode with no value, the planner rejects it * (for now every single-leg mode fails with UNSUPPORTED_FIND_AND_RERANK_SORT, GH #2602). */ 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..be658a4768 --- /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 = { + "{ \"$vectorize\" : \"cheese\", \"$lexical\" : \"cows\" }", + "{ \"$hybrid\" : \"cheese\", \"$vectorize\" : \"cheese\" }" + }) + void failOnMultipleTopLevelSortFields(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")); + } +} From d72fe81a0dcb812e88f0709f28b289a6713636f4 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 27 Oct 2026 12:53:40 -0700 Subject: [PATCH 4/5] Address remaining findAndRerank sort review feedback --- .../FindAndRerankSortClauseDeserializer.java | 41 +++++++++++++++---- ...ndAndRerankSortClauseDeserializerTest.java | 36 ++++++++++++++-- .../resolver/FindAndRerankPlannerTest.java | 30 +++++++++++--- 3 files changed, 89 insertions(+), 18 deletions(-) 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 31cb4287a7..512a0d1784 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 @@ -123,7 +123,13 @@ private static FindAndRerankSort deserialize(JsonParser jsonParser, ObjectNode s case HYBRID -> switch (topLevelValue) { case TextNode textNode -> - new FindAndRerankSort(RerankLegMode.MULTI_LEG, normalizedText(textNode.asText()), null, null, null, CommandFeatures.of(SortField.HYBRID.commandFeature())); + new FindAndRerankSort( + RerankLegMode.MULTI_LEG, + normalizedText(textNode.asText()), + null, + null, + null, + CommandFeatures.of(SortField.HYBRID.commandFeature())); case ObjectNode objectNode -> deserializeHybridObject(jsonParser, objectNode); case JsonNode node -> throw JsonFieldMatcher.errorForWrongType( @@ -136,15 +142,33 @@ private static FindAndRerankSort deserialize(JsonParser jsonParser, ObjectNode s }; case VECTORIZE -> { var commandFeatures = CommandFeatures.create(); - yield new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, textSortValue(jsonParser, sortField, topLevelValue, commandFeatures), null, null, commandFeatures); + yield new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, + null, + textSortValue(jsonParser, sortField, topLevelValue, commandFeatures), + null, + null, + commandFeatures); } case LEXICAL -> { var commandFeatures = CommandFeatures.create(); - yield new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, null, textSortValue(jsonParser, sortField, topLevelValue, commandFeatures), null, commandFeatures); + yield new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, + null, + null, + textSortValue(jsonParser, sortField, topLevelValue, commandFeatures), + null, + commandFeatures); } case VECTOR -> { var commandFeatures = CommandFeatures.create(); - yield new FindAndRerankSort(RerankLegMode.SINGLE_LEG, null, null, null, vectorSortValue(jsonParser, topLevelValue, commandFeatures), commandFeatures); + yield new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, + null, + null, + null, + vectorSortValue(jsonParser, topLevelValue, commandFeatures), + commandFeatures); } }; } @@ -174,13 +198,14 @@ private static FindAndRerankSort deserializeHybridObject( vectorSortValue( jsonParser, sortMatch.matched().get(SortField.VECTOR.sortField()), commandFeatures); - return new FindAndRerankSort(RerankLegMode.MULTI_LEG, null, vectorizeText, lexicalText, vector, commandFeatures); + return new FindAndRerankSort( + RerankLegMode.MULTI_LEG, null, vectorizeText, lexicalText, vector, commandFeatures); } /** - * A text sort value for {@code sortField}'s field, from inside the {@code $hybrid} object or from a - * top-level single-leg sort. The field and its metrics feature both come from {@code sortField}, so - * they cannot mismatch. + * A text sort value for {@code sortField}, from inside the {@code $hybrid} object or from a + * top-level single-leg sort. 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. 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 e272379974..82b25c178e 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 @@ -12,17 +12,17 @@ import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; -import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; -import io.stargate.sgv2.jsonapi.util.recordable.PrettyPrintable; import io.stargate.sgv2.jsonapi.exception.DocumentException; import io.stargate.sgv2.jsonapi.exception.SortException; -import org.junit.jupiter.params.provider.ValueSource; +import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; +import io.stargate.sgv2.jsonapi.util.recordable.PrettyPrintable; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; 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; @@ -169,12 +169,29 @@ private static Stream validSortsTestCases() { null, null, CommandFeatures.of(HYBRID))), + Arguments.of( + """ + { "$hybrid" : " padded text " } + """, + new FindAndRerankSort( + RerankLegMode.MULTI_LEG, + "padded text", + null, + null, + null, + CommandFeatures.of(HYBRID))), Arguments.of( """ { "$hybrid" : "" } """, new FindAndRerankSort( RerankLegMode.MULTI_LEG, null, null, null, null, CommandFeatures.of(HYBRID))), + Arguments.of( + """ + { "$hybrid" : " " } + """, + new FindAndRerankSort( + RerankLegMode.MULTI_LEG, null, null, null, null, CommandFeatures.of(HYBRID))), // ---- // maximum fields, resolver works out the valid combinations Arguments.of( @@ -380,13 +397,24 @@ private static Stream validSortsTestCases() { { "$lexical" : "lexical sort" } """, new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, null, null, "lexical sort", null, CommandFeatures.of(LEXICAL))), + RerankLegMode.SINGLE_LEG, + null, + null, + "lexical sort", + null, + CommandFeatures.of(LEXICAL))), Arguments.of( """ { "$lexical" : " " } """, new FindAndRerankSort( RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.of(LEXICAL))), + Arguments.of( + """ + { "$lexical" : null } + """, + new FindAndRerankSort( + RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.of(LEXICAL))), Arguments.of( """ { "$vector" : [1.1, 2.2, 3.3] } 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 6a3cca0f39..25dd5aca9b 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 @@ -304,12 +304,30 @@ static Stream sortErrorCases() { {"$hybrid": {"$lexical": "cows", "$vector": null}} """, SortException.Code.MISSING_VECTOR_OR_VECTORIZE_IN_HYBRID_SORT), - Arguments.of("$vectorize single leg False", "{\"$vectorize\": \"cheese\"}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), - Arguments.of("$vector single leg False", "{\"$vector\": [0.1, 0.2, 0.3]}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), - Arguments.of("$lexical single leg False", "{\"$lexical\": \"cows\"}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), - Arguments.of("$vectorize single leg True", "{\"$vectorize\": null}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), - Arguments.of("$vector single leg True", "{\"$vector\": null}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), - Arguments.of("$lexical single leg True", "{\"$lexical\": null}", SortException.Code.UNSUPPORTED_FIND_AND_RERANK_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.UNSUPPORTED_FIND_AND_RERANK_SORT), + Arguments.of( + "$vector single leg null", + "{\"$vector\": null}", + SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + Arguments.of( + "$lexical single leg null", + "{\"$lexical\": null}", + SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), Arguments.of("no sort", null, SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), Arguments.of( "sort empty", From baf681d0ea269d43ccde5f9a1360bbba952ed4ce Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 5 Nov 2026 17:03:19 -0800 Subject: [PATCH 5/5] Refactor findAndRerank sorts into sealed types --- .../clause/sort/FindAndRerankSort.java | 189 ++++++---- .../FindAndRerankSortClauseDeserializer.java | 130 ++----- .../command/clause/sort/RerankLegMode.java | 13 - .../command/clause/sort/SortExpression2.java | 74 ++++ .../resolver/FindAndRerankPlanner.java | 116 +++++- ...ndAndRerankSortClauseDeserializerTest.java | 355 ++++-------------- .../clause/sort/FindAndRerankSortTest.java | 106 ++++++ .../api/v1/FindAndRerankRequestTest.java | 4 +- .../resolver/FindAndRerankPlannerTest.java | 117 +++++- 9 files changed, 630 insertions(+), 474 deletions(-) delete mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/RerankLegMode.java create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/SortExpression2.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortTest.java diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java index 2986063649..9c59b1fc95 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java @@ -3,7 +3,9 @@ import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.node.NullNode; import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.util.recordable.Recordable; import java.util.*; @@ -11,23 +13,7 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.media.SchemaProperty; -/** - * The sort clause of a findAndRerank command, see {@link FindAndRerankSortClauseDeserializer} for - * the forms it can take. - * - * @param rerankLegMode {@link RerankLegMode#MULTI_LEG} when the sort clause uses $hybrid, otherwise - * {@link RerankLegMode#SINGLE_LEG}. - * @param hybridSort If null then the $hybrid field was not text, OR was blank text, - * otherwise it is the trimmed text. - * @param vectorizeSort If null then the $vectorize field was not present in the sort - * clause, OR was present but set to JSON null OR it was blank string, otherwise it is the value - * provided. - * @param lexicalSort If null then the $lexical field was not present in the sort - * clause, OR was present but set to JSON null OR it was blank string, otherwise it is the value - * provided. - * @param vectorSort If null then the vector field was not present in the sort clause, - * or present and JSON null, otherwise it is the value provided. - */ +/** The sort clause of a findAndRerank command. */ @JsonDeserialize(using = FindAndRerankSortClauseDeserializer.class) @Schema( name = "FindAndRerankSort", @@ -56,14 +42,106 @@ {"$hybrid": {"$vector": {"$binary": "P4AAAEAAAABAQAAA"}, "$lexical": "lexical query"}} """ }) -public record FindAndRerankSort( - @JsonIgnore RerankLegMode rerankLegMode, - @JsonIgnore String hybridSort, - @JsonIgnore String vectorizeSort, - @JsonIgnore String lexicalSort, - @JsonIgnore float[] vectorSort, - @JsonIgnore CommandFeatures commandFeatures) - implements Recordable { +public sealed interface FindAndRerankSort extends Recordable + permits FindAndRerankSort.MultiLeg, FindAndRerankSort.SingleLeg { + + @JsonIgnore + CommandFeatures commandFeatures(); + + sealed interface MultiLeg extends FindAndRerankSort permits HybridText, HybridObject {} + + record HybridText(@JsonIgnore String text, @JsonIgnore CommandFeatures commandFeatures) + implements MultiLeg { + public HybridText { + Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); + } + } + + record HybridObject( + @JsonIgnore String vectorize, + @JsonIgnore String lexical, + @JsonIgnore float[] vector, + @JsonIgnore CommandFeatures commandFeatures) + implements MultiLeg { + public HybridObject { + Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); + vector = vector == null ? null : vector.clone(); + } + + @Override + public float[] vector() { + return vector == null ? null : vector.clone(); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof HybridObject other + && Objects.equals(vectorize, other.vectorize) + && Objects.equals(lexical, other.lexical) + && Arrays.equals(vector, other.vector) + && Objects.equals(commandFeatures, other.commandFeatures); + } + + @Override + public int hashCode() { + return Objects.hash(vectorize, lexical, Arrays.hashCode(vector), commandFeatures); + } + } + + sealed interface SingleLeg extends FindAndRerankSort + permits UnresolvedSingleLeg, LexicalLeg, VectorLeg, FilterLeg {} + + /** Values remain raw until the planner validates them with find's sort builder. */ + record UnresolvedSingleLeg(@JsonIgnore JsonNode sort, @JsonIgnore CommandFeatures commandFeatures) + implements SingleLeg { + public UnresolvedSingleLeg { + Objects.requireNonNull(sort, "sort must not be null"); + Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); + } + } + + record LexicalLeg( + @JsonIgnore List sortExpressions, + @JsonIgnore CommandFeatures commandFeatures) + implements SingleLeg { + public LexicalLeg { + sortExpressions = List.copyOf(sortExpressions); + Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); + if (sortExpressions.size() != 1 + || !(sortExpressions.getFirst() instanceof SortExpression2.LexicalSe)) { + throw new IllegalArgumentException("LexicalLeg requires one lexical expression"); + } + } + } + + record VectorLeg( + @JsonIgnore List sortExpressions, + @JsonIgnore CommandFeatures commandFeatures) + implements SingleLeg { + public VectorLeg { + sortExpressions = List.copyOf(sortExpressions); + Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); + if (sortExpressions.size() != 1 + || !(sortExpressions.getFirst() instanceof SortExpression2.VectorSe + || sortExpressions.getFirst() instanceof SortExpression2.VectorizeSe)) { + throw new IllegalArgumentException("VectorLeg requires one vector or vectorize expression"); + } + } + } + + record FilterLeg( + @JsonIgnore List 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 512a0d1784..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 @@ -26,23 +26,17 @@ /** * {@link StdDeserializer} for the {@link FindAndRerankSort}. * - *

Checks the JSON structure and value types. A nonempty sort must have exactly one top-level - * field to select its mode. The planner validates the values against the collection and decides - * which legs run. For example, the text of {"$hybrid": "text"} is kept as hybrid text - * for the planner to use. + *

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"; - // top level has exactly one of $hybrid, $vectorize, $vector or $lexical - // can be string or object for $hybrid + // $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"} } } - // { "sort" : { "$vectorize" : "i like cheese" } } - // { "sort" : { "$vector" : [1, 2, 3] } } - // { "sort" : { "$lexical" : "cows" } } private static final JsonFieldMatcher MATCH_TOP_LEVEL_SORT_FIELDS = new JsonFieldMatcher<>( JsonNode.class, @@ -88,21 +82,21 @@ 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} after checking its JSON structure and value types. Inside - * $hybrid, both $vectorize and $vector are kept for - * the planner to validate. A single-leg sort with no value, e.g. - * {"$lexical": null}, is recorded as that mode with no value, the planner rejects it - * (for now every single-leg mode fails with UNSUPPORTED_FIND_AND_RERANK_SORT, GH #2602). */ 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 sortMatch = MATCH_TOP_LEVEL_SORT_FIELDS.matchAndThrow(sort, jsonParser, ERROR_CONTEXT); @@ -114,62 +108,15 @@ private static FindAndRerankSort deserialize(JsonParser jsonParser, ObjectNode s + String.join(", ", sortMatch.matched().keySet()))); } - // the matcher has checked the field name, so fromSortField cannot fail here - var topLevelField = sortMatch.matched().entrySet().iterator().next(); - var sortField = SortField.fromSortField(topLevelField.getKey()); - var topLevelValue = topLevelField.getValue(); - - return switch (sortField) { - case HYBRID -> - switch (topLevelValue) { - case TextNode textNode -> - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - normalizedText(textNode.asText()), - null, - null, - null, - CommandFeatures.of(SortField.HYBRID.commandFeature())); - case ObjectNode objectNode -> deserializeHybridObject(jsonParser, objectNode); - case JsonNode node -> - throw JsonFieldMatcher.errorForWrongType( - jsonParser, - ERROR_CONTEXT, - SortField.HYBRID.sortField(), - node, - TextNode.class, - ObjectNode.class); - }; - case VECTORIZE -> { - var commandFeatures = CommandFeatures.create(); - yield new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, - null, - textSortValue(jsonParser, sortField, topLevelValue, commandFeatures), - null, - null, - commandFeatures); - } - case LEXICAL -> { - var commandFeatures = CommandFeatures.create(); - yield new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, - null, - null, - textSortValue(jsonParser, sortField, topLevelValue, commandFeatures), - null, - commandFeatures); - } - case VECTOR -> { - var commandFeatures = CommandFeatures.create(); - yield new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, - null, - null, - null, - vectorSortValue(jsonParser, topLevelValue, commandFeatures), - commandFeatures); - } + return switch (sort.get(HYBRID_FIELD)) { + case TextNode textNode -> + new FindAndRerankSort.HybridText( + normalizedText(textNode.asText()), + CommandFeatures.of(SortField.HYBRID.commandFeature())); + case ObjectNode objectNode -> deserializeHybridObject(jsonParser, objectNode); + case JsonNode node -> + throw JsonFieldMatcher.errorForWrongType( + jsonParser, ERROR_CONTEXT, HYBRID_FIELD, node, TextNode.class, ObjectNode.class); }; } @@ -179,7 +126,7 @@ private static FindAndRerankSort deserializeHybridObject( 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 rerankLegMode and the sort values + // from the sort values CommandFeatures commandFeatures = CommandFeatures.of(SortField.HYBRID.commandFeature()); var vectorizeText = @@ -198,21 +145,18 @@ private static FindAndRerankSort deserializeHybridObject( vectorSortValue( jsonParser, sortMatch.matched().get(SortField.VECTOR.sortField()), commandFeatures); - return new FindAndRerankSort( - RerankLegMode.MULTI_LEG, null, vectorizeText, lexicalText, vector, commandFeatures); + return new FindAndRerankSort.HybridObject(vectorizeText, lexicalText, vector, commandFeatures); } /** - * A text sort value for {@code sortField}, from inside the {@code $hybrid} object or from a - * top-level single-leg sort. The field and its metrics feature both come from {@code sortField}, - * so they cannot mismatch. + * 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. Inside - * {@code $hybrid} that skips the leg, at the top level it is a single-leg sort with no value, - * the planner rejects it. + * @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) @@ -225,14 +169,12 @@ private static String textSortValue( } case NullNode ignored -> { // explicit setting to null is allowed - // { "sort" : { "$hybrid" : { "$vectorize" : null } } } or { "sort" : { "$lexical" : null } - // } + // { "sort" : { "$hybrid" : { "$vectorize" : null } } } commandFeatures.addFeature(sortField.commandFeature()); yield null; } case TextNode textNode -> { - // { "sort" : { "$hybrid" : { "$vectorize" : "I like cheese" } } } or - // { "sort" : { "$lexical" : "cheese" } } + // { "sort" : { "$hybrid" : { "$vectorize" : "I like cheese" } } } commandFeatures.addFeature(sortField.commandFeature()); yield normalizedText(textNode.asText()); } @@ -248,8 +190,7 @@ private static String textSortValue( } /** - * A {@code $vector} sort value, from inside the {@code $hybrid} object or from a top-level - * single-leg sort. + * 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. @@ -267,12 +208,12 @@ private static float[] vectorSortValue( } case NullNode ignored -> { // explicit setting to null is allowed - // { "sort" : { "$hybrid" : { "$vector" : null } } } or { "sort" : { "$vector" : null } } + // { "sort" : { "$hybrid" : { "$vector" : null } } } commandFeatures.addFeature(SortField.VECTOR.commandFeature()); yield null; } case ArrayNode arrayNode -> { - // { "sort" : { "$hybrid" : { "$vector" : [1,2,3] } } } or { "sort" : { "$vector" : [] } } + // { "sort" : { "$hybrid" : { "$vector" : [1,2,3] } } } commandFeatures.addFeature(SortField.VECTOR.commandFeature()); yield arrayNodeToVector(arrayNode); } @@ -330,13 +271,6 @@ String sortField() { CommandFeature commandFeature() { return commandFeature; } - - static SortField fromSortField(String sortField) { - return Arrays.stream(values()) - .filter(field -> field.sortField.equals(sortField)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("unknown sort field: " + sortField)); - } } /** 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 82b25c178e..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; @@ -46,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 { @@ -150,7 +73,7 @@ private static Stream validSortsTestCases() { """ {} """, - FindAndRerankSort.NO_ARG_SORT), + new UnresolvedSingleLeg(new ObjectMapper().createObjectNode(), CommandFeatures.EMPTY)), Arguments.of( """ null @@ -162,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}, @@ -209,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, @@ -222,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, @@ -268,66 +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 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" : {"$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 FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - null, - "lexical", - null, - CommandFeatures.of(HYBRID, LEXICAL, VECTOR))), + 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, @@ -336,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}, @@ -348,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, @@ -360,85 +214,11 @@ 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, - CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE, VECTOR))), - // ---- - // single leg sorts - Arguments.of( - """ - { "$vectorize" : "vectorize sort" } - """, - new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, - null, - "vectorize sort", - null, - null, - CommandFeatures.of(VECTORIZE))), - Arguments.of( - """ - { "$vectorize" : "" } - """, - new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.of(VECTORIZE))), - Arguments.of( - """ - { "$vectorize" : null } - """, - new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.of(VECTORIZE))), - Arguments.of( - """ - { "$lexical" : "lexical sort" } - """, - new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, - null, - null, - "lexical sort", - null, - CommandFeatures.of(LEXICAL))), - Arguments.of( - """ - { "$lexical" : " " } - """, - new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.of(LEXICAL))), - Arguments.of( - """ - { "$lexical" : null } - """, - new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.of(LEXICAL))), - Arguments.of( - """ - { "$vector" : [1.1, 2.2, 3.3] } - """, - new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, - null, - null, - null, - new float[] {1.1f, 2.2f, 3.3f}, - CommandFeatures.of(VECTOR))), - Arguments.of( - """ - { "$vector" : {"$binary": "%s"} } - """ - .formatted(vectorBase64), - new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, null, null, null, vector, CommandFeatures.of(VECTOR))), - Arguments.of( - """ - { "$vector" : null } - """, - new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.of(VECTOR)))); + CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE, VECTOR)))); } @ParameterizedTest @@ -467,11 +247,6 @@ private static Stream invalidSortsTestCases() { 1 """, "sort clause must be an object"), - Arguments.of( - """ - { "fake" : 1} - """, - "Expected fields: $hybrid, $vectorize, $vector, $lexical. Unexpected fields: fake"), Arguments.of( """ { "$hybrid" : {"fake" : 1} } @@ -491,6 +266,11 @@ private static Stream invalidSortsTestCases() { "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]} } @@ -510,31 +290,14 @@ private static Stream invalidSortsTestCases() { """ { "$hybrid" : { "$vector" : "", "$lexical" : "", "$vector" : {"$date": "2023-01-01"}} } """, - "`sort` clause (binary vector) contained fields with the wrong type. Field $vector may only be of types NullNode, ArrayNode, ObjectNode, but got: ObjectNode"), - // ---- - // single leg sorts - Arguments.of( - """ - { "$vectorize" : 1 } - """, - "Field $vectorize may only be of types NullNode, TextNode, but got: IntNode"), - Arguments.of( - """ - { "$lexical" : 1 } - """, - "Field $lexical may only be of types NullNode, TextNode, but got: IntNode"), - Arguments.of( - """ - { "$vector" : 1 } - """, - "Field $vector may only be of types NullNode, ArrayNode, ObjectNode, but got: IntNode")); + "`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 = { - "{ \"$vectorize\" : \"cheese\", \"$lexical\" : \"cows\" }", - "{ \"$hybrid\" : \"cheese\", \"$vectorize\" : \"cheese\" }" + "{ \"$hybrid\" : \"cheese\", \"$vectorize\" : \"cheese\" }", + "{ \"$hybrid\" : \"cheese\", \"$lexical\" : \"cows\" }" }) public void invalidSortWithTwoTopLevelFields(String jsonString) throws Exception { ObjectMapper mapper = new ObjectMapper(); @@ -550,7 +313,7 @@ public void invalidSortWithTwoTopLevelFields(String jsonString) throws Exception } @ParameterizedTest - @ValueSource(strings = {"{ \"$vector\" : [] }", "{ \"$hybrid\" : { \"$vector\" : [] } }"}) + @ValueSource(strings = {"{ \"$hybrid\" : { \"$vector\" : [] } }"}) public void invalidEmptyVector(String jsonString) throws Exception { ObjectMapper mapper = new ObjectMapper(); when(deserializationContext.readTree(jsonParser)).thenReturn(mapper.readTree(jsonString)); @@ -561,4 +324,50 @@ public void invalidEmptyVector(String jsonString) throws Exception { () -> 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 index be658a4768..79d4466ad3 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankRequestTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankRequestTest.java @@ -22,10 +22,10 @@ class FindAndRerankRequestTest { @ParameterizedTest @ValueSource( strings = { - "{ \"$vectorize\" : \"cheese\", \"$lexical\" : \"cows\" }", + "{ \"$hybrid\" : \"cheese\", \"$lexical\" : \"cows\" }", "{ \"$hybrid\" : \"cheese\", \"$vectorize\" : \"cheese\" }" }) - void failOnMultipleTopLevelSortFields(String sort) { + void failOnHybridSortWithOtherTopLevelFields(String sort) { // The request fails during deserialization before any database access. given() .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, "test-token") 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 25dd5aca9b..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 @@ -319,14 +328,22 @@ static Stream sortErrorCases() { Arguments.of( "$vectorize single leg null", "{\"$vectorize\": null}", - SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + SortException.Code.SORT_CLAUSE_VALUE_INVALID), Arguments.of( "$vector single leg null", "{\"$vector\": null}", - SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + 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( @@ -343,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 // =========================================================================================== @@ -634,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 // ===========================================================================================