Skip to content

Commit 3b1f8de

Browse files
committed
[BugFix] Return 400 instead of 500 on vectorSearch() arg-count mismatch
A vectorSearch() call with more arguments than its resolved signature declares (for example a duplicate named argument such as table='x', table='x') crashed with an unchecked IndexOutOfBoundsException surfaced as HTTP 500. The resolver returns a fixed-arity signature regardless of how many arguments it was called with, so castArguments looped over the supplied arguments while indexing into the shorter resolved-type list and ran off the end. Guard the argument count against the resolved signature in castArguments and throw an ExpressionEvaluationException, which maps to a clean 400, before any indexing. This protects every custom function resolver, not only vectorSearch(). Signed-off-by: Eric Wei <mengwei.eric@gmail.com>
1 parent e99aff0 commit 3b1f8de

4 files changed

Lines changed: 125 additions & 2 deletions

File tree

core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionRepository.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ private Optional<FunctionBuilder> resolve(
162162
|| sourceTypes.equals(targetTypes)) {
163163
return Optional.of(funcBuilder);
164164
}
165-
return Optional.of(castArguments(sourceTypes, targetTypes, funcBuilder));
165+
return Optional.of(castArguments(functionName, sourceTypes, targetTypes, funcBuilder));
166166
} else {
167167
return Optional.empty();
168168
}
@@ -175,8 +175,22 @@ private Optional<FunctionBuilder> resolve(
175175
* this case, wrap F and return equal(BOOL, cast_to_bool(STRING)).
176176
*/
177177
private FunctionBuilder castArguments(
178-
List<ExprType> sourceTypes, List<ExprType> targetTypes, FunctionBuilder funcBuilder) {
178+
FunctionName funcName,
179+
List<ExprType> sourceTypes,
180+
List<ExprType> targetTypes,
181+
FunctionBuilder funcBuilder) {
179182
return (fp, arguments) -> {
183+
// A resolver may return a fixed-arity resolved signature regardless of how many arguments it
184+
// was called with (e.g. a table function called with a duplicate named argument). Reject the
185+
// mismatch with a clean ExpressionEvaluationException instead of indexing past the end of the
186+
// resolved type list, which would surface as an unchecked IndexOutOfBoundsException (HTTP
187+
// 500).
188+
if (arguments.size() != targetTypes.size()) {
189+
throw new ExpressionEvaluationException(
190+
String.format(
191+
"Expected %d arguments for function %s but received %d",
192+
targetTypes.size(), funcName.getFunctionName(), arguments.size()));
193+
}
180194
List<Expression> argsCasted = new ArrayList<>();
181195
for (int i = 0; i < arguments.size(); i++) {
182196
Expression arg = arguments.get(i);

core/src/test/java/org/opensearch/sql/expression/function/BuiltinFunctionRepositoryTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,36 @@ void resolve_should_throw_exception_for_unsupported_conversion() {
181181
assertEquals(error.getMessage(), "Type conversion to type STRUCT is not supported");
182182
}
183183

184+
@Test
185+
@DisplayName("resolve should not crash when resolved signature is shorter than the arguments")
186+
void resolve_should_not_index_past_resolved_signature() {
187+
// A custom FunctionResolver (e.g. vectorSearch) may return a fixed-arity resolved signature
188+
// regardless of the number of arguments it was called with. When more arguments are supplied
189+
// than the resolved signature declares (e.g. a duplicate named argument), castArguments must
190+
// not index past the end of the resolved type list. Before the fix this threw an unchecked
191+
// IndexOutOfBoundsException, surfacing as an HTTP 500 instead of the resolver's clean 400.
192+
when(mockFunctionName.getFunctionName()).thenReturn("mock");
193+
194+
FunctionName funcName = mockFunctionName;
195+
FunctionSignature sourceSignature =
196+
new FunctionSignature(funcName, ImmutableList.of(STRING, STRING));
197+
FunctionSignature resolvedSignature = new FunctionSignature(funcName, ImmutableList.of(STRING));
198+
199+
DefaultFunctionResolver funcResolver = mock(DefaultFunctionResolver.class);
200+
FunctionBuilder funcBuilder = mock(FunctionBuilder.class);
201+
when(mockMap.containsKey(eq(funcName))).thenReturn(true);
202+
when(mockMap.get(eq(funcName))).thenReturn(funcResolver);
203+
when(funcResolver.resolve(any())).thenReturn(Pair.of(resolvedSignature, funcBuilder));
204+
205+
ExpressionEvaluationException error =
206+
assertThrows(
207+
ExpressionEvaluationException.class,
208+
() ->
209+
repo.resolve(Collections.emptyList(), sourceSignature)
210+
.apply(functionProperties, ImmutableList.of(mockExpression, mockExpression)));
211+
assertEquals("Expected 1 arguments for function mock but received 2", error.getMessage());
212+
}
213+
184214
@Test
185215
@DisplayName("resolve unregistered function should throw exception")
186216
void resolve_unregistered() {

integ-test/src/test/java/org/opensearch/sql/sql/VectorSearchIT.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,26 @@ public void testEmptyVectorRejects() throws IOException {
110110
assertThat(ex.getMessage(), containsString("must not be empty"));
111111
}
112112

113+
@Test
114+
public void testDuplicateNamedArgumentRejectsWith400() throws IOException {
115+
// A duplicate named argument on top of all four distinct names sends five arguments to the
116+
// resolver, exceeding its fixed four-arg signature. This previously crashed with an unchecked
117+
// IndexOutOfBoundsException surfaced as HTTP 500; it must now be a clean 400 with a
118+
// user-facing message.
119+
ResponseException ex =
120+
expectThrows(
121+
ResponseException.class,
122+
() ->
123+
executeQuery(
124+
"SELECT v._id FROM vectorSearch(table='t', table='t', field='f', "
125+
+ "vector='[1.0]', option='k=5') AS v"));
126+
127+
assertEquals(400, ex.getResponse().getStatusLine().getStatusCode());
128+
// The five-argument case is caught by the repository arity guard before the resolver's
129+
// duplicate-name check, so the message reports the argument-count mismatch.
130+
assertThat(ex.getMessage(), containsString("Expected 4 arguments"));
131+
}
132+
113133
@Test
114134
public void testInvalidFieldNameRejects() throws IOException {
115135
ResponseException ex =

opensearch/src/test/java/org/opensearch/sql/opensearch/storage/VectorSearchTableFunctionResolverTest.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import org.opensearch.sql.exception.ExpressionEvaluationException;
2222
import org.opensearch.sql.expression.DSL;
2323
import org.opensearch.sql.expression.Expression;
24+
import org.opensearch.sql.expression.function.BuiltinFunctionRepository;
2425
import org.opensearch.sql.expression.function.FunctionBuilder;
2526
import org.opensearch.sql.expression.function.FunctionName;
2627
import org.opensearch.sql.expression.function.FunctionProperties;
@@ -182,6 +183,64 @@ void resolve_rejectsDuplicateNamedArgument() {
182183
assertTrue(ex.getMessage().contains("table"));
183184
}
184185

186+
@Test
187+
void resolverArityGuardRejectsExtraNamedArgument() {
188+
// The resolver's own builder runs validateArguments() first, so applying it directly to a
189+
// duplicate named argument plus all four required arguments (five total) throws a clean
190+
// ExpressionEvaluationException. This covers the resolver's arity guard only; the
191+
// repository-level cast path that originally crashed is covered by
192+
// resolveThroughRepositoryRejectsExtraNamedArgument and BuiltinFunctionRepositoryTest.
193+
VectorSearchTableFunctionResolver resolver =
194+
new VectorSearchTableFunctionResolver(client, settings);
195+
FunctionName functionName = FunctionName.of("vectorsearch");
196+
List<Expression> expressions =
197+
List.of(
198+
DSL.namedArgument("table", DSL.literal("a")),
199+
DSL.namedArgument("table", DSL.literal("b")),
200+
DSL.namedArgument("field", DSL.literal("embedding")),
201+
DSL.namedArgument("vector", DSL.literal("[1.0]")),
202+
DSL.namedArgument("option", DSL.literal("k=5")));
203+
FunctionSignature functionSignature =
204+
new FunctionSignature(
205+
functionName, expressions.stream().map(Expression::type).collect(Collectors.toList()));
206+
FunctionBuilder builder = resolver.resolve(functionSignature).getValue();
207+
208+
ExpressionEvaluationException ex =
209+
assertThrows(
210+
ExpressionEvaluationException.class,
211+
() -> builder.apply(functionProperties, expressions));
212+
assertTrue(ex.getMessage().contains("requires 4 arguments"));
213+
}
214+
215+
@Test
216+
void resolveThroughRepositoryRejectsExtraNamedArgument() {
217+
// Routes the vectorSearch resolver through BuiltinFunctionRepository the way production does
218+
// (storage-engine resolver registered as a datasource function). A duplicate named argument
219+
// plus all four required arguments gives five arguments against the resolver's fixed four-arg
220+
// signature, which exercises the cast-wrapper path that originally crashed with an unchecked
221+
// IndexOutOfBoundsException (surfacing as HTTP 500). It must now fail with a clean
222+
// ExpressionEvaluationException instead.
223+
VectorSearchTableFunctionResolver resolver =
224+
new VectorSearchTableFunctionResolver(client, settings);
225+
BuiltinFunctionRepository repository = BuiltinFunctionRepository.getInstance();
226+
List<Expression> expressions =
227+
List.of(
228+
DSL.namedArgument("table", DSL.literal("a")),
229+
DSL.namedArgument("table", DSL.literal("b")),
230+
DSL.namedArgument("field", DSL.literal("embedding")),
231+
DSL.namedArgument("vector", DSL.literal("[1.0]")),
232+
DSL.namedArgument("option", DSL.literal("k=5")));
233+
234+
assertThrows(
235+
ExpressionEvaluationException.class,
236+
() ->
237+
repository.compile(
238+
functionProperties,
239+
List.of(resolver),
240+
FunctionName.of("vectorsearch"),
241+
expressions));
242+
}
243+
185244
@Test
186245
void resolve_rejectsUnknownArgumentName() {
187246
VectorSearchTableFunctionResolver resolver =

0 commit comments

Comments
 (0)