diff --git a/src/main/asciidoc/reference/query-methods.adoc b/src/main/asciidoc/reference/query-methods.adoc index acd33683f..caad3e015 100644 --- a/src/main/asciidoc/reference/query-methods.adoc +++ b/src/main/asciidoc/reference/query-methods.adoc @@ -47,11 +47,39 @@ include::{spring-data-commons-docs}/query-by-example.adoc[leveloffset=+1] |Not In |findByLastNameNotIn(Collection) |...where x.lastName not in ? +|IgnoreCase |findByLastNameIgnoreCase |...where UPPER(x.lastName) = UPPER(?) + +|Exists + +IsNotNull + +|findByAddressExists() + +findByParameterIsNotNull() + +findByParameterFieldExists() + +|...where address exists and != null + +...where parameter exists and != null + +...where parameter.field exists and != null + +("Exists" and "IsNotNull" represent the same functionality and can be used interchangeably as bins/fields exist only when not set to null) + +|IsNull |findByParameterIsNull() + +findByParameterFieldIsNull() + +|...where parameter = null + +...where parameter.field = null + +("IsNull" in fact means that the bin/field does not exist) + |True |findByEnabledTrue() |...where x.enabled = true |False |findByOptOutFalse() |...where x.optOut = false - -|IgnoreCase |findByLastNameIgnoreCase |...where UPPER(x.lastName) = UPPER(?) |=== An example of an interface with several query methods is: diff --git a/src/main/java/org/springframework/data/aerospike/convert/MappingAerospikeWriteConverter.java b/src/main/java/org/springframework/data/aerospike/convert/MappingAerospikeWriteConverter.java index 9ee3b997a..fcd9472a9 100644 --- a/src/main/java/org/springframework/data/aerospike/convert/MappingAerospikeWriteConverter.java +++ b/src/main/java/org/springframework/data/aerospike/convert/MappingAerospikeWriteConverter.java @@ -201,6 +201,10 @@ protected Map convertMap(final Map source, final return source.entrySet().stream().collect(TreeMap::new, (m, e) -> { Object key = e.getKey(); Object value = e.getValue(); + if (key == null) { + throw new UnsupportedOperationException("Key of a map cannot be null"); + } + if (!conversions.isSimpleType(key.getClass())) { throw new MappingException("Cannot use a complex object as a key value."); } diff --git a/src/main/java/org/springframework/data/aerospike/query/FilterOperation.java b/src/main/java/org/springframework/data/aerospike/query/FilterOperation.java index d538ebdeb..facd7c54d 100644 --- a/src/main/java/org/springframework/data/aerospike/query/FilterOperation.java +++ b/src/main/java/org/springframework/data/aerospike/query/FilterOperation.java @@ -4,6 +4,7 @@ import com.aerospike.client.cdt.CTX; import com.aerospike.client.cdt.ListReturnType; import com.aerospike.client.cdt.MapReturnType; +import com.aerospike.client.command.ParticleType; import com.aerospike.client.exp.Exp; import com.aerospike.client.exp.ListExp; import com.aerospike.client.exp.MapExp; @@ -29,6 +30,7 @@ import static com.aerospike.client.command.ParticleType.JBLOB; import static com.aerospike.client.command.ParticleType.LIST; import static com.aerospike.client.command.ParticleType.MAP; +import static com.aerospike.client.command.ParticleType.NULL; import static com.aerospike.client.command.ParticleType.STRING; import static org.springframework.data.aerospike.query.Qualifier.CONVERTER; import static org.springframework.data.aerospike.query.Qualifier.DOT_PATH; @@ -157,9 +159,8 @@ public Exp filterExp(Map qualifierMap) { } case BOOL -> Exp.eq(Exp.boolBin(getField(qualifierMap)), Exp.val((Boolean) value.getObject())); case JBLOB -> getFilterExp(getConverter(qualifierMap), value, getField(qualifierMap), Exp::eq); - case MAP -> - getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::eq, - Exp::mapBin); + case MAP -> getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::eq, + Exp::mapBin); case LIST -> getFilterExp(Exp.val((List) value.getObject()), getField(qualifierMap), Exp::eq, Exp::listBin); default -> throw new IllegalArgumentException("EQ FilterExpression unsupported particle type: " + @@ -207,9 +208,8 @@ public Exp filterExp(Map qualifierMap) { yield Exp.or(Exp.not(Exp.binExists(getField(qualifierMap))), ne); } case JBLOB -> getFilterExp(getConverter(qualifierMap), value, getField(qualifierMap), Exp::ne); - case MAP -> - getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::ne, - Exp::mapBin); + case MAP -> getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::ne, + Exp::mapBin); case LIST -> getFilterExp(Exp.val((List) value.getObject()), getField(qualifierMap), Exp::ne, Exp::listBin); default -> throw new IllegalArgumentException("NOTEQ FilterExpression unsupported particle type: " + @@ -231,9 +231,8 @@ public Exp filterExp(Map qualifierMap) { case STRING -> Exp.gt(Exp.stringBin(getField(qualifierMap)), Exp.val(getValue1(qualifierMap).toString())); case JBLOB -> getFilterExp(getConverter(qualifierMap), value, getField(qualifierMap), Exp::gt); - case MAP -> - getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::gt, - Exp::mapBin); + case MAP -> getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::gt, + Exp::mapBin); case LIST -> getFilterExp(Exp.val((List) value.getObject()), getField(qualifierMap), Exp::gt, Exp::listBin); default -> throw new IllegalArgumentException("GT FilterExpression unsupported particle type: " + @@ -260,9 +259,8 @@ public Exp filterExp(Map qualifierMap) { case STRING -> Exp.ge(Exp.stringBin(getField(qualifierMap)), Exp.val(getValue1(qualifierMap).toString())); case JBLOB -> getFilterExp(getConverter(qualifierMap), value, getField(qualifierMap), Exp::ge); - case MAP -> - getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::ge, - Exp::mapBin); + case MAP -> getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::ge, + Exp::mapBin); case LIST -> getFilterExp(Exp.val((List) value.getObject()), getField(qualifierMap), Exp::ge, Exp::listBin); default -> throw new IllegalArgumentException("GTEQ FilterExpression unsupported particle type: " + @@ -287,9 +285,8 @@ public Exp filterExp(Map qualifierMap) { case STRING -> Exp.lt(Exp.stringBin(getField(qualifierMap)), Exp.val(getValue1(qualifierMap).toString())); case JBLOB -> getFilterExp(getConverter(qualifierMap), value, getField(qualifierMap), Exp::lt); - case MAP -> - getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::lt, - Exp::mapBin); + case MAP -> getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::lt, + Exp::mapBin); case LIST -> getFilterExp(Exp.val((List) value.getObject()), getField(qualifierMap), Exp::lt, Exp::listBin); default -> throw new IllegalArgumentException("LT FilterExpression unsupported particle type: " + @@ -315,9 +312,8 @@ public Exp filterExp(Map qualifierMap) { case STRING -> Exp.le(Exp.stringBin(getField(qualifierMap)), Exp.val(getValue1(qualifierMap).toString())); case JBLOB -> getFilterExp(getConverter(qualifierMap), value, getField(qualifierMap), Exp::le); - case MAP -> - getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::le, - Exp::mapBin); + case MAP -> getFilterExp(Exp.val(getConvertedMap(qualifierMap, value)), getField(qualifierMap), Exp::le, + Exp::mapBin); case LIST -> getFilterExp(Exp.val((List) value.getObject()), getField(qualifierMap), Exp::le, Exp::listBin); default -> throw new IllegalArgumentException("LTEQ FilterExpression unsupported particle type: " + @@ -487,7 +483,11 @@ public Filter sIndexFilter(Map qualifierMap) { MAP_VAL_NOTEQ_BY_KEY { @Override public Exp filterExp(Map qualifierMap) { - return getFilterExpMapValNotEqOrFail(qualifierMap, Exp::ne); + if (getValue1(qualifierMap) instanceof Value.NullValue || getValue1(qualifierMap).getObject() != null) { + return findNotNullByMapKey(qualifierMap); + } else { + return getFilterExpMapValNotEqOrFail(qualifierMap, Exp::ne); + } } @Override @@ -778,6 +778,28 @@ public Filter sIndexFilter(Map qualifierMap) { return null; // String secondary index does not support "contains" queries } }, + MAP_VAL_NOT_NULL_BY_KEY { + @Override + public Exp filterExp(Map qualifierMap) { + return findNotNullByMapKey(qualifierMap); + } + + @Override + public Filter sIndexFilter(Map qualifierMap) { + return null; + } + }, + MAP_VAL_IS_NULL_BY_KEY { + @Override + public Exp filterExp(Map qualifierMap) { + return getMapValEqOrFail(qualifierMap, Exp::eq, "MAP_VAL_IS_NULL_BY_KEY"); + } + + @Override + public Filter sIndexFilter(Map qualifierMap) { + return null; + } + }, MAP_KEYS_CONTAIN { @Override public Exp filterExp(Map qualifierMap) { @@ -839,6 +861,7 @@ public Exp filterExp(Map qualifierMap) { case JBLOB -> getConvertedValue1Exp(qualifierMap); case LIST -> Exp.val((List) getValue1(qualifierMap).getObject()); case MAP -> Exp.val(getConvertedMap(qualifierMap, FilterOperation::getValue1)); + case NULL -> Exp.nil(); default -> throw new IllegalArgumentException( "MAP_VALUES_CONTAIN FilterExpression unsupported type: got " + getValue1(qualifierMap).getClass().getSimpleName()); @@ -863,6 +886,7 @@ public Exp filterExp(Map qualifierMap) { case JBLOB -> getConvertedValue1Exp(qualifierMap); case LIST -> Exp.val((List) getValue1(qualifierMap).getObject()); case MAP -> Exp.val(getConvertedMap(qualifierMap, FilterOperation::getValue1)); + case NULL -> Exp.nil(); default -> throw new IllegalArgumentException( "MAP_VALUES_CONTAIN FilterExpression unsupported type: got " + getValue1(qualifierMap).getClass().getSimpleName()); @@ -976,6 +1000,7 @@ public Exp filterExp(Map qualifierMap) { case JBLOB -> getConvertedValue1Exp(qualifierMap); case LIST -> Exp.val((List) getValue1(qualifierMap).getObject()); case MAP -> Exp.val(getConvertedMap(qualifierMap, FilterOperation::getValue1)); + case ParticleType.NULL -> Exp.nil(); default -> throw new IllegalArgumentException( "LIST_VAL_CONTAINING FilterExpression unsupported type: got " + getValue1(qualifierMap).getClass().getSimpleName()); @@ -1011,6 +1036,7 @@ public Exp filterExp(Map qualifierMap) { case JBLOB -> getConvertedValue1Exp(qualifierMap); case LIST -> Exp.val((List) getValue1(qualifierMap).getObject()); case MAP -> Exp.val(getConvertedMap(qualifierMap, FilterOperation::getValue1)); + case ParticleType.NULL -> Exp.nil(); default -> throw new IllegalArgumentException( "LIST_VAL_CONTAINING FilterExpression unsupported type: got " + getValue1(qualifierMap).getClass().getSimpleName()); @@ -1218,18 +1244,46 @@ public Filter sIndexFilter(Map qualifierMap) { return Filter.range(getField(qualifierMap), IndexCollectionType.LIST, Long.MIN_VALUE, getValue1(qualifierMap).toLong()); } - }, EXISTS { + }, IS_NOT_NULL { @Override public Exp filterExp(Map qualifierMap) { return Exp.binExists(getField(qualifierMap)); } + @Override + public Filter sIndexFilter(Map qualifierMap) { + return null; + } + }, IS_NULL { + @Override + public Exp filterExp(Map qualifierMap) { + return Exp.not(Exp.binExists(getField(qualifierMap))); // with value set to null a bin becomes non-existing + } + + @Override + public Filter sIndexFilter(Map qualifierMap) { + return null; + } + }, NOT_NULL { + @Override + public Exp filterExp(Map qualifierMap) { + return Exp.binExists(getField(qualifierMap)); // if a bin exists its value is not null + } + @Override public Filter sIndexFilter(Map qualifierMap) { return null; } }; + private static Exp findNotNullByMapKey(Map qualifierMap) { + String key = getValue2(qualifierMap).toString(); + return Exp.gt( + MapExp.getByKey(MapReturnType.COUNT, Exp.Type.INT, Exp.val(key), + Exp.mapBin(getField(qualifierMap))), + Exp.val(0)); + } + private static Exp getConvertedValue1Exp(Map qualifierMap) { Object convertedValue = getConvertedValue(qualifierMap, FilterOperation::getValue1); Exp exp; @@ -1364,7 +1418,11 @@ yield getMapValEqExp(qualifierMap, expType, convertedValue, dotPathArr, operator } case LIST -> getMapValEqExp(qualifierMap, Exp.Type.LIST, value1.getObject(), dotPathArr, operator, useCtx); - case MAP -> getMapValEqExp(qualifierMap, Exp.Type.MAP, Exp.val(getConvertedMap(qualifierMap, value1)), dotPathArr, operator, + case MAP -> + getMapValEqExp(qualifierMap, Exp.Type.MAP, Exp.val(getConvertedMap(qualifierMap, value1)), dotPathArr + , operator, + useCtx); + case ParticleType.NULL -> getMapValEqExp(qualifierMap, Exp.Type.NIL, value1, dotPathArr, operator, useCtx); default -> throw new IllegalArgumentException( opName + " FilterExpression unsupported type: " + value1.getClass().getSimpleName()); @@ -1487,6 +1545,8 @@ private static Exp toExp(Object value) { res = Exp.val((byte[]) value); } else if (value instanceof Calendar) { res = Exp.val((Calendar) value); + } else if (value instanceof Value.NullValue) { + res = Exp.nil(); } else { throw new IllegalArgumentException("Unsupported type for converting: " + value.getClass() .getCanonicalName()); diff --git a/src/main/java/org/springframework/data/aerospike/repository/query/AerospikeQueryCreator.java b/src/main/java/org/springframework/data/aerospike/repository/query/AerospikeQueryCreator.java index 6191c2711..0166a238f 100644 --- a/src/main/java/org/springframework/data/aerospike/repository/query/AerospikeQueryCreator.java +++ b/src/main/java/org/springframework/data/aerospike/repository/query/AerospikeQueryCreator.java @@ -122,10 +122,10 @@ private AerospikeCriteria create(Part part, AerospikePersistentProperty property case NEGATING_SIMPLE_PROPERTY -> getCriteria(part, property, v1, null, parameters, FilterOperation.NOTEQ); case IN -> getCriteria(part, property, v1, null, parameters, FilterOperation.IN); case NOT_IN -> getCriteria(part, property, v1, null, parameters, FilterOperation.NOT_IN); - case TRUE -> getCriteria(part, property, true, null, parameters, FilterOperation.EQ); - case FALSE -> getCriteria(part, property, false, null, parameters, FilterOperation.EQ); - case EXISTS -> getCriteria(part, property, false, null, parameters, FilterOperation.EXISTS); - case IS_NOT_NULL -> getCriteria(part, property, false, null, parameters, FilterOperation.EXISTS); + case TRUE -> getCriteria(part, property, true, null, parameters, FilterOperation.EQ); + case FALSE -> getCriteria(part, property, false, null, parameters, FilterOperation.EQ); + case EXISTS, IS_NOT_NULL -> getCriteria(part, property, null, null, parameters, FilterOperation.IS_NOT_NULL); + case IS_NULL -> getCriteria(part, property, null, null, parameters, FilterOperation.IS_NULL); default -> throw new IllegalArgumentException("Unsupported keyword '" + part.getType() + "'"); }; } diff --git a/src/test/java/org/springframework/data/aerospike/repository/PersonRepositoryQueryTests.java b/src/test/java/org/springframework/data/aerospike/repository/PersonRepositoryQueryTests.java index 7c563c638..981324086 100644 --- a/src/test/java/org/springframework/data/aerospike/repository/PersonRepositoryQueryTests.java +++ b/src/test/java/org/springframework/data/aerospike/repository/PersonRepositoryQueryTests.java @@ -19,8 +19,10 @@ import org.springframework.data.domain.Sort; import java.time.LocalDate; +import java.util.ArrayList; import java.util.Collection; import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -558,13 +560,107 @@ void findByMapKeyValueNotEqual() { @Test void findByAddressExists() { assertThat(stefan.getAddress()).isNull(); + assertThat(carter.getAddress()).isNotNull(); + assertThat(dave.getAddress()).isNotNull(); - List persons = repository.findByAddressExists(); - assertThat(persons).contains(carter); - List persons2 = repository.findByAddressIsNotNull(); - assertThat(persons2).contains(carter); -// List persons = repository.findByAddressNotExists(); -// assertThat(persons).contains(stefan); + assertThat(repository.findByAddressExists()).contains(carter, dave).doesNotContain(stefan); + assertThat(repository.findByAddressZipCodeExists()).contains(carter, dave).doesNotContain(stefan); + + stefan.setAddress(new Address(null, null, null, null)); + repository.save(stefan); + // when set to null a bin/field becomes non-existing + assertThat(repository.findByAddressZipCodeExists()).contains(carter, dave).doesNotContain(stefan); + + stefan.setAddress(new Address(null, null, "zipCode", null)); + repository.save(stefan); + assertThat(repository.findByAddressZipCodeExists()).contains(carter, dave, stefan); + + stefan.setAddress(null); // cleanup + repository.save(stefan); + } + + @Test + void findByAddressIsNull() { + assertThat(stefan.getAddress()).isNull(); + assertThat(carter.getAddress()).isNotNull(); + assertThat(dave.getAddress()).isNotNull(); + assertThat(repository.findByAddressIsNull()).contains(stefan).doesNotContain(carter, dave); + + stefan.setAddress(new Address(null, null, null, null)); + repository.save(stefan); + assertThat(repository.findByAddressIsNull()).doesNotContain(stefan); + assertThat(repository.findByAddressZipCodeIsNull()).contains(stefan); + assertThat(repository.findByAddressZipCode(null)).contains(stefan).doesNotContain(carter, dave); + + dave.setBestFriend(stefan); + repository.save(dave); + carter.setFriend(dave); + repository.save(carter); + assertThat(repository.findByFriendBestFriendAddressZipCode(null)).contains(carter); + assertThat(repository.findByFriendBestFriendAddressZipCodeIsNull()).contains(carter); + + stefan.setAddress(null); // cleanup + repository.save(stefan); + TestUtils.setFriendsToNull(repository, carter, dave); + + Map stringMap = new HashMap<>(); + stringMap.put("key", null); + stefan.setStringMap(stringMap); + repository.save(stefan); + assertThat(repository.findByStringMapContaining("key", (String) null)).contains(stefan); // key-specific + assertThat(repository.findByStringMapContaining("key12345", (String) null)).contains(stefan); // no such key + assertThat(repository.findByStringMapContaining(null, VALUE)).contains(stefan); // among all values in map + + List strings = new ArrayList<>(); + strings.add(null); + stefan.setStrings(strings); + repository.save(stefan); + assertThat(repository.findByStringsContaining(null)).contains(stefan); + + stefan.setStringMap(null); // cleanup + repository.save(stefan); + stefan.setStrings(null); + repository.save(stefan); + } + + @Test + void findByAddressIsNotNull() { + assertThat(stefan.getAddress()).isNull(); + assertThat(carter.getAddress()).isNotNull(); + assertThat(dave.getAddress()).isNotNull(); + assertThat(repository.findByAddressIsNotNull()).contains(carter, dave).doesNotContain(stefan); + + stefan.setAddress(new Address(null, null, "zipCode", null)); + repository.save(stefan); + assertThat(repository.findByAddressIsNotNull()).contains(stefan); + assertThat(repository.findByAddressZipCodeIsNotNull()).contains(stefan); // zipCode is not null + assertThat(repository.findByAddressZipCodeIsNot(null)).contains(stefan); + + stefan.setAddress(new Address(null, null, null, null)); + repository.save(stefan); + assertThat(repository.findByAddressIsNotNull()).contains(stefan); // Address is not null + assertThat(repository.findByAddressZipCodeIsNotNull()).doesNotContain(stefan); // zipCode is null + + stefan.setAddress(null); // cleanup + repository.save(stefan); + + Map stringMap = new HashMap<>(); + stringMap.put("key", "str"); + stefan.setStringMap(stringMap); + repository.save(stefan); + assertThat(repository.findByStringMapNotContaining("key", (String) null)).contains(stefan); + assertThat(repository.findByStringMapNotContaining(null, VALUE)).contains(stefan); + + List strings = new ArrayList<>(); + strings.add("ing"); + stefan.setStrings(strings); + repository.save(stefan); + assertThat(repository.findByStringsNotContaining(null)).contains(stefan); + + stefan.setStringMap(null); // cleanup + repository.save(stefan); + stefan.setStrings(null); + repository.save(stefan); } @Test diff --git a/src/test/java/org/springframework/data/aerospike/sample/PersonRepository.java b/src/test/java/org/springframework/data/aerospike/sample/PersonRepository.java index 35d3c0949..b44537b82 100644 --- a/src/test/java/org/springframework/data/aerospike/sample/PersonRepository.java +++ b/src/test/java/org/springframework/data/aerospike/sample/PersonRepository.java @@ -138,12 +138,25 @@ public interface PersonRepository

extends AerospikeRepository< List

findByAddressExists(); - List

findByAddressIsNotNull(); + List

findByAddressZipCodeExists(); - List

findByAddressNotExists(); + List

findByAddressIsNotNull(); List

findByAddressIsNull(); + List

findByAddressZipCodeIsNull(); + + /** + * Find all entities that satisfy the condition "have a friend who has bestFriend with the address with zipCode + * which is not null" (find by nested POJO field) + */ + List

findByFriendBestFriendAddressZipCodeIsNull(); + + /** + * Find all entities that satisfy the condition "have address with existing zipCode" + */ + List

findByAddressZipCodeIsNotNull(); + /** * Find all entities that satisfy the condition "have Address with fewer elements or with a corresponding key-value * lower in ordering than in the given argument" (find by POJO). @@ -156,6 +169,11 @@ public interface PersonRepository

extends AerospikeRepository< List

findByAddressZipCode(String zipCode); + /** + * Find all entities that satisfy the condition "have address with zipCode which is not equal to the given argument" + */ + List

findByAddressZipCodeIsNot(String zipCode); + List

findByAddressZipCodeContaining(String str); List

findByAddressZipCodeNotContaining(String str); @@ -339,6 +357,14 @@ public interface PersonRepository

extends AerospikeRepository< */ List

findByStringMapNotContaining(String value, CriteriaDefinition.AerospikeMapCriteria criterion); + /** + * Find all entities that satisfy the condition "have the given map key and the value equal to the given string" + * + * @param key Map key + * @param value String to check whether map value is not equal to it + */ + List

findByStringMapNotContaining(String key, String value); + /** * Find all entities containing the given map element (key or value depending on the given criterion) *