diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 929db2bd7..e454d063a 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -8,6 +8,22 @@ and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Version == [Unreleased] +=== Changed + +- Update ArangoDB driver to 7.22.0 +- Update Apache Cassandra driver to 4.19.0 +- Update Couchbase driver to 3.9.0 +- Update Neo4J driver to 5.28.9 +- Update OrientDB driver to 3.2.43 +- Update Elasticsearch driver to 8.19.1 +- Update Apache Hbase to version 2.6.3 +- Update Jedis version to 6.1.0 +- Update Apache Tinkerpop core to 3.7.4 + +=== Added + +- Include support to Contains, StartsWith, EndsWith for NoSQL databases + == [1.1.9] - 2025-07-30 === Fixed diff --git a/jnosql-arangodb/pom.xml b/jnosql-arangodb/pom.xml index bfbc8d589..96d87560b 100644 --- a/jnosql-arangodb/pom.xml +++ b/jnosql-arangodb/pom.xml @@ -29,7 +29,7 @@ The Eclipse JNoSQL layer to ArangoDB - 7.21.0 + 7.22.0 diff --git a/jnosql-arangodb/src/main/java/org/eclipse/jnosql/databases/arangodb/communication/QueryAQLConverter.java b/jnosql-arangodb/src/main/java/org/eclipse/jnosql/databases/arangodb/communication/QueryAQLConverter.java index 902f54074..89f9049a5 100644 --- a/jnosql-arangodb/src/main/java/org/eclipse/jnosql/databases/arangodb/communication/QueryAQLConverter.java +++ b/jnosql-arangodb/src/main/java/org/eclipse/jnosql/databases/arangodb/communication/QueryAQLConverter.java @@ -19,6 +19,7 @@ import jakarta.data.Sort; import org.eclipse.jnosql.communication.TypeReference; import org.eclipse.jnosql.communication.ValueUtil; +import org.eclipse.jnosql.communication.driver.StringMatch; import org.eclipse.jnosql.communication.semistructured.CriteriaCondition; import org.eclipse.jnosql.communication.semistructured.DeleteQuery; import org.eclipse.jnosql.communication.semistructured.Element; @@ -153,6 +154,15 @@ private static void definesCondition(CriteriaCondition condition, case LIKE: appendCondition(aql, params, entity, document, LIKE); return; + case CONTAINS: + appendCondition(aql, params, entity, Element.of(document.name(), StringMatch.CONTAINS.format(document.get(String.class))), LIKE); + return; + case STARTS_WITH: + appendCondition(aql, params, entity, Element.of(document.name(), StringMatch.STARTS_WITH.format(document.get(String.class))), LIKE); + return; + case ENDS_WITH: + appendCondition(aql, params, entity, Element.of(document.name(), StringMatch.ENDS_WITH.format(document.get(String.class))), LIKE); + return; case AND: for (CriteriaCondition dc : document.get(new TypeReference>() { diff --git a/jnosql-arangodb/src/test/java/org/eclipse/jnosql/databases/arangodb/communication/ArangoDBDocumentManagerTest.java b/jnosql-arangodb/src/test/java/org/eclipse/jnosql/databases/arangodb/communication/ArangoDBDocumentManagerTest.java index d578e0c50..8b3b5103c 100644 --- a/jnosql-arangodb/src/test/java/org/eclipse/jnosql/databases/arangodb/communication/ArangoDBDocumentManagerTest.java +++ b/jnosql-arangodb/src/test/java/org/eclipse/jnosql/databases/arangodb/communication/ArangoDBDocumentManagerTest.java @@ -19,10 +19,12 @@ import org.assertj.core.api.SoftAssertions; import org.eclipse.jnosql.communication.TypeReference; import org.eclipse.jnosql.communication.semistructured.CommunicationEntity; +import org.eclipse.jnosql.communication.semistructured.CriteriaCondition; import org.eclipse.jnosql.communication.semistructured.DeleteQuery; import org.eclipse.jnosql.communication.semistructured.Element; import org.eclipse.jnosql.communication.semistructured.Elements; import org.eclipse.jnosql.communication.semistructured.SelectQuery; +import org.eclipse.jnosql.mapping.semistructured.MappingQuery; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,6 +32,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -404,6 +407,51 @@ void shouldFindBetween2() { }); } + @Test + void shouldFindContains() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.contains(Element.of("name", + "lia")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldStartsWith() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.startsWith(Element.of("name", + "Pol")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldEndsWith() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.endsWith(Element.of("name", + "ana")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + private CommunicationEntity getEntity() { CommunicationEntity entity = CommunicationEntity.of(COLLECTION_NAME); Map map = new HashMap<>(); diff --git a/jnosql-cassandra/pom.xml b/jnosql-cassandra/pom.xml index e18217802..91fd28b71 100644 --- a/jnosql-cassandra/pom.xml +++ b/jnosql-cassandra/pom.xml @@ -29,7 +29,7 @@ The Eclipse JNoSQL layer to Cassandra - 4.17.0 + 4.19.0 @@ -43,12 +43,12 @@ ${project.version} - com.datastax.oss + org.apache.cassandra java-driver-core ${casandra.driver.version} - com.datastax.oss + org.apache.cassandra java-driver-query-builder ${casandra.driver.version} diff --git a/jnosql-couchbase/pom.xml b/jnosql-couchbase/pom.xml index be71105b4..c136526c6 100644 --- a/jnosql-couchbase/pom.xml +++ b/jnosql-couchbase/pom.xml @@ -49,7 +49,7 @@ com.couchbase.client java-client - 3.8.3 + 3.9.0 org.testcontainers diff --git a/jnosql-couchbase/src/main/java/org/eclipse/jnosql/databases/couchbase/communication/N1QLBuilder.java b/jnosql-couchbase/src/main/java/org/eclipse/jnosql/databases/couchbase/communication/N1QLBuilder.java index 24a1317c0..b6be20983 100644 --- a/jnosql-couchbase/src/main/java/org/eclipse/jnosql/databases/couchbase/communication/N1QLBuilder.java +++ b/jnosql-couchbase/src/main/java/org/eclipse/jnosql/databases/couchbase/communication/N1QLBuilder.java @@ -17,6 +17,7 @@ import com.couchbase.client.java.json.JsonObject; import jakarta.data.Direction; import org.eclipse.jnosql.communication.TypeReference; +import org.eclipse.jnosql.communication.driver.StringMatch; import org.eclipse.jnosql.communication.semistructured.CriteriaCondition; import org.eclipse.jnosql.communication.semistructured.Element; import org.eclipse.jnosql.communication.semistructured.SelectQuery; @@ -113,6 +114,15 @@ private void condition(CriteriaCondition condition, StringBuilder n1ql, JsonObje case LIKE: predicate(n1ql, " LIKE ", document, params); return; + case CONTAINS: + predicate(n1ql, " LIKE ", Element.of(document.name(), StringMatch.CONTAINS.format(document.get(String.class))), params); + return; + case STARTS_WITH: + predicate(n1ql, " LIKE ", Element.of(document.name(), StringMatch.STARTS_WITH.format(document.get(String.class))), params); + return; + case ENDS_WITH: + predicate(n1ql, " LIKE ", Element.of(document.name(), StringMatch.ENDS_WITH.format(document.get(String.class))), params); + return; case NOT: n1ql.append(" NOT "); condition(document.get(CriteriaCondition.class), n1ql, params, ids); diff --git a/jnosql-couchbase/src/test/java/org/eclipse/jnosql/databases/couchbase/communication/CouchbaseDocumentManagerTest.java b/jnosql-couchbase/src/test/java/org/eclipse/jnosql/databases/couchbase/communication/CouchbaseDocumentManagerTest.java index 1c1de8ea8..a53457ebd 100644 --- a/jnosql-couchbase/src/test/java/org/eclipse/jnosql/databases/couchbase/communication/CouchbaseDocumentManagerTest.java +++ b/jnosql-couchbase/src/test/java/org/eclipse/jnosql/databases/couchbase/communication/CouchbaseDocumentManagerTest.java @@ -22,16 +22,19 @@ import org.eclipse.jnosql.communication.TypeReference; import org.eclipse.jnosql.communication.keyvalue.BucketManager; import org.eclipse.jnosql.communication.semistructured.CommunicationEntity; +import org.eclipse.jnosql.communication.semistructured.CriteriaCondition; import org.eclipse.jnosql.communication.semistructured.DeleteQuery; import org.eclipse.jnosql.communication.semistructured.Element; import org.eclipse.jnosql.communication.semistructured.Elements; import org.eclipse.jnosql.communication.semistructured.SelectQuery; +import org.eclipse.jnosql.mapping.semistructured.MappingQuery; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfSystemProperty; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -294,6 +297,51 @@ void shouldUpdateNull(){ }); } + @Test + void shouldFindContains() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.contains(Element.of("name", + "lia")), COLLECTION_PERSON_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldStartsWith() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.startsWith(Element.of("name", + "Pol")), COLLECTION_PERSON_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldEndsWith() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.endsWith(Element.of("name", + "ana")), COLLECTION_PERSON_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + private CommunicationEntity getEntity() { CommunicationEntity entity = CommunicationEntity.of(COLLECTION_PERSON_NAME); Map map = new HashMap<>(); diff --git a/jnosql-database-commons/src/main/java/org/eclipse/jnosql/communication/driver/StringMatch.java b/jnosql-database-commons/src/main/java/org/eclipse/jnosql/communication/driver/StringMatch.java new file mode 100644 index 000000000..6b4e9dc23 --- /dev/null +++ b/jnosql-database-commons/src/main/java/org/eclipse/jnosql/communication/driver/StringMatch.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Apache License v2.0 is available at http://www.opensource.org/licenses/apache2.0.php. + * + * You may elect to redistribute this code under either of these licenses. + * + * Contributors: + * + * Otavio Santana + */ +package org.eclipse.jnosql.communication.driver; + +import java.util.Objects; + + +/** + * Represents strategies for matching string values in database queries, + * typically for SQL {@code LIKE} clauses or NoSQL regex-like searches. + *

+ * Each constant defines a specific way to wrap the given value + * with wildcard symbols ({@code %}) to produce a matching pattern. + *

+ *

+ * Example usage: + *

{@code
+ * String pattern = StringMatch.CONTAINS.format("Ota"); // "%Ota%"
+ * }
+ */ +public enum StringMatch { + + /** + * Exact match. + *

+ * The given value will be used as-is, without adding any wildcards. + * For SQL, this corresponds to {@code column = 'value'}. + *

+ */ + DEFAULT { + @Override + public String apply(String value) { + return value; + } + }, + + /** + * Contains match. + *

+ * The given value will be wrapped with wildcards on both sides: + * {@code %value%}. For SQL, this corresponds to + * {@code column LIKE '%value%'}. + *

+ */ + CONTAINS { + @Override + public String apply(String value) { + return "%" + value + "%"; + } + }, + + /** + * Starts-with match. + *

+ * The given value will be followed by a wildcard: + * {@code value%}. For SQL, this corresponds to + * {@code column LIKE 'value%'}. + *

+ */ + STARTS_WITH { + @Override + public String apply(String value) { + return value + "%"; + } + }, + + /** + * Ends-with match. + *

+ * The given value will be preceded by a wildcard: + * {@code %value}. For SQL, this corresponds to + * {@code column LIKE '%value'}. + *

+ */ + ENDS_WITH { + @Override + public String apply(String value) { + return "%" + value; + } + }; + + /** + * Applies the match strategy to the given value, producing a pattern string. + * + * @param value the value to be transformed into a pattern + * @return the pattern string, with wildcards applied according to the match strategy + */ + abstract String apply(String value); + + /** + * Formats the given value by applying the match strategy. + *

+ * This method ensures the value is not {@code null} before applying the strategy. + *

+ * + * @param value the value to be transformed into a pattern + * @return the pattern string, with wildcards applied according to the match strategy + * @throws NullPointerException if {@code value} is {@code null} + */ + public String format(String value) { + Objects.requireNonNull(value, "value cannot be null"); + return apply(value); + } + +} diff --git a/jnosql-database-commons/src/test/java/org/eclipse/jnosql/communication/driver/StringMatchTest.java b/jnosql-database-commons/src/test/java/org/eclipse/jnosql/communication/driver/StringMatchTest.java new file mode 100644 index 000000000..995ee5100 --- /dev/null +++ b/jnosql-database-commons/src/test/java/org/eclipse/jnosql/communication/driver/StringMatchTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Apache License v2.0 is available at http://www.opensource.org/licenses/apache2.0.php. + * + * You may elect to redistribute this code under either of these licenses. + * + * Contributors: + * + * Otavio Santana + */ +package org.eclipse.jnosql.communication.driver; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.*; + +class StringMatchTest { + + + @Test + @DisplayName("DEFAULT should return the exact input without wildcards") + void shouldReturnExactValueForDefault() { + String input = "Ota"; + String result = StringMatch.DEFAULT.format(input); + + assertThat(result).isEqualTo("Ota"); + } + + @Test + @DisplayName("CONTAINS should wrap input with % on both sides") + void shouldWrapWithWildcardsForContains() { + String input = "Ota"; + String result = StringMatch.CONTAINS.format(input); + + assertThat(result).isEqualTo("%Ota%"); + } + + @Test + @DisplayName("STARTS_WITH should append % to the input") + void shouldAppendPercentForStartsWith() { + String input = "Ota"; + String result = StringMatch.STARTS_WITH.format(input); + + assertThat(result).isEqualTo("Ota%"); + } + + @Test + @DisplayName("ENDS_WITH should prepend % to the input") + void shouldPrependPercentForEndsWith() { + String input = "Ota"; + String result = StringMatch.ENDS_WITH.format(input); + + assertThat(result).isEqualTo("%Ota"); + } + + @ParameterizedTest(name = "All strategies should reject null input: {0}") + @EnumSource(StringMatch.class) + @DisplayName("Null input should throw NullPointerException for every strategy") + void shouldRejectNullValuesWithNpe(StringMatch strategy) { + assertThatThrownBy(() -> strategy.format(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("value cannot be null"); + } +} \ No newline at end of file diff --git a/jnosql-dynamodb/pom.xml b/jnosql-dynamodb/pom.xml index f29c38261..160a4accb 100644 --- a/jnosql-dynamodb/pom.xml +++ b/jnosql-dynamodb/pom.xml @@ -23,7 +23,7 @@ The Eclipse JNoSQL layer implementation AWS DynamoDB - 2.31.47 + 2.32.21 diff --git a/jnosql-elasticsearch/pom.xml b/jnosql-elasticsearch/pom.xml index 3ab913661..1c590801f 100644 --- a/jnosql-elasticsearch/pom.xml +++ b/jnosql-elasticsearch/pom.xml @@ -29,7 +29,7 @@ The Eclipse JNoSQL layer to Elasticsearch - 8.17.4 + 8.19.1 diff --git a/jnosql-elasticsearch/src/main/java/org/eclipse/jnosql/databases/elasticsearch/communication/QueryConverter.java b/jnosql-elasticsearch/src/main/java/org/eclipse/jnosql/databases/elasticsearch/communication/QueryConverter.java index 23c381b29..f3ee9ea34 100644 --- a/jnosql-elasticsearch/src/main/java/org/eclipse/jnosql/databases/elasticsearch/communication/QueryConverter.java +++ b/jnosql-elasticsearch/src/main/java/org/eclipse/jnosql/databases/elasticsearch/communication/QueryConverter.java @@ -29,6 +29,7 @@ import org.eclipse.jnosql.communication.Condition; import org.eclipse.jnosql.communication.TypeReference; import org.eclipse.jnosql.communication.ValueUtil; +import org.eclipse.jnosql.communication.driver.StringMatch; import org.eclipse.jnosql.communication.semistructured.CriteriaCondition; import org.eclipse.jnosql.communication.semistructured.Element; import org.eclipse.jnosql.communication.semistructured.SelectQuery; @@ -160,6 +161,25 @@ private static Query.Builder getCondition(IndexMappingRecord indexMappingRecord, .query(document.value().get(String.class)) .allowLeadingWildcard(true) .fields(fieldName))); + case CONTAINS: + return (Query.Builder) new Query.Builder() + .queryString(QueryStringQuery.of(rq -> rq + .query(StringMatch.CONTAINS.format(document.value().get(String.class))) + .allowLeadingWildcard(true) + .fields(fieldName))); + case STARTS_WITH: + return (Query.Builder) new Query.Builder() + .queryString(QueryStringQuery.of(rq -> rq + .query(StringMatch.STARTS_WITH.format(document.value().get(String.class))) + .allowLeadingWildcard(true) + .fields(fieldName))); + case ENDS_WITH: + return (Query.Builder) new Query.Builder() + .queryString(QueryStringQuery.of(rq -> rq + .query(StringMatch.ENDS_WITH.format(document.value().get(String.class))) + .allowLeadingWildcard(true) + .fields(fieldName))); + case IN: return (Query.Builder) ValueUtil.convertToList(document.value()) .stream() diff --git a/jnosql-hbase/pom.xml b/jnosql-hbase/pom.xml index ed3d8ff1a..04b4be1b5 100644 --- a/jnosql-hbase/pom.xml +++ b/jnosql-hbase/pom.xml @@ -40,7 +40,7 @@ org.apache.hbase hbase-client - 2.6.0 + 2.6.3 diff --git a/jnosql-mongodb/src/main/java/org/eclipse/jnosql/databases/mongodb/communication/DocumentQueryConversor.java b/jnosql-mongodb/src/main/java/org/eclipse/jnosql/databases/mongodb/communication/DocumentQueryConversor.java index 21840cc2a..b125cb031 100644 --- a/jnosql-mongodb/src/main/java/org/eclipse/jnosql/databases/mongodb/communication/DocumentQueryConversor.java +++ b/jnosql-mongodb/src/main/java/org/eclipse/jnosql/databases/mongodb/communication/DocumentQueryConversor.java @@ -56,6 +56,9 @@ public static Bson convert(CriteriaCondition condition) { yield Filters.nor(convert(criteriaCondition)); } case LIKE -> Filters.regex(document.name(), Pattern.compile(prepareRegexValue(value.toString()))); + case CONTAINS -> Filters.regex(document.name(), Pattern.compile(prepareContains(value.toString()))); + case STARTS_WITH -> Filters.regex(document.name(), Pattern.compile(prepareStartsWith(value.toString()))); + case ENDS_WITH -> Filters.regex(document.name(), Pattern.compile(prepareEndsWith(value.toString()))); case AND -> { List andConditions = condition.element().value().get(new TypeReference<>() { }); @@ -75,16 +78,43 @@ public static Bson convert(CriteriaCondition condition) { } default -> throw new UnsupportedOperationException("The condition " + condition.condition() - + " is not supported from mongoDB diana driver"); + + " is not supported from Eclipse JNoSQL driver"); }; } - public static String prepareRegexValue(String rawData) { - if (rawData == null) - return "^$"; - return "^" + rawData - .replaceAll("_", ".{1}") - .replaceAll("%", ".{1,}"); + static String prepareRegexValue(String likePattern) { + if (likePattern == null) { + return "(?!)"; // never matches + } + StringBuilder sb = new StringBuilder("^"); + for (char c : likePattern.toCharArray()) { + switch (c) { + case '%': + sb.append(".*"); + break; + case '_': + sb.append('.'); + break; + default: + sb.append(Pattern.quote(String.valueOf(c))); + } + } + sb.append('$'); + return sb.toString(); + } + + static String prepareStartsWith(String raw) { + if (raw == null) return "(?!)"; + return "^" + Pattern.quote(raw) + ".*$"; + } + static String prepareEndsWith(String raw) { + if (raw == null) return "(?!)"; + return "^.*" + Pattern.quote(raw) + "$"; + } + + static String prepareContains(String raw) { + if (raw == null) return "(?!)"; + return "^.*" + Pattern.quote(raw) + ".*$"; } } diff --git a/jnosql-mongodb/src/test/java/org/eclipse/jnosql/databases/mongodb/communication/DocumentQueryConversorTest.java b/jnosql-mongodb/src/test/java/org/eclipse/jnosql/databases/mongodb/communication/DocumentQueryConverterTest.java similarity index 68% rename from jnosql-mongodb/src/test/java/org/eclipse/jnosql/databases/mongodb/communication/DocumentQueryConversorTest.java rename to jnosql-mongodb/src/test/java/org/eclipse/jnosql/databases/mongodb/communication/DocumentQueryConverterTest.java index 112d331ed..f95cd9c20 100644 --- a/jnosql-mongodb/src/test/java/org/eclipse/jnosql/databases/mongodb/communication/DocumentQueryConversorTest.java +++ b/jnosql-mongodb/src/test/java/org/eclipse/jnosql/databases/mongodb/communication/DocumentQueryConverterTest.java @@ -20,30 +20,29 @@ import static org.assertj.core.api.Assertions.assertThat; -class DocumentQueryConversorTest { +class DocumentQueryConverterTest { @ParameterizedTest @CsvSource(textBlock = """ - Max_;^Max.{1} - Max%;^Max.{1,} - M_x;^M.{1}x - M%x;^M.{1,}x - _ax;^.{1}ax - %ax;^.{1,}ax - ;^$ + Max_;^\\QM\\E\\Qa\\E\\Qx\\E.$ + Max%;^\\QM\\E\\Qa\\E\\Qx\\E.*$ + M_x;^\\QM\\E.\\Qx\\E$ + M%x;^\\QM\\E.*\\Qx\\E$ + _ax;^.\\Qa\\E\\Qx\\E$ + %ax;^.*\\Qa\\E\\Qx\\E$ """, delimiterString = ";") void shouldPrepareRegexValueSupportedByMongoDB(String rawValue, String expectedValue) { assertThat(DocumentQueryConversor.prepareRegexValue(rawValue)) .as("The value should be prepared to be used in a MongoDB regex query: " + - "the '_' character should matches any single character, and " + - "the '%' character should matches any sequence of characters.") + "the '_' character should match any single character, and " + + "the '%' character should match any sequence of characters.") .isEqualTo(expectedValue); } @Test - void shouldReturnEmptyRegexWhenRawValueIsNull() { + void shouldReturnNeverMatchingRegexWhenRawValueIsNull() { assertThat(DocumentQueryConversor.prepareRegexValue(null)) - .as("should return an empty regex when the raw value is null") - .isEqualTo("^$"); + .as("should return a never-matching regex when the raw value is null") + .isEqualTo("(?!)"); } } \ No newline at end of file diff --git a/jnosql-mongodb/src/test/java/org/eclipse/jnosql/databases/mongodb/communication/MongoDBDocumentManagerTest.java b/jnosql-mongodb/src/test/java/org/eclipse/jnosql/databases/mongodb/communication/MongoDBDocumentManagerTest.java index 77a80f3bb..46b62c4ba 100644 --- a/jnosql-mongodb/src/test/java/org/eclipse/jnosql/databases/mongodb/communication/MongoDBDocumentManagerTest.java +++ b/jnosql-mongodb/src/test/java/org/eclipse/jnosql/databases/mongodb/communication/MongoDBDocumentManagerTest.java @@ -18,12 +18,14 @@ import org.assertj.core.api.SoftAssertions; import org.eclipse.jnosql.communication.TypeReference; import org.eclipse.jnosql.communication.semistructured.CommunicationEntity; +import org.eclipse.jnosql.communication.semistructured.CriteriaCondition; import org.eclipse.jnosql.communication.semistructured.DatabaseManager; import org.eclipse.jnosql.communication.semistructured.DeleteQuery; import org.eclipse.jnosql.communication.semistructured.Element; import org.eclipse.jnosql.communication.semistructured.Elements; import org.eclipse.jnosql.communication.semistructured.SelectQuery; import org.eclipse.jnosql.databases.mongodb.communication.type.Money; +import org.eclipse.jnosql.mapping.semistructured.MappingQuery; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -247,7 +249,7 @@ void shouldFindDocumentLike() { List entities = StreamSupport.stream(entitiesSaved.spliterator(), false).toList(); var query = select().from(COLLECTION_NAME) - .where("name").like("Lu") + .where("name").like("Lu%") .and("type").eq("V") .build(); @@ -627,6 +629,51 @@ void shouldInsertUUID() { } + @Test + void shouldFindContains() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.contains(Element.of("name", + "lia")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldStartsWith() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.startsWith(Element.of("name", + "Pol")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldEndsWith() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.endsWith(Element.of("name", + "ana")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + private CommunicationEntity createDocumentList() { CommunicationEntity entity = CommunicationEntity.of("AppointmentBook"); diff --git a/jnosql-neo4j/pom.xml b/jnosql-neo4j/pom.xml index cc634cf6f..559d39614 100644 --- a/jnosql-neo4j/pom.xml +++ b/jnosql-neo4j/pom.xml @@ -27,7 +27,7 @@ JNoSQL Neo4J Driver - 5.28.5 + 5.28.9 diff --git a/jnosql-neo4j/src/main/java/org/eclipse/jnosql/databases/neo4j/communication/LikeToCypherRegex.java b/jnosql-neo4j/src/main/java/org/eclipse/jnosql/databases/neo4j/communication/LikeToCypherRegex.java new file mode 100644 index 000000000..a9f29ae44 --- /dev/null +++ b/jnosql-neo4j/src/main/java/org/eclipse/jnosql/databases/neo4j/communication/LikeToCypherRegex.java @@ -0,0 +1,50 @@ +/* + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Apache License v2.0 is available at http://www.opensource.org/licenses/apache2.0.php. + * + * You may elect to redistribute this code under either of these licenses. + * + * Contributors: + * + * Otavio Santana + * + */ +package org.eclipse.jnosql.databases.neo4j.communication; + +import java.util.regex.Pattern; + +enum LikeToCypherRegex { + INSTANCE; + + public String toCypherRegex(String like) { + if (like == null) { + return "(?!)"; + } + StringBuilder regex = new StringBuilder(like.length() + 8); + StringBuilder lit = new StringBuilder(); + + regex.append('^'); + for (int i = 0; i < like.length(); i++) { + char c = like.charAt(i); + if (c == '%' || c == '_') { + if (!lit.isEmpty()) { + regex.append(Pattern.quote(lit.toString())); + lit.setLength(0); + } + regex.append(c == '%' ? ".*" : "."); + } else { + lit.append(c); + } + } + if (!lit.isEmpty()) { + regex.append(Pattern.quote(lit.toString())); + } + regex.append('$'); + return regex.toString(); + } +} diff --git a/jnosql-neo4j/src/main/java/org/eclipse/jnosql/databases/neo4j/communication/Neo4JQueryBuilder.java b/jnosql-neo4j/src/main/java/org/eclipse/jnosql/databases/neo4j/communication/Neo4JQueryBuilder.java index 195d12c6d..548f673d6 100644 --- a/jnosql-neo4j/src/main/java/org/eclipse/jnosql/databases/neo4j/communication/Neo4JQueryBuilder.java +++ b/jnosql-neo4j/src/main/java/org/eclipse/jnosql/databases/neo4j/communication/Neo4JQueryBuilder.java @@ -106,9 +106,12 @@ private void createWhereClause(StringBuilder cypher, CriteriaCondition condition case LESSER_THAN: case LESSER_EQUALS_THAN: case LIKE: + case STARTS_WITH: + case ENDS_WITH: + case CONTAINS: case IN: String paramName = INTERNAL_ID.equals(fieldName) ? "id" : fieldName; // Ensure valid parameter name - parameters.put(paramName, element.get()); + parameters.put(paramName, value(element.get(), condition.condition())); cypher.append(queryField).append(" ") .append(getConditionOperator(condition.condition())) .append(" $").append(paramName); @@ -121,7 +124,8 @@ private void createWhereClause(StringBuilder cypher, CriteriaCondition condition case AND: case OR: cypher.append("("); - List conditions = element.get(new TypeReference<>() {}); + List conditions = element.get(new TypeReference<>() { + }); for (int index = 0; index < conditions.size(); index++) { if (index > 0) { cypher.append(" ").append(getConditionOperator(condition.condition())).append(" "); @@ -135,6 +139,13 @@ private void createWhereClause(StringBuilder cypher, CriteriaCondition condition } } + private Object value(Object value, Condition condition) { + if(Condition.LIKE.equals(condition)) { + return LikeToCypherRegex.INSTANCE.toCypherRegex(value.toString()); + } + return value; + } + private String translateField(String field) { if (INTERNAL_ID.equals(field)) { return "elementId(e)"; @@ -145,7 +156,6 @@ private String translateField(String field) { return "e." + field; } - private String getConditionOperator(Condition condition) { return switch (condition) { case EQUALS -> "="; @@ -153,10 +163,13 @@ private String getConditionOperator(Condition condition) { case GREATER_EQUALS_THAN -> ">="; case LESSER_THAN -> "<"; case LESSER_EQUALS_THAN -> "<="; - case LIKE -> "CONTAINS"; + case LIKE -> "=~"; case IN -> "IN"; case AND -> "AND"; case OR -> "OR"; + case STARTS_WITH -> "STARTS WITH"; + case ENDS_WITH -> "ENDS WITH"; + case CONTAINS -> "CONTAINS"; default -> throw new CommunicationException("Unsupported operator: " + condition); }; } diff --git a/jnosql-neo4j/src/test/java/org/eclipse/jnosql/databases/neo4j/communication/LikeToCypherRegexTest.java b/jnosql-neo4j/src/test/java/org/eclipse/jnosql/databases/neo4j/communication/LikeToCypherRegexTest.java new file mode 100644 index 000000000..68dd80073 --- /dev/null +++ b/jnosql-neo4j/src/test/java/org/eclipse/jnosql/databases/neo4j/communication/LikeToCypherRegexTest.java @@ -0,0 +1,85 @@ +/* + * + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Apache License v2.0 is available at http://www.opensource.org/licenses/apache2.0.php. + * + * You may elect to redistribute this code under either of these licenses. + * + * Contributors: + * + * Otavio Santana + * + */ +package org.eclipse.jnosql.databases.neo4j.communication; + + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.*; + +class LikeToCypherRegexTest { + + + @ParameterizedTest(name = "LIKE \"{0}\" -> regex \"{1}\"") + @CsvSource({ + // contains / starts / ends + "'%Ota%', '^.*\\QOta\\E.*$'", + "'Ota%', '^\\QOta\\E.*$'", + "'%Ota', '^.*\\QOta\\E$'", + // exact (no wildcards) + "'Ota', '^\\QOta\\E$'", + // single-char wildcard + "'Ot_', '^\\QOt\\E.$'", + // mixed case with both _ and % + "'_%ta%', '^..*\\Qta\\E.*$'" + }) + @DisplayName("Converts SQL LIKE to anchored Cypher regex") + void shouldConvertSqlLikeToAnchoredRegex(String like, String expectedRegex) { + String actual = LikeToCypherRegex.INSTANCE.toCypherRegex(like); + assertThat(actual).isEqualTo(expectedRegex); + } + + @Test + @DisplayName("Escapes regex metacharacters in literals") + void shouldEscapeRegexMetacharacters() { + // Input contains regex metas: . ^ $ ( ) [ ] { } + ? * | \ + String like = "%a.^$()[]{}+?*|\\b%"; + String regex = LikeToCypherRegex.INSTANCE.toCypherRegex(like); + + assertThat(regex) + .startsWith("^.*") + .endsWith(".*$") + // The literal run should be quoted as one block + .contains("\\Qa.^$()[]{}+?*|\\b\\E"); + } + + @Test + @DisplayName("Returns never-matching regex for null") + void shouldReturnNeverMatchingForNull() { + String regex = LikeToCypherRegex.INSTANCE.toCypherRegex(null); + assertThat(regex).isEqualTo("(?!)"); + } + + @Test + @DisplayName("Handles empty string as exact empty match") + void shouldHandleEmptyString() { + String regex = LikeToCypherRegex.INSTANCE.toCypherRegex(""); + assertThat(regex).isEqualTo("^$"); // not "^\\Q\\E$" + } + + @Test + @DisplayName("Handles only wildcards") + void shouldHandleOnlyWildcards() { + assertThat(LikeToCypherRegex.INSTANCE.toCypherRegex("%")).isEqualTo("^.*$"); + assertThat(LikeToCypherRegex.INSTANCE.toCypherRegex("%%")).isEqualTo("^.*.*$"); + assertThat(LikeToCypherRegex.INSTANCE.toCypherRegex("_")).isEqualTo("^.$"); + assertThat(LikeToCypherRegex.INSTANCE.toCypherRegex("__")).isEqualTo("^..$"); + } +} \ No newline at end of file diff --git a/jnosql-neo4j/src/test/java/org/eclipse/jnosql/databases/neo4j/communication/Neo4JDatabaseManagerTest.java b/jnosql-neo4j/src/test/java/org/eclipse/jnosql/databases/neo4j/communication/Neo4JDatabaseManagerTest.java index d549779d9..2a83bcc62 100644 --- a/jnosql-neo4j/src/test/java/org/eclipse/jnosql/databases/neo4j/communication/Neo4JDatabaseManagerTest.java +++ b/jnosql-neo4j/src/test/java/org/eclipse/jnosql/databases/neo4j/communication/Neo4JDatabaseManagerTest.java @@ -19,13 +19,16 @@ import net.datafaker.Faker; import org.assertj.core.api.SoftAssertions; import org.eclipse.jnosql.communication.semistructured.CommunicationEntity; +import org.eclipse.jnosql.communication.semistructured.CriteriaCondition; import org.eclipse.jnosql.communication.semistructured.Element; import org.eclipse.jnosql.communication.semistructured.Elements; +import org.eclipse.jnosql.mapping.semistructured.MappingQuery; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -318,7 +321,7 @@ void shouldSelectLike() { var entity = getEntity(); entity.add("name", "Ada Lovelace"); entityManager.insert(entity); - var query = select().from(COLLECTION_NAME).where("name").like("Love").build(); + var query = select().from(COLLECTION_NAME).where("name").like("%Love%").build(); var entities = entityManager.select(query).toList(); SoftAssertions.assertSoftly(softly -> { softly.assertThat(entities).hasSize(1); @@ -607,6 +610,51 @@ void shouldCreateEdgeWithProperties() { }); } + @Test + void shouldFindContains() { + var entity = getEntity(); + entity.add("name", "Poliana"); + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.contains(Element.of("name", + "lia")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldStartsWith() { + var entity = getEntity(); + entity.add("name", "Poliana"); + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.startsWith(Element.of("name", + "Pol")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldEndsWith() { + var entity = getEntity(); + entity.add("name", "Poliana"); + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.endsWith(Element.of("name", + "ana")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + private CommunicationEntity getEntity() { Faker faker = new Faker(); diff --git a/jnosql-oracle-nosql/src/main/java/org/eclipse/jnosql/databases/oracle/communication/AbstractQueryBuilder.java b/jnosql-oracle-nosql/src/main/java/org/eclipse/jnosql/databases/oracle/communication/AbstractQueryBuilder.java index d731cf0b7..debd127a9 100644 --- a/jnosql-oracle-nosql/src/main/java/org/eclipse/jnosql/databases/oracle/communication/AbstractQueryBuilder.java +++ b/jnosql-oracle-nosql/src/main/java/org/eclipse/jnosql/databases/oracle/communication/AbstractQueryBuilder.java @@ -64,9 +64,18 @@ protected void condition(CriteriaCondition condition, StringBuilder query, List< case GREATER_EQUALS_THAN: predicate(query, " >= ", document, params); return; -/* case LIKE: - predicate(query, " LIKE ", document, params); - return;*/ + case LIKE: + predicateLike(query, document); + return; + case CONTAINS: + predicateContains(query, document); + return; + case STARTS_WITH: + predicateStartsWith(query, document); + return; + case ENDS_WITH: + predicateEndsWith(query, document); + return; case NOT: query.append(" NOT "); condition(document.get(CriteriaCondition.class), query, params, ids); @@ -134,6 +143,37 @@ protected void predicate(StringBuilder query, params.add(fieldValue); } + protected void predicateLike(StringBuilder query, + Element document) { + String name = identifierOf(document.name()); + Object value = OracleNoSqlLikeConverter.INSTANCE.convert(document.get()); + query.append("regex_like(").append(name).append(", \"").append(value).append("\")"); + } + + protected void predicateStartsWith(StringBuilder query, + Element document) { + String name = identifierOf(document.name()); + var value = document.get() == null ? "" : document.get(String.class); + query.append("regex_like(").append(name).append(", \"").append(OracleNoSqlLikeConverter.INSTANCE.startsWith(value)).append( + "\")"); + } + + protected void predicateEndsWith(StringBuilder query, + Element document) { + String name = identifierOf(document.name()); + var value = document.get() == null ? "" : document.get(String.class); + query.append("regex_like(").append(name).append(", \"").append(OracleNoSqlLikeConverter.INSTANCE.endsWith(value)).append( + "\")"); + } + + protected void predicateContains(StringBuilder query, + Element document) { + String name = identifierOf(document.name()); + var value = document.get() == null ? "" : document.get(String.class); + query.append("regex_like(").append(name).append(", \"").append(OracleNoSqlLikeConverter.INSTANCE.contains(value)).append( + "\")"); + } + protected String identifierOf(String name) { return ' ' + table + "." + JSON_FIELD + "." + name + ' '; } diff --git a/jnosql-oracle-nosql/src/main/java/org/eclipse/jnosql/databases/oracle/communication/OracleNoSqlLikeConverter.java b/jnosql-oracle-nosql/src/main/java/org/eclipse/jnosql/databases/oracle/communication/OracleNoSqlLikeConverter.java new file mode 100644 index 000000000..e0f201bdc --- /dev/null +++ b/jnosql-oracle-nosql/src/main/java/org/eclipse/jnosql/databases/oracle/communication/OracleNoSqlLikeConverter.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Apache License v2.0 is available at http://www.opensource.org/licenses/apache2.0.php. + * + * You may elect to redistribute this code under either of these licenses. + * + * Contributors: + * + * Otavio Santana + */ +package org.eclipse.jnosql.databases.oracle.communication; + +import java.util.Set; + +enum OracleNoSqlLikeConverter { + INSTANCE; + + private static final Set META = Set.of( + '.', '^', '$', '*', '+', '?', '(', ')', '[', ']', '{', '}', '\\', '|' + ); + + /** + * SQL LIKE (%, _) -> Oracle NoSQL regex_like pattern. + * Examples: + * "Lu%" -> "Lu.*" + * "%Lu" -> ".*Lu" + * "%Lu%" -> ".*Lu.*" + * "Lu" -> "Lu" // exact match equivalent in regex_like + * "a.c" -> "a\\.c" // '.' escaped + */ + String convert(Object value) { + if (value == null) return ""; // let caller decide behavior for empty + String like = value.toString(); + StringBuilder out = new StringBuilder(like.length()); + + for (int i = 0; i < like.length(); i++) { + char c = like.charAt(i); + switch (c) { + case '%': out.append(".*"); break; // zero or more + case '_': out.append('.'); break; // exactly one + default: + if (META.contains(c)) out.append('\\'); + out.append(c); + } + } + return out.toString(); + } + + /** Contains: equivalent to SQL LIKE %term% */ + String contains(String term) { + return ".*" + escape(term) + ".*"; + } + + /** Starts with: equivalent to SQL LIKE term% */ + String startsWith(String term) { + return escape(term) + ".*"; + } + + /** Ends with: equivalent to SQL LIKE %term */ + String endsWith(String term) { + return ".*" + escape(term); + } + + + private String escape(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (char c : s.toCharArray()) { + if (META.contains(c)) out.append('\\'); + out.append(c); + } + return out.toString(); + } +} diff --git a/jnosql-oracle-nosql/src/test/java/org/eclipse/jnosql/databases/oracle/communication/OracleNoSQLDocumentManagerTest.java b/jnosql-oracle-nosql/src/test/java/org/eclipse/jnosql/databases/oracle/communication/OracleNoSQLDocumentManagerTest.java index 7478e32e9..4824522ed 100644 --- a/jnosql-oracle-nosql/src/test/java/org/eclipse/jnosql/databases/oracle/communication/OracleNoSQLDocumentManagerTest.java +++ b/jnosql-oracle-nosql/src/test/java/org/eclipse/jnosql/databases/oracle/communication/OracleNoSQLDocumentManagerTest.java @@ -17,8 +17,11 @@ import org.assertj.core.api.SoftAssertions; import org.eclipse.jnosql.communication.TypeReference; import org.eclipse.jnosql.communication.semistructured.CommunicationEntity; +import org.eclipse.jnosql.communication.semistructured.CriteriaCondition; +import org.eclipse.jnosql.communication.semistructured.DeleteQuery; import org.eclipse.jnosql.communication.semistructured.Element; import org.eclipse.jnosql.communication.semistructured.Elements; +import org.eclipse.jnosql.mapping.semistructured.MappingQuery; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -605,6 +608,76 @@ void shouldDoQueryUsingEnumAsParameter() { }); } + @Test + void shouldFindDocumentLike() { + DeleteQuery deleteQuery = delete().from(COLLECTION_NAME).where("type").eq("V").build(); + entityManager.delete(deleteQuery); + Iterable entitiesSaved = entityManager.insert(getEntitiesWithValues()); + List entities = StreamSupport.stream(entitiesSaved.spliterator(), false).toList(); + + var query = select().from(COLLECTION_NAME) + .where("name").like("Lu%") + .and("type").eq("V") + .build(); + + List entitiesFound = entityManager.select(query).collect(Collectors.toList()); + + SoftAssertions.assertSoftly(soft -> { + soft.assertThat(entitiesFound).hasSize(2); + var names = entitiesFound.stream() + .flatMap(d -> d.find("name").stream()) + .map(d -> d.get(String.class)) + .toList(); + soft.assertThat(names).contains("Lucas", "Luna"); + + }); + } + + @Test + void shouldFindContains() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.contains(Element.of("name", + "lia")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldStartsWith() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.startsWith(Element.of("name", + "Pol")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldEndsWith() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.endsWith(Element.of("name", + "ana")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + private CommunicationEntity createDocumentList() { var entity = CommunicationEntity.of("AppointmentBook"); entity.add(Element.of("_id", new Random().nextInt())); diff --git a/jnosql-oracle-nosql/src/test/java/org/eclipse/jnosql/databases/oracle/communication/OracleNoSqlLikeConverterTest.java b/jnosql-oracle-nosql/src/test/java/org/eclipse/jnosql/databases/oracle/communication/OracleNoSqlLikeConverterTest.java new file mode 100644 index 000000000..29e445b79 --- /dev/null +++ b/jnosql-oracle-nosql/src/test/java/org/eclipse/jnosql/databases/oracle/communication/OracleNoSqlLikeConverterTest.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Apache License v2.0 is available at http://www.opensource.org/licenses/apache2.0.php. + * + * You may elect to redistribute this code under either of these licenses. + * + * Contributors: + * + * Otavio Santana + */ +package org.eclipse.jnosql.databases.oracle.communication; + +import org.junit.jupiter.api.DisplayName; +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.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +class OracleNoSqlLikeConverterTest { + + + @ParameterizedTest(name = "LIKE \"{0}\" -> pattern \"{1}\"") + @MethodSource("cases") + @DisplayName("Converts SQL LIKE to Oracle NoSQL regex_like pattern (no anchors)") + void shouldConvertSqlLikeToOracleNoSqlRegex(String like, String expected) { + String actual = OracleNoSqlLikeConverter.INSTANCE.convert(like); + assertThat(actual).isEqualTo(expected); + } + + static Stream cases() { + return Stream.of( + // starts / ends / contains / exact + arguments("Lu%", "Lu.*"), + arguments("%Lu", ".*Lu"), + arguments("%Lu%", ".*Lu.*"), + arguments("Lu", "Lu"), + + // single-char wildcard + arguments("Ot_", "Ot."), + arguments("_ta", ".ta"), + + // escaping of regex metacharacters + arguments("%a.c%", ".*a\\.c.*"), + arguments("100% match", "100.* match"), + + // edge cases + arguments("", ""), // empty LIKE -> empty pattern + arguments("%%", ".*.*"), // only wildcards + arguments("__", "..") + ); + } + + @Test + @DisplayName("Returns empty string for null input") + void shouldReturnEmptyForNull() { + assertThat(OracleNoSqlLikeConverter.INSTANCE.convert(null)).isEqualTo(""); + } + + @Test + @DisplayName("Returns empty string for empty input") + void shouldReturnEmptyForEmptyString() { + assertThat(OracleNoSqlLikeConverter.INSTANCE.convert("")).isEqualTo(""); + } + + @ParameterizedTest(name = "contains(\"{0}\") -> \"{1}\"") + @MethodSource("containsCases") + @DisplayName("contains(term) escapes meta and wraps with .* … .*") + void shouldContains(String term, String expected) { + String actual = OracleNoSqlLikeConverter.INSTANCE.contains(term); + assertThat(actual).isEqualTo(expected); + } + + static Stream containsCases() { + return Stream.of( + arguments("Lu", ".*Lu.*"), + arguments("a.c", ".*a\\.c.*"), + arguments("price$", ".*price\\$.*"), + arguments("(hello)", ".*\\(hello\\).*"), + arguments("", ".*.*") + ); + } + + @ParameterizedTest(name = "startsWith(\"{0}\") -> \"{1}\"") + @MethodSource("startsWithCases") + @DisplayName("startsWith(term) escapes meta and appends .*") + void shouldStartsWith(String term, String expected) { + String actual = OracleNoSqlLikeConverter.INSTANCE.startsWith(term); + assertThat(actual).isEqualTo(expected); + } + + static Stream startsWithCases() { + return Stream.of( + arguments("Lu", "Lu.*"), + arguments("a.c", "a\\.c.*"), + arguments("price$", "price\\$.*"), + arguments("(hello)", "\\(hello\\).*"), + arguments("", ".*") + ); + } + + + @ParameterizedTest(name = "endsWith(\"{0}\") -> \"{1}\"") + @MethodSource("endsWithCases") + @DisplayName("endsWith(term) escapes meta and prefixes .*") + void shouldEndsWith(String term, String expected) { + String actual = OracleNoSqlLikeConverter.INSTANCE.endsWith(term); + assertThat(actual).isEqualTo(expected); + } + + static Stream endsWithCases() { + return Stream.of( + arguments("Lu", ".*Lu"), + arguments("a.c", ".*a\\.c"), + arguments("price$", ".*price\\$"), + arguments("(hello)", ".*\\(hello\\)"), + arguments("", ".*") + ); + } + + @Test + @DisplayName("All regex metacharacters are escaped in contains/startsWith/endsWith") + void escapesAllMetaCharacters() { + String term = ".^$*+?()[]{}\\|"; + // Expected escaped chunk: \.\^\$\*\+\?\(\)\[\]\{\}\\\| + String escaped = "\\.\\^\\$\\*\\+\\?\\(\\)\\[\\]\\{\\}\\\\\\|"; + + assertThat(OracleNoSqlLikeConverter.INSTANCE.contains(term)) + .isEqualTo(".*" + escaped + ".*"); + + assertThat(OracleNoSqlLikeConverter.INSTANCE.startsWith(term)) + .isEqualTo(escaped + ".*"); + + assertThat(OracleNoSqlLikeConverter.INSTANCE.endsWith(term)) + .isEqualTo(".*" + escaped); + } + +} \ No newline at end of file diff --git a/jnosql-orientdb/pom.xml b/jnosql-orientdb/pom.xml index a9aa1964a..4662c2b20 100644 --- a/jnosql-orientdb/pom.xml +++ b/jnosql-orientdb/pom.xml @@ -35,7 +35,7 @@ com.orientechnologies orientdb-graphdb - 3.2.42 + 3.2.43 ${project.groupId} diff --git a/jnosql-orientdb/src/main/java/org/eclipse/jnosql/databases/orientdb/communication/QueryOSQLConverter.java b/jnosql-orientdb/src/main/java/org/eclipse/jnosql/databases/orientdb/communication/QueryOSQLConverter.java index 09a4ceb56..95e0a754e 100644 --- a/jnosql-orientdb/src/main/java/org/eclipse/jnosql/databases/orientdb/communication/QueryOSQLConverter.java +++ b/jnosql-orientdb/src/main/java/org/eclipse/jnosql/databases/orientdb/communication/QueryOSQLConverter.java @@ -21,6 +21,7 @@ import jakarta.data.Sort; import org.eclipse.jnosql.communication.TypeReference; import org.eclipse.jnosql.communication.ValueUtil; +import org.eclipse.jnosql.communication.driver.StringMatch; import org.eclipse.jnosql.communication.semistructured.CriteriaCondition; import org.eclipse.jnosql.communication.semistructured.Element; import org.eclipse.jnosql.communication.semistructured.SelectQuery; @@ -98,6 +99,16 @@ private static void definesCondition(CriteriaCondition condition, StringBuilder case LIKE: appendCondition(query, params, document, LIKE, ids); return; + case STARTS_WITH: + appendCondition(query, params, Element.of(document.name(), StringMatch.STARTS_WITH.format(document.get(String.class))), LIKE, ids); + return; + case CONTAINS: + appendCondition(query, params, Element.of(document.name(), StringMatch.CONTAINS.format(document.get(String.class))), LIKE, ids); + return; + case ENDS_WITH: + appendCondition(query, params, Element.of(document.name(), StringMatch.ENDS_WITH.format(document.get(String.class))), LIKE, ids); + return; + case AND: for (CriteriaCondition dc : document.get(new TypeReference>() { })) { diff --git a/jnosql-redis/pom.xml b/jnosql-redis/pom.xml index 39b92a9da..458da9a05 100644 --- a/jnosql-redis/pom.xml +++ b/jnosql-redis/pom.xml @@ -38,7 +38,7 @@ redis.clients jedis - 6.0.0 + 6.1.0 ${project.groupId} diff --git a/jnosql-solr/src/test/java/org/eclipse/jnosql/databases/solr/communication/DefaultSolrDocumentManagerTest.java b/jnosql-solr/src/test/java/org/eclipse/jnosql/databases/solr/communication/DefaultSolrDocumentManagerTest.java index 7532d7192..8c731ec29 100644 --- a/jnosql-solr/src/test/java/org/eclipse/jnosql/databases/solr/communication/DefaultSolrDocumentManagerTest.java +++ b/jnosql-solr/src/test/java/org/eclipse/jnosql/databases/solr/communication/DefaultSolrDocumentManagerTest.java @@ -406,13 +406,6 @@ void shouldReturnErrorWhenSaveSubDocument() { } - @Test - void shouldSaveSubDocument2() { - var entity = getEntity(); - entity.add(Element.of("phones", asList(Element.of("mobile", "1231231"), Element.of("mobile2", "1231231")))); - Assertions.assertThrows(SolrException.class, () -> entityManager.insert(entity)); - } - @Test void shouldCreateDate() { Date date = new Date(); @@ -435,11 +428,6 @@ void shouldCreateDate() { assertEquals(now, documentEntity.find("date").get().get(LocalDate.class)); } - @Test - void shouldRetrieveListSubdocumentList() { - Assertions.assertThrows(SolrException.class, () -> entityManager.insert(createSubdocumentList())); - } - @Test void shouldCount() { var entity = entityManager.insert(getEntity()); diff --git a/jnosql-tinkerpop/pom.xml b/jnosql-tinkerpop/pom.xml index e83229ed2..d9128c0aa 100644 --- a/jnosql-tinkerpop/pom.xml +++ b/jnosql-tinkerpop/pom.xml @@ -27,7 +27,7 @@ JNoSQL Apache Tinkerpop Driver - 3.7.3 + 3.7.4 0.9-3.4.0 4.9.1 diff --git a/jnosql-tinkerpop/src/main/java/org/eclipse/jnosql/databases/tinkerpop/communication/LikeToRegex.java b/jnosql-tinkerpop/src/main/java/org/eclipse/jnosql/databases/tinkerpop/communication/LikeToRegex.java new file mode 100644 index 000000000..6fb1e5d08 --- /dev/null +++ b/jnosql-tinkerpop/src/main/java/org/eclipse/jnosql/databases/tinkerpop/communication/LikeToRegex.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Apache License v2.0 is available at http://www.opensource.org/licenses/apache2.0.php. + * + * You may elect to redistribute this code under either of these licenses. + * + * Contributors: + * + * Otavio Santana + */ +package org.eclipse.jnosql.databases.tinkerpop.communication; + + +/** + * The like to regex converter + */ +enum LikeToRegex { + INSTANCE; + + + /** + * Converts like pattern to regex pattern. + * + * @param text the like pattern to convert + * @return the regex pattern + */ + String likeToRegex(Object text) { + String like = text == null ? null : text.toString(); + if (like == null) { + return "(?!)"; + } + StringBuilder rx = new StringBuilder("^"); + StringBuilder lit = new StringBuilder(); + for (int i = 0; i < like.length(); i++) { + char c = like.charAt(i); + if (c == '%' || c == '_') { + if (!lit.isEmpty()) { + rx.append(java.util.regex.Pattern.quote(lit.toString())); + lit.setLength(0); + } + rx.append(c == '%' ? ".*" : "."); + } else { + lit.append(c); + } + } + if (!lit.isEmpty()) { + rx.append(java.util.regex.Pattern.quote(lit.toString())); + } + rx.append('$'); + return rx.toString(); + } + +} diff --git a/jnosql-tinkerpop/src/main/java/org/eclipse/jnosql/databases/tinkerpop/communication/TraversalExecutor.java b/jnosql-tinkerpop/src/main/java/org/eclipse/jnosql/databases/tinkerpop/communication/TraversalExecutor.java index cf1085151..1f7f36676 100644 --- a/jnosql-tinkerpop/src/main/java/org/eclipse/jnosql/databases/tinkerpop/communication/TraversalExecutor.java +++ b/jnosql-tinkerpop/src/main/java/org/eclipse/jnosql/databases/tinkerpop/communication/TraversalExecutor.java @@ -15,6 +15,7 @@ package org.eclipse.jnosql.databases.tinkerpop.communication; import org.apache.tinkerpop.gremlin.process.traversal.P; +import org.apache.tinkerpop.gremlin.process.traversal.TextP; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversal; import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__; import org.apache.tinkerpop.gremlin.structure.Vertex; @@ -41,6 +42,18 @@ static GraphTraversal getPredicate(CriteriaCondition condition) case EQUALS -> { return __.has(name, P.eq(value)); } + case LIKE -> { + return __.has(name, TextP.regex(LikeToRegex.INSTANCE.likeToRegex(value))); + } + case ENDS_WITH -> { + return __.has(name, TextP.endingWith(value == null ? "" : value.toString())); + } + case STARTS_WITH -> { + return __.has(name, TextP.startingWith(value == null ? "" : value.toString())); + } + case CONTAINS -> { + return __.has(name, TextP.containing(value == null ? "" : value.toString())); + } case GREATER_THAN -> { return __.has(name, P.gt(value)); } @@ -79,6 +92,7 @@ static GraphTraversal getPredicate(CriteriaCondition condition) .reduce(GraphTraversal::or) .orElseThrow(() -> new UnsupportedOperationException("There is an inconsistency at the OR operator")); } + default -> throw new UnsupportedOperationException("There is not support to the type " + operator + " in graph"); } diff --git a/jnosql-tinkerpop/src/test/java/org/eclipse/jnosql/databases/tinkerpop/communication/DefaultTinkerpopGraphDatabaseManagerTest.java b/jnosql-tinkerpop/src/test/java/org/eclipse/jnosql/databases/tinkerpop/communication/DefaultTinkerpopGraphDatabaseManagerTest.java index 6e7c5e5ee..0e62930c7 100644 --- a/jnosql-tinkerpop/src/test/java/org/eclipse/jnosql/databases/tinkerpop/communication/DefaultTinkerpopGraphDatabaseManagerTest.java +++ b/jnosql-tinkerpop/src/test/java/org/eclipse/jnosql/databases/tinkerpop/communication/DefaultTinkerpopGraphDatabaseManagerTest.java @@ -20,14 +20,17 @@ import org.assertj.core.api.SoftAssertions; import org.eclipse.jnosql.communication.graph.CommunicationEdge; import org.eclipse.jnosql.communication.semistructured.CommunicationEntity; +import org.eclipse.jnosql.communication.semistructured.CriteriaCondition; import org.eclipse.jnosql.communication.semistructured.DeleteQuery; import org.eclipse.jnosql.communication.semistructured.Element; import org.eclipse.jnosql.communication.semistructured.Elements; import org.eclipse.jnosql.communication.semistructured.SelectQuery; +import org.eclipse.jnosql.mapping.semistructured.MappingQuery; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.time.Duration; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -488,6 +491,68 @@ void shouldFindEdgeById() { assertEquals(edge.target().find("_id").orElseThrow().get(), foundEdge.get().target().find("_id").orElseThrow().get()); } + @Test + void shouldFindContains() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.contains(Element.of("name", + "lia")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldStartsWith() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.startsWith(Element.of("name", + "Pol")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldEndsWith() { + var entity = getEntity(); + + entityManager.insert(entity); + var query = new MappingQuery(Collections.emptyList(), 0L, 0L, CriteriaCondition.endsWith(Element.of("name", + "ana")), COLLECTION_NAME, Collections.emptyList()); + + var result = entityManager.select(query).toList(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(1); + softly.assertThat(result.get(0).find("name").orElseThrow().get(String.class)).isEqualTo("Poliana"); + }); + } + + @Test + void shouldFindDocumentLike() { + DeleteQuery deleteQuery = delete().from(COLLECTION_NAME).where("type").eq("V").build(); + entityManager.delete(deleteQuery); + Iterable entitiesSaved = entityManager.insert(getEntitiesWithValues()); + List entities = StreamSupport.stream(entitiesSaved.spliterator(), false).toList(); + + var query = select().from(COLLECTION_NAME) + .where("name").like("Lu%") + .and("type").eq("V") + .build(); + + List entitiesFound = entityManager.select(query).collect(Collectors.toList()); + assertEquals(2, entitiesFound.size()); + assertThat(entitiesFound).contains(entities.get(0), entities.get(2)); + } + private CommunicationEntity getEntity() { CommunicationEntity entity = CommunicationEntity.of(COLLECTION_NAME); Map map = new HashMap<>();