diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 00b80d84e..c7fdc3a2f 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 === Changed diff --git a/jnosql-arangodb/pom.xml b/jnosql-arangodb/pom.xml index 795c1b2c2..117d38ce4 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 1cda8cf0c..16c87ba53 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 @@ -42,12 +42,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 9d9c017e6..2462c3269 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..ef9e40a15 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; @@ -210,6 +213,51 @@ void shouldCount() { assertTrue(counted > 0); } + @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 createSubdocumentList() { CommunicationEntity entity = CommunicationEntity.of(COLLECTION_APP_NAME); entity.add(Element.of("_id", "ids")); 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..70d5d72db --- /dev/null +++ b/jnosql-database-commons/src/main/java/org/eclipse/jnosql/communication/driver/StringMatch.java @@ -0,0 +1,118 @@ + +/* + * 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); + } + +} \ No newline at end of file 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 fce557f16..d616c887a 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 99965892a..feb7d6610 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..31a6d8c76 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,24 @@ 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 2f8c25626..99cd06aae 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..40b07fe90 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<>() { }); @@ -79,12 +82,39 @@ public static Bson convert(CriteriaCondition condition) { }; } - 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 77% 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..1da85fb4f 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,17 +20,16 @@ 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)) @@ -41,9 +40,9 @@ void shouldPrepareRegexValueSupportedByMongoDB(String rawValue, String expectedV } @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..bbe236fe2 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(); @@ -624,7 +626,52 @@ void shouldInsertUUID() { soft.assertThat(element.name()).isEqualTo("uuid"); soft.assertThat(element.get(UUID.class)).isInstanceOf(UUID.class); }); + } + + + @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"); + }); } diff --git a/jnosql-neo4j/pom.xml b/jnosql-neo4j/pom.xml index 614a2dfc2..e9eba818c 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..6f2227558 --- /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(); + } +} \ No newline at end of file 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..ad2e87e5b 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); @@ -135,6 +138,12 @@ 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)"; @@ -153,10 +162,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..91fe82131 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; @@ -607,6 +610,52 @@ 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 0793a691f..924de5846 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); @@ -141,4 +150,36 @@ protected String identifierOf(String name) { protected void entityCondition(StringBuilder query, String tableName) { query.append(" WHERE ").append(table).append(".entity= '").append(tableName).append("'"); } + + + 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( + "\")"); + } } 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..de3995591 --- /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(); + } +} \ No newline at end of file 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 7b916e6fd..a335053b5 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; @@ -581,6 +584,76 @@ private CommunicationEntity createDocumentList() { return entity; } + @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 getEntity() { var entity = CommunicationEntity.of(COLLECTION_NAME); Map map = new HashMap<>(); 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 2a9bb8df9..fa0918bfc 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..106154519 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,15 @@ 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 15ca52e00..7d02290a6 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-tinkerpop/pom.xml b/jnosql-tinkerpop/pom.xml index e43393e89..3c4addfe9 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..62775cabe --- /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(); + } + +} \ No newline at end of file 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..2f7c9cdce 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)); } 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<>();