diff --git a/langchain4j-infinispan/pom.xml b/langchain4j-infinispan/pom.xml index acdb2766ff..944804857c 100644 --- a/langchain4j-infinispan/pom.xml +++ b/langchain4j-infinispan/pom.xml @@ -13,8 +13,8 @@ LangChain4j :: Integration :: Infinispan - 16.0.6 - 6.0.6 + 16.0.14 + 6.0.7 @@ -26,6 +26,11 @@ pom import + + org.jboss.logging + jboss-logging + 3.6.3.Final + diff --git a/langchain4j-infinispan/src/main/java/dev/langchain4j/store/embedding/infinispan/InfinispanMetadataFilterMapper.java b/langchain4j-infinispan/src/main/java/dev/langchain4j/store/embedding/infinispan/InfinispanMetadataFilterMapper.java index 8ff2cd5441..1629d3d63b 100644 --- a/langchain4j-infinispan/src/main/java/dev/langchain4j/store/embedding/infinispan/InfinispanMetadataFilterMapper.java +++ b/langchain4j-infinispan/src/main/java/dev/langchain4j/store/embedding/infinispan/InfinispanMetadataFilterMapper.java @@ -14,7 +14,6 @@ import dev.langchain4j.store.embedding.filter.logical.And; import dev.langchain4j.store.embedding.filter.logical.Not; import dev.langchain4j.store.embedding.filter.logical.Or; import java.util.Collection; -import java.util.Optional; import java.util.stream.Collectors; /** @@ -107,48 +106,38 @@ class InfinispanMetadataFilterMapper { } private String mapIn(IsIn filter) { - Optional first = filter.comparisonValues().stream().findFirst(); - if (first.isEmpty()) { - throw new UnsupportedOperationException("Infinispan metadata filter IN must contain values"); - } - Object o = first.get(); - String inStatement = formattedComparisonValues(filter.comparisonValues(), o instanceof Number); - String m = "m" + i + "."; - String filterQuery = m + "value IN (" + inStatement + ")"; - if (o instanceof Integer || o instanceof Long) { - filterQuery = m + "value_int IN (" + inStatement + ")"; - } else if (o instanceof Float || o instanceof Double) { - filterQuery = m + "value_float IN (" + inStatement + ")"; - } + ensureNotEmpty(filter.comparisonValues()); + String column = "m" + i + "." + valueColumn(filter.comparisonValues()); + String inStatement = formattedComparisonValues(filter.comparisonValues()); - return metadataKey(filter.key()) + filterQuery; + return metadataKey(filter.key()) + column + " IN (" + inStatement + ")"; } private String mapNotIn(IsNotIn filter) { - Optional first = filter.comparisonValues().stream().findFirst(); - if (first.isEmpty()) { + ensureNotEmpty(filter.comparisonValues()); + String m = "m" + i + "."; + String column = m + valueColumn(filter.comparisonValues()); + String inStatement = formattedComparisonValues(filter.comparisonValues()); + + return "(" + column + " NOT IN (" + inStatement + ")" + metadataKeyLast(filter.key()) + ") " + "OR (" + + column + " IN (" + inStatement + ") and " + m + "name!='" + escape(filter.key()) + "')" + + addMetadataNullCheck(); + } + + private void ensureNotEmpty(Collection comparisonValues) { + if (comparisonValues.isEmpty()) { throw new UnsupportedOperationException("Infinispan metadata filter IN must contain values"); } - Object o = first.get(); - String inStatement = formattedComparisonValues(filter.comparisonValues(), o instanceof Number); - String m = "m" + i + "."; - String filterQuery = m + "value NOT IN (" + inStatement + ")"; - if (o instanceof Integer || o instanceof Long) { - filterQuery = m + "value_int NOT IN (" + inStatement + ")"; - } else if (o instanceof Float || o instanceof Double) { - filterQuery = m + "value_float NOT IN (" + inStatement + ")"; - } + } - String inFilterQuery = m + "value IN (" + inStatement + ")"; - if (o instanceof Integer || o instanceof Long) { - inFilterQuery = m + "value_int IN (" + inStatement + ")"; - } else if (o instanceof Float || o instanceof Double) { - inFilterQuery = m + "value_float IN (" + inStatement + ")"; + private String valueColumn(Collection comparisonValues) { + if (comparisonValues.stream().anyMatch(v -> v instanceof Float || v instanceof Double)) { + return "value_float"; } - - return "(" + filterQuery + metadataKeyLast(filter.key()) + ") " + "OR (" - + inFilterQuery + " and " + m + "name!='" + escape(filter.key()) + "')" - + addMetadataNullCheck(); + if (comparisonValues.stream().allMatch(v -> v instanceof Integer || v instanceof Long)) { + return "value_int"; + } + return "value"; } private String computeFilter(String operator, Object value) { @@ -198,14 +187,30 @@ class InfinispanMetadataFilterMapper { return "m" + i + ".name='" + escape(key) + "' and "; } - private String formattedComparisonValues(Collection comparisonValues, boolean isNumeric) { - String inStatement = comparisonValues.stream() - .map(s -> isNumeric ? s.toString() : "'" + escape(String.valueOf(s)) + "'") + private String formattedComparisonValues(Collection comparisonValues) { + boolean hasNumeric = comparisonValues.stream().anyMatch(v -> v instanceof Number); + boolean hasNonNumeric = comparisonValues.stream().anyMatch(v -> !(v instanceof Number)); + if (hasNumeric && hasNonNumeric) { + throw new IllegalArgumentException( + "Infinispan metadata filter IN/NOT IN cannot mix numeric and non-numeric values"); + } + boolean asFloat = "value_float".equals(valueColumn(comparisonValues)); + return comparisonValues.stream() + .map(s -> formattedComparisonValue(s, asFloat)) .collect(Collectors.joining(", ")); - return inStatement; + } + + private String formattedComparisonValue(Object value, boolean asFloat) { + if (!(value instanceof Number)) { + return "'" + escape(String.valueOf(value)) + "'"; + } + if (asFloat && (value instanceof Integer || value instanceof Long)) { + return String.valueOf(((Number) value).doubleValue()); + } + return value.toString(); } private static String escape(String s) { - return s.replace("'", "''"); + return s.replace("\\", "\\\\").replace("'", "''"); } } diff --git a/langchain4j-infinispan/src/test/java/dev/langchain4j/store/embedding/infinispan/InfinispanMetadataFilterMapperTest.java b/langchain4j-infinispan/src/test/java/dev/langchain4j/store/embedding/infinispan/InfinispanMetadataFilterMapperTest.java index a804cc7dce..249a82c691 100644 --- a/langchain4j-infinispan/src/test/java/dev/langchain4j/store/embedding/infinispan/InfinispanMetadataFilterMapperTest.java +++ b/langchain4j-infinispan/src/test/java/dev/langchain4j/store/embedding/infinispan/InfinispanMetadataFilterMapperTest.java @@ -369,15 +369,68 @@ class InfinispanMetadataFilterMapperTest { @Test void should_handle_mixed_numeric_types_in_in_filter() { - // given + // given — all values are Number (different subtypes), no mixing with strings Filter filter = new IsIn("mixed", Arrays.asList(1, 2L, 3.0f, 4.0)); // when InfinispanMetadataFilterMapper.FilterResult result = mapper.map(filter); + // then — presence of a floating point value selects value_float for the whole list, + // and integral values are widened to match the column + assertThat(result.query).isEqualTo("m0.name='mixed' and m0.value_float IN (3.0, 4.0, 1.0, 2.0)"); + } + + @ParameterizedTest + @MethodSource("numericInColumnSelection") + void should_select_in_column_independently_of_iteration_order(List values, String expectedColumn) { + // when + InfinispanMetadataFilterMapper.FilterResult result = mapper.map(new IsIn("n", values)); + // then - // Should use the type of the first element (Integer in this case) - assertThat(result.query).isEqualTo("m0.name='mixed' and m0.value_float IN (3.0, 4.0, 1, 2)"); + assertThat(result.query).startsWith("m0.name='n' and m0." + expectedColumn + " IN ("); + } + + static List numericInColumnSelection() { + return Arrays.asList( + Arguments.of(Arrays.asList(1, 2, 3), "value_int"), + Arguments.of(Arrays.asList(1, 2L), "value_int"), + Arguments.of(Arrays.asList(1.5, 2.5), "value_float"), + // these two pairs hash into opposite iteration orders; both must pick value_float + Arguments.of(Arrays.asList(1, 2.5), "value_float"), + Arguments.of(Arrays.asList(3, 0.5), "value_float"), + Arguments.of(Arrays.asList("a", "b"), "value")); + } + + @Test + void should_select_not_in_column_independently_of_iteration_order() { + // when + InfinispanMetadataFilterMapper.FilterResult result = mapper.map(new IsNotIn("n", Arrays.asList(1, 2.5))); + + // then — both occurrences of the column must agree + assertThat(result.query).contains("m0.value_float NOT IN (").contains("m0.value_float IN ("); + assertThat(result.query).doesNotContain("value_int"); + } + + @Test + void should_reject_mixed_numeric_and_string_in_filter() { + // given — mix of Number and String triggers rejection regardless of iteration order + Filter filter = new IsIn("key", Arrays.asList(1, "x' OR 1=1 --")); + + // when & then + assertThatThrownBy(() -> mapper.map(filter)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot mix numeric and non-numeric values"); + } + + @Test + void should_reject_mixed_numeric_and_string_not_in_filter() { + // given + Filter filter = new IsNotIn("key", Arrays.asList(42, "")); + + // when & then + assertThatThrownBy(() -> mapper.map(filter)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot mix numeric and non-numeric values"); } @Test @@ -441,6 +494,90 @@ class InfinispanMetadataFilterMapperTest { assertThat(result.query).contains("OR (i.metadata is null)"); } + @Test + void should_escape_ickle_injection_in_key() { + // given + Filter filter = new IsEqualTo("foo' OR 1=1 OR name='", "bar"); + + // when + InfinispanMetadataFilterMapper.FilterResult result = mapper.map(filter); + + // then + assertThat(result.query).isEqualTo("m0.name='foo'' OR 1=1 OR name=''' and m0.value = 'bar'"); + } + + @Test + void should_escape_ickle_injection_in_value() { + // given + Filter filter = new IsEqualTo("name", "x' OR 1=1 --"); + + // when + InfinispanMetadataFilterMapper.FilterResult result = mapper.map(filter); + + // then + assertThat(result.query).isEqualTo("m0.name='name' and m0.value = 'x'' OR 1=1 --'"); + } + + @Test + void should_escape_ickle_injection_in_not_equal_key() { + // given + Filter filter = new IsNotEqualTo("a' OR 1=1 --", "val"); + + // when + InfinispanMetadataFilterMapper.FilterResult result = mapper.map(filter); + + // then + assertThat(result.query).contains("m0.name='a'' OR 1=1 --'"); + } + + @Test + void should_escape_ickle_injection_in_in_key() { + // given + Filter filter = new IsIn("k' OR 1=1 --", Arrays.asList("a", "b")); + + // when + InfinispanMetadataFilterMapper.FilterResult result = mapper.map(filter); + + // then + assertThat(result.query).contains("m0.name='k'' OR 1=1 --'"); + } + + @Test + void should_escape_ickle_injection_in_in_values() { + // given + Filter filter = new IsIn("key", Arrays.asList("x' OR 1=1 --", "normal")); + + // when + InfinispanMetadataFilterMapper.FilterResult result = mapper.map(filter); + + // then + assertThat(result.query).contains("'x'' OR 1=1 --'"); + } + + @Test + void should_escape_ickle_injection_in_not_in_key() { + // given + Filter filter = new IsNotIn("k' OR 1=1 --", Arrays.asList("a")); + + // when + InfinispanMetadataFilterMapper.FilterResult result = mapper.map(filter); + + // then + assertThat(result.query).contains("m0.name='k'' OR 1=1 --'").contains("m0.name!='k'' OR 1=1 --'"); + } + + @Test + void should_escape_backslash_in_value() { + // given + Filter filter = new IsEqualTo("path", "C:\\Users\\test"); + + // when + InfinispanMetadataFilterMapper.FilterResult result = mapper.map(filter); + + // then + assertThat(result.query).isEqualTo("m0.name='path' and m0.value = 'C:\\\\Users\\\\test'"); + } + @Test void should_generate_correct_metadata_aliases_for_nested_filters() { // given