AbstractDynamoDBQueryCriteria.java
/*
* Copyright © 2018 spring-data-dynamodb (https://github.com/prasanna0586/spring-data-dynamodb)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.socialsignin.spring.data.dynamodb.repository.query;
import org.socialsignin.spring.data.dynamodb.core.DynamoDBOperations;
import org.socialsignin.spring.data.dynamodb.core.MarshallingMode;
import org.socialsignin.spring.data.dynamodb.mapping.DynamoDBMappingContext;
import org.socialsignin.spring.data.dynamodb.marshaller.Date2IsoDynamoDBMarshaller;
import org.socialsignin.spring.data.dynamodb.marshaller.Instant2IsoDynamoDBMarshaller;
import org.socialsignin.spring.data.dynamodb.query.Query;
import org.socialsignin.spring.data.dynamodb.repository.ExpressionAttribute;
import org.socialsignin.spring.data.dynamodb.repository.QueryConstants;
import org.socialsignin.spring.data.dynamodb.repository.support.DynamoDBEntityInformation;
import org.socialsignin.spring.data.dynamodb.repository.support.DynamoDBHashAndRangeKeyExtractingEntityMetadata;
import org.socialsignin.spring.data.dynamodb.utils.SortHandler;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.data.domain.Sort.Order;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.util.*;
import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.time.Instant;
import java.util.*;
import java.util.Map.Entry;
/**
* Abstract base class for DynamoDB query criteria implementations.
* Provides common functionality for building and executing DynamoDB queries and scans.
* @param <T> the entity type
* @param <ID> the ID type
* @author Prasanna Kumar Ramachandran
*/
public abstract class AbstractDynamoDBQueryCriteria<T, ID> implements DynamoDBQueryCriteria<T, ID>, SortHandler {
/**
* The entity class type for this query criteria.
*/
protected final Class<T> clazz;
@NonNull
private final DynamoDBEntityInformation<T, ID> entityInformation;
@NonNull
private final Map<String, String> attributeNamesByPropertyName;
@Nullable
private final String hashKeyPropertyName;
/**
* The DynamoDB mapping context for type conversions and schema information.
*/
protected final DynamoDBMappingContext mappingContext;
/**
* Multi-value map of attribute conditions, keyed by attribute name.
* Supports multiple conditions on the same attribute.
*/
protected final MultiValueMap<String, Condition> attributeConditions;
/**
* Multi-value map of property conditions, keyed by property name.
* Supports multiple conditions on the same property.
*/
protected final MultiValueMap<String, Condition> propertyConditions;
/**
* The DynamoDB attribute value for the hash key.
* This is the marshalled representation ready for DynamoDB.
*/
protected Object hashKeyAttributeValue;
/**
* The original property value for the hash key (before any type conversions).
*/
protected Object hashKeyPropertyValue;
/**
* The name of the global secondary index (GSI or LSI) to query, or null for main table queries.
*/
@Nullable
protected String globalSecondaryIndexName;
/**
* The sort specification for the query results.
* Defaults to unsorted if not specified.
*/
protected Sort sort = Sort.unsorted();
/**
* The projection expression specifying which attributes to return.
* Null means all attributes are returned.
*/
@Nullable
protected String projection = null;
/**
* The maximum number of items to return from the query.
* Null means no limit is applied.
*/
@Nullable
protected Integer limit = null;
/**
* The filter expression for further filtering query results after key conditions.
* Null means no filter is applied.
*/
@Nullable
protected String filterExpression = null;
/**
* Array of expression attribute names for use in filter expressions.
* Maps placeholder names to actual attribute names.
*/
protected ExpressionAttribute[] expressionAttributeNames;
/**
* Array of expression attribute values for use in filter expressions.
* Maps placeholder values to actual attribute values.
*/
protected ExpressionAttribute[] expressionAttributeValues;
/**
* Map of expression value parameters to their string representations.
* Used for parameter substitution in filter expressions.
*/
protected Map<String, String> mappedExpressionValues;
/**
* The consistent read mode for the query (CONSISTENT, EVENTUAL, or DEFAULT).
* Controls whether strongly consistent reads are used.
*/
protected QueryConstants.ConsistentReadMode consistentReads = QueryConstants.ConsistentReadMode.DEFAULT;
/**
* Determines if these criteria is applicable for a single entity load operation.
* @return true if these criteria represents a single entity load query, false otherwise
*/
public abstract boolean isApplicableForLoad();
/**
* Builds a DynamoDB QueryRequest from the specified criteria.
* Constructs key condition expressions for hash and range keys, handles projections,
* sorting, filtering, and expression attributes.
*
* @param tableName the DynamoDB table name
* @param theIndexName the index name to query (null for main table)
* @param hashKeyAttributeName the hash key attribute name
* @param rangeKeyAttributeName the range key attribute name (null if not present)
* @param rangeKeyPropertyName the range key property name (null if not present)
* @param hashKeyConditions conditions on the hash key
* @param rangeKeyConditions conditions on the range key
* @return a configured QueryRequest ready for DynamoDB
*/
protected QueryRequest buildQueryRequest(String tableName, String theIndexName, String hashKeyAttributeName,
@Nullable String rangeKeyAttributeName, @Nullable String rangeKeyPropertyName, @Nullable List<Condition> hashKeyConditions,
@Nullable List<Condition> rangeKeyConditions) {
// SDK v2: Build QueryRequest using modern keyConditionExpression
QueryRequest queryRequest = QueryRequest.builder()
.build();
queryRequest = queryRequest.toBuilder().tableName(tableName).build();
queryRequest = queryRequest.toBuilder().indexName(theIndexName).build();
// Build KeyConditionExpression for ALL query types (main table, GSI, and LSI)
// This is required by DynamoDB SDK v2 for all Query operations
List<String> keyConditionParts = new ArrayList<>();
Map<String, AttributeValue> keyExpressionValues = new HashMap<>();
Map<String, String> keyExpressionNames = new HashMap<>();
int nameCounter = 0;
int valueCounter = 0;
// Build hash key condition (always present for Query operations)
if (hashKeyConditions != null && !hashKeyConditions.isEmpty()) {
for (Condition hashKeyCondition : hashKeyConditions) {
String namePlaceholder = "#pk" + nameCounter++;
String conditionExpr = buildKeyConditionPart(namePlaceholder, hashKeyCondition, valueCounter, keyExpressionValues);
keyConditionParts.add(conditionExpr);
keyExpressionNames.put(namePlaceholder, hashKeyAttributeName);
valueCounter = keyExpressionValues.size();
}
}
// Build range key condition (if present)
if (rangeKeyConditions != null && !rangeKeyConditions.isEmpty()) {
for (Condition rangeKeyCondition : rangeKeyConditions) {
String namePlaceholder = "#sk" + nameCounter++;
String conditionExpr = buildKeyConditionPart(namePlaceholder, rangeKeyCondition, valueCounter, keyExpressionValues);
keyConditionParts.add(conditionExpr);
keyExpressionNames.put(namePlaceholder, rangeKeyAttributeName);
valueCounter = keyExpressionValues.size();
}
}
// For GSI queries, add ALL attribute conditions as key conditions
// For main table queries, ONLY add attribute conditions that match the range key
if (isApplicableForGlobalSecondaryIndex()) {
// GSI: Add all attribute conditions as key conditions
for (Entry<String, List<Condition>> singleAttributeConditions : attributeConditions.entrySet()) {
for (Condition condition : singleAttributeConditions.getValue()) {
String namePlaceholder = "#k" + nameCounter++;
String conditionExpr = buildKeyConditionPart(namePlaceholder, condition, valueCounter, keyExpressionValues);
keyConditionParts.add(conditionExpr);
keyExpressionNames.put(namePlaceholder, singleAttributeConditions.getKey());
valueCounter = keyExpressionValues.size();
}
}
} else if (rangeKeyAttributeName != null) {
// Main table: Only add attribute conditions for the range key (GT, LT, BETWEEN, etc.)
if (attributeConditions.containsKey(rangeKeyAttributeName)) {
for (Condition condition : attributeConditions.get(rangeKeyAttributeName)) {
String namePlaceholder = "#sk" + nameCounter++;
String conditionExpr = buildKeyConditionPart(namePlaceholder, condition, valueCounter, keyExpressionValues);
keyConditionParts.add(conditionExpr);
keyExpressionNames.put(namePlaceholder, rangeKeyAttributeName);
valueCounter = keyExpressionValues.size();
}
}
}
// Set keyConditionExpression (required for all Query operations in SDK v2)
if (!keyConditionParts.isEmpty()) {
String keyConditionExpression = String.join(" AND ", keyConditionParts);
queryRequest = queryRequest.toBuilder()
.keyConditionExpression(keyConditionExpression)
.build();
if (!keyExpressionNames.isEmpty()) {
queryRequest = queryRequest.toBuilder().expressionAttributeNames(keyExpressionNames).build();
}
if (!keyExpressionValues.isEmpty()) {
queryRequest = queryRequest.toBuilder().expressionAttributeValues(keyExpressionValues).build();
}
}
// Handle projection (select specific attributes)
if (projection != null) {
queryRequest = queryRequest.toBuilder().select(Select.SPECIFIC_ATTRIBUTES).build();
queryRequest = queryRequest.toBuilder().projectionExpression(projection).build();
} else if (isApplicableForGlobalSecondaryIndex()) {
// For GSI queries without explicit projection, use ALL_PROJECTED_ATTRIBUTES
queryRequest = queryRequest.toBuilder().select(Select.ALL_PROJECTED_ATTRIBUTES).build();
}
// Determine allowed sort properties based on query type
// Check if this is an LSI query
// : hash key is table partition key + index name is set
boolean isLSIQuery = false;
if (isApplicableForGlobalSecondaryIndex() && getHashKeyAttributeValue() != null) {
String queryHashKeyPropertyName = getHashKeyPropertyName();
boolean isTablePartitionKey = queryHashKeyPropertyName != null && queryHashKeyPropertyName.equals(entityInformation.getHashKeyPropertyName());
boolean isGSIPartitionKey = entityInformation.isGlobalIndexHashKeyProperty(queryHashKeyPropertyName);
// LSI: hash key is table partition, not a GSI partition key
isLSIQuery = isTablePartitionKey && !isGSIPartitionKey;
}
List<String> allowedSortProperties = new ArrayList<>();
if (isLSIQuery) {
// LSI queries: ONLY allow sorting by the specific LSI range key being queried
// Detect which LSI property has a condition
String lsiPropertyWithCondition = null;
if (entityInformation instanceof DynamoDBHashAndRangeKeyExtractingEntityMetadata<?, ?> compositeKeyEntityInfo) {
Set<String> indexRangeKeyPropertyNames = compositeKeyEntityInfo.getIndexRangeKeyPropertyNames();
if (indexRangeKeyPropertyNames != null) {
for (String indexRangeKeyPropertyName : indexRangeKeyPropertyNames) {
boolean hasCondition = propertyConditions.containsKey(indexRangeKeyPropertyName) ||
attributeConditions.containsKey(getAttributeName(indexRangeKeyPropertyName));
if (hasCondition) {
if (lsiPropertyWithCondition != null) {
throw new UnsupportedOperationException(
"Cannot query multiple LSI range keys in a single query. Found conditions on: " +
lsiPropertyWithCondition + " and " + indexRangeKeyPropertyName);
}
lsiPropertyWithCondition = indexRangeKeyPropertyName;
}
}
}
}
if (lsiPropertyWithCondition != null) {
allowedSortProperties.add(lsiPropertyWithCondition);
} else {
// Fallback: if we couldn't detect LSI property, use rangeKeyPropertyName
if (rangeKeyPropertyName != null) {
allowedSortProperties.add(rangeKeyPropertyName);
}
}
} else if (isApplicableForGlobalSecondaryIndex()) {
// GSI queries: ONLY allow sorting by the specific GSI's range key being queried
// Collect all GSI properties that have conditions
List<String> gsiPropertiesWithConditions = new ArrayList<>();
Map<String, String[]> gsiNamesByProperty = entityInformation.getGlobalSecondaryIndexNamesByPropertyName();
for (Entry<String, List<Condition>> singlePropertyCondition : propertyConditions.entrySet()) {
String propertyName = singlePropertyCondition.getKey();
if (gsiNamesByProperty.containsKey(propertyName)) {
gsiPropertiesWithConditions.add(propertyName);
}
}
// Find common GSI index that contains ALL properties with conditions
String commonGsiIndexName = null;
if (!gsiPropertiesWithConditions.isEmpty()) {
// Start with the GSI indexes of the first property
String firstProperty = gsiPropertiesWithConditions.getFirst();
String[] firstPropertyIndexes = gsiNamesByProperty.get(firstProperty);
if (firstPropertyIndexes != null) {
// For each GSI index of the first property, check if ALL other properties also belong to it
for (String candidateIndexName : firstPropertyIndexes) {
boolean allPropertiesInThisIndex = true;
for (String property : gsiPropertiesWithConditions) {
String[] propertyIndexes = gsiNamesByProperty.get(property);
boolean propertyInCandidateIndex = false;
if (propertyIndexes != null) {
for (String indexName : propertyIndexes) {
if (indexName.equals(candidateIndexName)) {
propertyInCandidateIndex = true;
break;
}
}
}
if (!propertyInCandidateIndex) {
allPropertiesInThisIndex = false;
break;
}
}
if (allPropertiesInThisIndex) {
commonGsiIndexName = candidateIndexName;
break; // Found a common GSI, use it
}
}
}
// If no common GSI found, these properties belong to different GSIs
if (commonGsiIndexName == null) {
throw new UnsupportedOperationException(
"Cannot query properties from different GSI indexes in a single query. Found conditions on: " +
String.join(", ", gsiPropertiesWithConditions));
}
// Add all properties with conditions to allowed sort properties
allowedSortProperties.addAll(gsiPropertiesWithConditions);
// Add all range keys from the common GSI to allowed sort properties
for (Entry<String, String[]> entry : gsiNamesByProperty.entrySet()) {
String propertyName = entry.getKey();
String[] indexNames = entry.getValue();
if (indexNames != null) {
for (String indexName : indexNames) {
if (indexName.equals(commonGsiIndexName)) {
// Check if this is a range key property for this GSI
if (entityInformation.isGlobalIndexRangeKeyProperty(propertyName)) {
allowedSortProperties.add(propertyName);
}
break;
}
}
}
}
}
} else {
// Main table queries (no secondary index)
// Allow sorting by main table range key
if (rangeKeyPropertyName != null) {
allowedSortProperties.add(rangeKeyPropertyName);
}
// Also allow sorting by any LSI range key for hash-only + OrderBy pattern
// (e.g., findByCustomerIdOrderByOrderDateAsc)
if (entityInformation instanceof DynamoDBHashAndRangeKeyExtractingEntityMetadata<?, ?> compositeKeyEntityInfo) {
Set<String> indexRangeKeyPropertyNames = compositeKeyEntityInfo.getIndexRangeKeyPropertyNames();
if (indexRangeKeyPropertyNames != null && sort != null) {
Iterator<Order> sortIterator = sort.iterator();
if (sortIterator.hasNext()) {
String sortProperty = sortIterator.next().getProperty();
// Only add LSI range key if it's the sort property (hash-only + OrderBy pattern)
if (indexRangeKeyPropertyNames.contains(sortProperty)) {
allowedSortProperties.add(sortProperty);
}
}
}
}
}
List<String> dedupedAllowedProps = new ArrayList<>(new HashSet<>(allowedSortProperties));
queryRequest = applySortIfSpecified(queryRequest, dedupedAllowedProps);
queryRequest = applyConsistentReads(queryRequest);
// SDK v2: Use builder pattern for setting limit
if (limit != null) {
queryRequest = queryRequest.toBuilder().limit(limit).build();
}
if (filterExpression != null) {
String filter = filterExpression;
if (StringUtils.hasLength(filter)) {
queryRequest = queryRequest.toBuilder().filterExpression(filter).build();
// SDK v2: Build expression attribute names map and merge with existing key condition names
if (expressionAttributeNames != null && expressionAttributeNames.length > 0) {
Map<String, String> attributeNamesMap = new HashMap<>(queryRequest.expressionAttributeNames() != null
? queryRequest.expressionAttributeNames() : new HashMap<>());
for (ExpressionAttribute attribute : expressionAttributeNames) {
if (StringUtils.hasLength(attribute.key())) {
attributeNamesMap.put(attribute.key(), attribute.value());
}
}
if (!attributeNamesMap.isEmpty()) {
queryRequest = queryRequest.toBuilder().expressionAttributeNames(attributeNamesMap).build();
}
}
// SDK v2: Build expression attribute values map and merge with existing key condition values
if (expressionAttributeValues != null && expressionAttributeValues.length > 0) {
Map<String, AttributeValue> attributeValuesMap = new HashMap<>(queryRequest.expressionAttributeValues() != null
? queryRequest.expressionAttributeValues() : new HashMap<>());
for (ExpressionAttribute value : expressionAttributeValues) {
if (StringUtils.hasLength(value.key())) {
String stringValue;
if (mappedExpressionValues.containsKey(value.parameterName())) {
stringValue = mappedExpressionValues.get(value.parameterName());
} else {
stringValue = value.value();
}
// SDK v2: Use builder pattern for AttributeValue
attributeValuesMap.put(value.key(), AttributeValue.builder().s(stringValue).build());
}
}
if (!attributeValuesMap.isEmpty()) {
queryRequest = queryRequest.toBuilder().expressionAttributeValues(attributeValuesMap).build();
}
}
}
}
return queryRequest;
}
/**
* Applies the consistent read mode to the QueryRequest.
* @param queryRequest the QueryRequest to configure
* @return the QueryRequest with consistent read setting applied
*/
protected QueryRequest applyConsistentReads(@NonNull QueryRequest queryRequest) {
return switch (consistentReads) {
case CONSISTENT -> queryRequest.toBuilder().consistentRead(true).build();
case EVENTUAL -> queryRequest.toBuilder().consistentRead(false).build();
default -> queryRequest;
};
}
/**
* Applies sort order to the QueryRequest if sorting is specified and valid.
* Validates that sort properties are permitted for the query type.
* @param queryRequest the QueryRequest to configure
* @param permittedPropertyNames the properties that are allowed to be sorted
* @return the QueryRequest with sort order applied
* @throws UnsupportedOperationException if sorting by invalid properties or multiple attributes
*/
protected QueryRequest applySortIfSpecified(@NonNull QueryRequest queryRequest, @NonNull List<String> permittedPropertyNames) {
if (permittedPropertyNames.size() > 2) {
throw new UnsupportedOperationException("Can only sort by at most a single global hash and range key");
}
boolean sortAlreadySet = false;
for (Order order : sort) {
if (permittedPropertyNames.contains(order.getProperty())) {
if (sortAlreadySet) {
throw new UnsupportedOperationException("Sorting by multiple attributes not possible");
}
// SDK v2: Check keyConditionExpression instead of deprecated keyConditions
// For GSI queries: sorting with both hash and range conditions requires hash key equality
// For main table queries: sorting with hash and range conditions is always allowed
boolean hasMultipleKeyConditions = queryRequest.keyConditionExpression() != null
&& queryRequest.keyConditionExpression().contains(" AND ");
if (hasMultipleKeyConditions && isApplicableForGlobalSecondaryIndex() && !hasIndexHashKeyEqualCondition()) {
throw new UnsupportedOperationException(
"Sorting for global index queries with criteria on both hash and range not possible");
}
boolean scanForward = order.getDirection().equals(Direction.ASC);
queryRequest = queryRequest.toBuilder().scanIndexForward(scanForward).build();
sortAlreadySet = true;
} else {
throw new UnsupportedOperationException("Sorting only possible by " + permittedPropertyNames
+ " for the criteria specified and not for " + order.getProperty());
}
}
return queryRequest;
}
/**
* Builds a key condition expression part from a Condition object.
* Supports EQ, LT, LE, GT, GE, BETWEEN, and BEGINS_WITH operators (valid for key conditions).
* @param namePlaceholder The expression attribute name placeholder (e.g., "#pk", "#sk")
* @param condition The condition to convert
* @param startValueCounter Starting counter for value placeholders
* @param expressionValues Map to populate with expression attribute values
* @return The key condition expression string
*/
@NonNull
private String buildKeyConditionPart(String namePlaceholder, @NonNull Condition condition, int startValueCounter,
@NonNull Map<String, AttributeValue> expressionValues) {
ComparisonOperator operator = condition.comparisonOperator();
List<AttributeValue> attributeValueList = condition.attributeValueList();
// Validate attributeValueList based on operator requirements
if (attributeValueList == null || attributeValueList.isEmpty()) {
if (operator != ComparisonOperator.NULL && operator != ComparisonOperator.NOT_NULL) {
throw new IllegalArgumentException("Attribute value list cannot be null or empty for operator: " + operator);
}
}
switch (operator) {
case EQ:
String eqPlaceholder = ":kval" + startValueCounter;
expressionValues.put(eqPlaceholder, attributeValueList.getFirst());
return namePlaceholder + " = " + eqPlaceholder;
case LT:
String ltPlaceholder = ":kval" + startValueCounter;
expressionValues.put(ltPlaceholder, attributeValueList.getFirst());
return namePlaceholder + " < " + ltPlaceholder;
case LE:
String lePlaceholder = ":kval" + startValueCounter;
expressionValues.put(lePlaceholder, attributeValueList.getFirst());
return namePlaceholder + " <= " + lePlaceholder;
case GT:
String gtPlaceholder = ":kval" + startValueCounter;
expressionValues.put(gtPlaceholder, attributeValueList.getFirst());
return namePlaceholder + " > " + gtPlaceholder;
case GE:
String gePlaceholder = ":kval" + startValueCounter;
expressionValues.put(gePlaceholder, attributeValueList.getFirst());
return namePlaceholder + " >= " + gePlaceholder;
case BETWEEN:
if (attributeValueList.size() != 2) {
throw new IllegalArgumentException("BETWEEN operator requires exactly 2 values, got: " + attributeValueList.size());
}
String betweenPlaceholder1 = ":kval" + startValueCounter;
String betweenPlaceholder2 = ":kval" + (startValueCounter + 1);
expressionValues.put(betweenPlaceholder1, attributeValueList.get(0));
expressionValues.put(betweenPlaceholder2, attributeValueList.get(1));
return namePlaceholder + " BETWEEN " + betweenPlaceholder1 + " AND " + betweenPlaceholder2;
case BEGINS_WITH:
String beginsPlaceholder = ":kval" + startValueCounter;
expressionValues.put(beginsPlaceholder, attributeValueList.getFirst());
return "begins_with(" + namePlaceholder + ", " + beginsPlaceholder + ")";
default:
throw new UnsupportedOperationException(
"Comparison operator " + operator + " not supported for key conditions. " +
"Only EQ, LT, LE, GT, GE, BETWEEN, and BEGINS_WITH are allowed.");
}
}
/**
* Checks if all current attribute conditions use comparison operators that are valid for queries.
* Valid operators: EQ, LE, LT, GE, GT, BEGINS_WITH, BETWEEN.
* @return true if all conditions use valid query operators, false otherwise
*/
public boolean comparisonOperatorsPermittedForQuery() {
List<ComparisonOperator> comparisonOperatorsPermittedForQuery = Arrays.asList(ComparisonOperator.EQ,
ComparisonOperator.LE, ComparisonOperator.LT, ComparisonOperator.GE, ComparisonOperator.GT,
ComparisonOperator.BEGINS_WITH, ComparisonOperator.BETWEEN);
// Can only query on subset of Conditions
for (Collection<Condition> conditions : attributeConditions.values()) {
for (Condition condition : conditions) {
if (!comparisonOperatorsPermittedForQuery
.contains(condition.comparisonOperator())) {
return false;
}
}
}
return true;
}
/**
* Gets the conditions on the hash key from the current criteria.
* Handles both main table queries and index queries (LSI/GSI).
* @return a list of hash key conditions, or null if no hash key conditions exist
*/
@Nullable
protected List<Condition> getHashKeyConditions() {
List<Condition> hashKeyConditions = null;
// For LSI: hash key is the table's partition key (not in globalSecondaryIndexNames map), only when using an index
// For GSI: hash key is a GSI partition key (in globalSecondaryIndexNames map)
boolean isLSIHashKey = isApplicableForGlobalSecondaryIndex()
&& getGlobalSecondaryIndexName() != null
&& getHashKeyPropertyName() != null
&& getHashKeyPropertyName().equals(entityInformation.getHashKeyPropertyName());
boolean isGSIHashKey = isApplicableForGlobalSecondaryIndex()
&& entityInformation.getGlobalSecondaryIndexNamesByPropertyName().containsKey(getHashKeyPropertyName());
// SDK v2: Also build hash key conditions for main table queries (not just LSI/GSI)
boolean isMainTableHashKey = !isApplicableForGlobalSecondaryIndex();
if (isLSIHashKey || isGSIHashKey || isMainTableHashKey) {
if (getHashKeyAttributeValue() != null) {
hashKeyConditions = Collections
.singletonList(createSingleValueCondition(getHashKeyPropertyName(), ComparisonOperator.EQ,
getHashKeyAttributeValue(), getHashKeyAttributeValue().getClass(), true));
}
if (hashKeyConditions == null && attributeConditions.containsKey(getHashKeyAttributeName())) {
hashKeyConditions = attributeConditions.get(getHashKeyAttributeName());
}
}
return hashKeyConditions;
}
/**
* Constructor for AbstractDynamoDBQueryCriteria.
* @param dynamoDBEntityInformation metadata about the DynamoDB entity
* @param mappingContext the DynamoDB mapping context for type conversions
*/
public AbstractDynamoDBQueryCriteria(@NonNull DynamoDBEntityInformation<T, ID> dynamoDBEntityInformation,
DynamoDBMappingContext mappingContext) {
this.clazz = dynamoDBEntityInformation.getJavaType();
this.attributeConditions = new LinkedMultiValueMap<>();
this.propertyConditions = new LinkedMultiValueMap<>();
this.hashKeyPropertyName = dynamoDBEntityInformation.getHashKeyPropertyName();
this.entityInformation = dynamoDBEntityInformation;
this.attributeNamesByPropertyName = new HashMap<>();
// TODO consider adding the table schema to
// DynamoDBEntityInformation instead
this.mappingContext = mappingContext;
}
/**
* Gets the first declared index name for the specified attribute from the given candidate indexes.
* @param indexNamesByAttributeName map of attribute names to their declared index names
* @param indexNamesToCheck the list of candidate index names to search within
* @param attributeName the attribute name to find an index for
* @return the first matching index name, or null if no match is found
*/
@Nullable
private String getFirstDeclaredIndexNameForAttribute(@NonNull Map<String, String[]> indexNamesByAttributeName,
@NonNull List<String> indexNamesToCheck, String attributeName) {
String indexName = null;
String[] declaredOrderedIndexNamesForAttribute = indexNamesByAttributeName.get(attributeName);
for (String declaredOrderedIndexNameForAttribute : declaredOrderedIndexNamesForAttribute) {
if (indexName == null && indexNamesToCheck.contains(declaredOrderedIndexNameForAttribute)) {
indexName = declaredOrderedIndexNameForAttribute;
}
}
return indexName;
}
/**
* Gets the global secondary index name that should be used for the query.
* Performs index selection based on attribute conditions and sort requirements.
* Prioritizes exact matches and sort-compatible indexes.
*
* @return the global secondary index name, or null if the main table should be queried
* @throws RuntimeException if multiple indexes are defined on the same attribute set
* @throws UnsupportedOperationException if conditions span multiple different GSI indexes
*/
@Nullable
protected String getGlobalSecondaryIndexName() {
// Lazy evaluate the globalSecondaryIndexName if not already set
// Check if we have a sort requirement
boolean hasSortRequirement = false;
if (sort != null) {
Iterator<Order> sortIterator = sort.iterator();
hasSortRequirement = sortIterator.hasNext();
}
// Check if hash key is a GSI partition key
boolean shouldSelectIndex = isShouldSelectIndex(hasSortRequirement);
if (globalSecondaryIndexName == null && shouldSelectIndex) {
// Declare map of index names by attribute name which we will populate below -
// this will be used to determine which index to use if multiple indexes are
// applicable
Map<String, String[]> indexNamesByAttributeName = new HashMap<>();
// <p>
// Declare map of attribute lists by index name which we will populate below -
// this will be used to determine whether we have an exact match index for
// specified attribute conditions
MultiValueMap<String, String> attributeListsByIndexName = new LinkedMultiValueMap<>();
// Populate the above maps
for (Entry<String, String[]> indexNamesForPropertyNameEntry : entityInformation
.getGlobalSecondaryIndexNamesByPropertyName().entrySet()) {
String propertyName = indexNamesForPropertyNameEntry.getKey();
String attributeName = getAttributeName(propertyName);
indexNamesByAttributeName.put(attributeName, indexNamesForPropertyNameEntry.getValue());
for (String indexNameForPropertyName : indexNamesForPropertyNameEntry.getValue()) {
attributeListsByIndexName.add(indexNameForPropertyName, attributeName);
}
}
// Declare lists to store matching index names
List<String> exactMatchIndexNames = new ArrayList<>();
List<String> partialMatchIndexNames = new ArrayList<>();
// Populate matching index name lists - an index is either an exact match ( the
// index attributes match all the specified criteria exactly)
// or a partial match ( the properties for the specified criteria are contained
// within the property set for an index )
for (Entry<String, List<String>> attributeListForIndexNameEntry : attributeListsByIndexName.entrySet()) {
String indexNameForAttributeList = attributeListForIndexNameEntry.getKey();
List<String> attributeList = attributeListForIndexNameEntry.getValue();
// Convert list to set for O(1) containment checks instead of O(n)
Set<String> attributeSet = new HashSet<>(attributeList);
if (attributeSet.containsAll(attributeConditions.keySet())) {
if (attributeConditions.keySet().containsAll(attributeSet)) {
exactMatchIndexNames.add(indexNameForAttributeList);
} else {
partialMatchIndexNames.add(indexNameForAttributeList);
}
}
}
// Check if we have a sort requirement
String sortPropertyName = null;
if (sort != null) {
Iterator<Order> sortIterator = sort.iterator();
if (sortIterator.hasNext()) {
sortPropertyName = sortIterator.next().getProperty();
}
}
// If we have a sort requirement, filter candidates to prefer indexes that support it
if (sortPropertyName != null) {
final String finalSortPropertyName = sortPropertyName;
// Filter exact matches to prefer those that have the sort property as a range key
List<String> sortCompatibleExactMatches = exactMatchIndexNames.stream()
.filter(indexName -> {
List<String> indexAttributes = attributeListsByIndexName.get(indexName);
return indexAttributes != null &&
indexAttributes.contains(getAttributeName(finalSortPropertyName)) &&
entityInformation.isGlobalIndexRangeKeyProperty(finalSortPropertyName);
})
.collect(java.util.stream.Collectors.toList());
// Filter partial matches similarly
List<String> sortCompatiblePartialMatches = partialMatchIndexNames.stream()
.filter(indexName -> {
List<String> indexAttributes = attributeListsByIndexName.get(indexName);
return indexAttributes != null &&
indexAttributes.contains(getAttributeName(finalSortPropertyName)) &&
entityInformation.isGlobalIndexRangeKeyProperty(finalSortPropertyName);
})
.collect(java.util.stream.Collectors.toList());
// Prefer sort-compatible indexes if available
if (!sortCompatibleExactMatches.isEmpty()) {
// We have sort-compatible exact matches - use them
exactMatchIndexNames = sortCompatibleExactMatches;
partialMatchIndexNames.clear(); // Clear partial matches since we have exact matches
} else if (!sortCompatiblePartialMatches.isEmpty()) {
// We have NO sort-compatible exact matches, but we DO have sort-compatible partial matches
// In this case, the sort-compatible partial match is BETTER than a non-sort-compatible exact match
// Example: merchantId-transactionDate-index (partial, sort-compatible) is better than
// merchantId-index (exact, NOT sort-compatible) for findByMerchantIdOrderByTransactionDateAsc
exactMatchIndexNames.clear(); // Clear non-sort-compatible exact matches
exactMatchIndexNames = sortCompatiblePartialMatches; // Promote sort-compatible partial matches
partialMatchIndexNames.clear();
}
}
if (exactMatchIndexNames.size() > 1) {
throw new RuntimeException(
"Multiple indexes defined on same attribute set:" + attributeConditions.keySet());
} else if (exactMatchIndexNames.size() == 1) {
globalSecondaryIndexName = exactMatchIndexNames.getFirst();
} else if (partialMatchIndexNames.size() > 1) {
if (attributeConditions.size() == 1) {
globalSecondaryIndexName = getFirstDeclaredIndexNameForAttribute(indexNamesByAttributeName,
partialMatchIndexNames, attributeConditions.keySet().iterator().next());
}
if (globalSecondaryIndexName == null) {
globalSecondaryIndexName = partialMatchIndexNames.getFirst();
}
} else if (partialMatchIndexNames.size() == 1) {
globalSecondaryIndexName = partialMatchIndexNames.getFirst();
}
}
return globalSecondaryIndexName;
}
/**
* Determines whether index selection should be performed based on query conditions and sort requirements.
* @param hasSortRequirement whether the query has a sort requirement
* @return true if index selection should be performed, false to use main table
*/
private boolean isShouldSelectIndex(boolean hasSortRequirement) {
boolean hashKeyIsGSIPartitionKey = entityInformation.getGlobalSecondaryIndexNamesByPropertyName()
.containsKey(getHashKeyPropertyName());
// Run index selection if:
// 1. We have attribute conditions (traditional GSI query), OR
// 2. Hash key is a GSI partition key AND we have a sort requirement (hash-only GSI query with OrderBy)
return (attributeConditions != null && !attributeConditions.isEmpty()) ||
(hashKeyIsGSIPartitionKey && hasSortRequirement);
}
/**
* Checks if the specified property name is the hash key property.
* @param propertyName the property name to check
* @return true if the property is the hash key, false otherwise
*/
protected boolean isHashKeyProperty(String propertyName) {
return hashKeyPropertyName != null && hashKeyPropertyName.equals(propertyName);
}
/**
* Gets the hash key property name.
* @return the hash key property name, or null if not set
*/
@Nullable
protected String getHashKeyPropertyName() {
return hashKeyPropertyName;
}
/**
* Gets the DynamoDB attribute name for the hash key property.
* @return the hash key attribute name
*/
protected String getHashKeyAttributeName() {
return getAttributeName(getHashKeyPropertyName());
}
/**
* Checks if there is an equality condition on the index hash key.
* Handles both GSI and LSI hash keys.
* @return true if an equality condition exists on the index hash key, false otherwise
*/
protected boolean hasIndexHashKeyEqualCondition() {
boolean hasIndexHashKeyEqualCondition = false;
for (Map.Entry<String, List<Condition>> propertyConditionList : propertyConditions.entrySet()) {
// Only consider table partition key if we're actually using an index (LSI case)
boolean isGSIHashKey = entityInformation.isGlobalIndexHashKeyProperty(propertyConditionList.getKey());
boolean isLSIHashKey = getGlobalSecondaryIndexName() != null
&& propertyConditionList.getKey().equals(entityInformation.getHashKeyPropertyName());
if (isGSIHashKey || isLSIHashKey) {
for (Condition condition : propertyConditionList.getValue()) {
if (condition.comparisonOperator().equals(ComparisonOperator.EQ)) {
hasIndexHashKeyEqualCondition = true;
}
}
}
}
if (hashKeyAttributeValue != null) {
// For LSI: hash key is the table's partition key (only when an index is being used)
// For GSI: hash key is a GSI partition key
boolean isTablePartitionKey = getGlobalSecondaryIndexName() != null
&& hashKeyPropertyName != null
&& hashKeyPropertyName.equals(entityInformation.getHashKeyPropertyName());
boolean isGSIPartitionKey = entityInformation.isGlobalIndexHashKeyProperty(hashKeyPropertyName);
if (isTablePartitionKey || isGSIPartitionKey) {
hasIndexHashKeyEqualCondition = true;
}
}
return hasIndexHashKeyEqualCondition;
}
/**
* Checks if there is a condition on an index range key.
* Handles both GSI and LSI range keys.
* @return true if a condition exists on an index range key, false otherwise
*/
protected boolean hasIndexRangeKeyCondition() {
boolean hasIndexRangeKeyCondition = false;
for (Map.Entry<String, List<Condition>> propertyConditionList : propertyConditions.entrySet()) {
if (entityInformation.isGlobalIndexRangeKeyProperty(propertyConditionList.getKey())) {
hasIndexRangeKeyCondition = true;
}
}
if (hashKeyAttributeValue != null && entityInformation.isGlobalIndexRangeKeyProperty(hashKeyPropertyName)) {
hasIndexRangeKeyCondition = true;
}
return hasIndexRangeKeyCondition;
}
/**
* Determines if these criteria is applicable for querying a global secondary index (GSI or LSI).
* Validates that the selected index has appropriate conditions and attributes.
* @return true if the criteria can be executed against a secondary index, false if main table should be used
*/
protected boolean isApplicableForGlobalSecondaryIndex() {
boolean global = this.getGlobalSecondaryIndexName() != null;
if (global && getHashKeyAttributeValue() != null) {
// Check if the hash key used in the query is valid for this index
// Valid cases:
// 1. LSI: hash key is the table's partition key (not in globalSecondaryIndexNames map)
// 2. GSI: hash key is a GSI partition key (in globalSecondaryIndexNames map)
String queryHashKeyPropertyName = getHashKeyPropertyName();
boolean isTablePartitionKey = queryHashKeyPropertyName != null && queryHashKeyPropertyName.equals(entityInformation.getHashKeyPropertyName());
boolean isGSIPartitionKey = entityInformation.getGlobalSecondaryIndexNamesByPropertyName().containsKey(queryHashKeyPropertyName);
// If the hash key is neither the table's partition key (LSI case) nor a GSI partition key, reject
if (!isTablePartitionKey && !isGSIPartitionKey) {
return false;
}
}
int attributeConditionCount = attributeConditions.size();
boolean attributeConditionsAppropriate = hasIndexHashKeyEqualCondition()
&& (attributeConditionCount == 1 || (attributeConditionCount == 2 && hasIndexRangeKeyCondition()));
return global && (attributeConditionCount == 0 || attributeConditionsAppropriate)
&& comparisonOperatorsPermittedForQuery();
}
/**
* Sets the hash key value for the query.
* @param value the hash key value to set
* @return this query criteria for method chaining
* @throws IllegalArgumentException if value is null
*/
@NonNull
public DynamoDBQueryCriteria<T, ID> withHashKeyEquals(Object value) {
Assert.notNull(value, "Creating conditions on null hash keys not supported: please specify a value for '"
+ getHashKeyPropertyName() + "'");
hashKeyAttributeValue = getPropertyAttributeValue(getHashKeyPropertyName(), value);
hashKeyPropertyValue = value;
return this;
}
/**
* Checks if a hash key value has been specified.
* @return true if a hash key value is set, false otherwise
*/
public boolean isHashKeySpecified() {
return getHashKeyAttributeValue() != null;
}
/**
* Gets the hash key attribute value.
* @return the hash key attribute value
*/
public Object getHashKeyAttributeValue() {
return hashKeyAttributeValue;
}
/**
* Gets the original hash key property value (before any type conversions).
* @return the hash key property value
*/
public Object getHashKeyPropertyValue() {
return hashKeyPropertyValue;
}
/**
* Gets the DynamoDB attribute name for the specified property name.
* Caches the result for performance.
* @param propertyName the property name to convert
* @return the DynamoDB attribute name
*/
protected String getAttributeName(String propertyName) {
String attributeName = attributeNamesByPropertyName.get(propertyName);
if (attributeName == null) {
attributeName = entityInformation.getOverriddenAttributeName(propertyName).orElse(propertyName);
attributeNamesByPropertyName.put(propertyName, attributeName);
}
return attributeName;
}
/**
* Adds a BETWEEN criteria on the specified property.
* Implementation of interface method.
* @param propertyName the property name to filter on
* @param value1 the lower bound value (inclusive)
* @param value2 the upper bound value (inclusive)
* @param type the type of the property
* @return this query criteria for method chaining
*/
@NonNull
@Override
public DynamoDBQueryCriteria<T, ID> withPropertyBetween(@NonNull String propertyName, Object value1, Object value2,
Class<?> type) {
Condition condition = createCollectionCondition(propertyName, ComparisonOperator.BETWEEN,
Arrays.asList(value1, value2), type);
return withCondition(propertyName, condition);
}
/**
* Adds an IN (membership) criteria on the specified property.
* Implementation of interface method.
* @param propertyName the property name to filter on
* @param value an iterable of values to match against
* @param propertyType the type of the property
* @return this query criteria for method chaining
*/
@NonNull
@Override
public DynamoDBQueryCriteria<T, ID> withPropertyIn(@NonNull String propertyName, @NonNull Iterable<?> value, Class<?> propertyType) {
Condition condition = createCollectionCondition(propertyName, ComparisonOperator.IN, value, propertyType);
return withCondition(propertyName, condition);
}
/**
* Adds a single-value criteria to the query using the specified comparison operator.
* Implementation of interface method.
* @param propertyName the property name to filter on
* @param comparisonOperator the comparison operator to apply
* @param value the value to compare against
* @param propertyType the type of the property
* @return this query criteria for method chaining
*/
@Override
public DynamoDBQueryCriteria<T, ID> withSingleValueCriteria(@NonNull String propertyName,
@NonNull ComparisonOperator comparisonOperator, Object value, Class<?> propertyType) {
if (comparisonOperator.equals(ComparisonOperator.EQ)) {
return withPropertyEquals(propertyName, value, propertyType);
} else {
Condition condition = createSingleValueCondition(propertyName, comparisonOperator, value, propertyType,
false);
return withCondition(propertyName, condition);
}
}
/**
* Builds a query object that can be executed to fetch results.
* Implementation of interface method.
* @param dynamoDBOperations the DynamoDB operations instance to use for execution
* @return a Query object configured with the criteria
*/
@Override
public Query<T> buildQuery(DynamoDBOperations dynamoDBOperations) {
if (isApplicableForLoad()) {
return buildSingleEntityLoadQuery(dynamoDBOperations);
} else {
return buildFinderQuery(dynamoDBOperations);
}
}
/**
* Builds a count query that returns the number of matching items.
* Implementation of interface method.
* @param dynamoDBOperations the DynamoDB operations instance to use for execution
* @param pageQuery whether this is for paginated query counting
* @return a Query object that returns the count of matching items
*/
@Override
public Query<Long> buildCountQuery(DynamoDBOperations dynamoDBOperations, boolean pageQuery) {
if (isApplicableForLoad()) {
return buildSingleEntityCountQuery(dynamoDBOperations);
} else {
return buildFinderCountQuery(dynamoDBOperations, pageQuery);
}
}
/**
* Builds a query for loading a single entity by its keys.
* Must be implemented by subclasses.
* @param dynamoDBOperations the DynamoDB operations instance
* @return a Query object for single entity load
*/
protected abstract Query<T> buildSingleEntityLoadQuery(DynamoDBOperations dynamoDBOperations);
/**
* Builds a count query for a single entity.
* Must be implemented by subclasses.
* @param dynamoDBOperations the DynamoDB operations instance
* @return a count Query object
*/
protected abstract Query<Long> buildSingleEntityCountQuery(DynamoDBOperations dynamoDBOperations);
/**
* Builds a finder query that returns zero or more entities matching the criteria.
* Must be implemented by subclasses.
* @param dynamoDBOperations the DynamoDB operations instance
* @return a Query object for finder operations
*/
protected abstract Query<T> buildFinderQuery(DynamoDBOperations dynamoDBOperations);
/**
* Builds a count query for finder operations.
* Must be implemented by subclasses.
* @param dynamoDBOperations the DynamoDB operations instance
* @param pageQuery whether this is for paginated query counting
* @return a count Query object
*/
protected abstract Query<Long> buildFinderCountQuery(DynamoDBOperations dynamoDBOperations, boolean pageQuery);
/**
* Checks if only the hash key is specified without any other conditions.
* Must be implemented by subclasses.
* @return true if only hash key is specified, false otherwise
*/
protected abstract boolean isOnlyHashKeySpecified();
/**
* Adds a criteria with no value (e.g., NULL or NOT_NULL checks).
* Implementation of interface method.
* @param propertyName the property name to filter on
* @param comparisonOperator the comparison operator for no-value conditions
* @return this query criteria for method chaining
*/
@NonNull
@Override
public DynamoDBQueryCriteria<T, ID> withNoValuedCriteria(@NonNull String propertyName,
ComparisonOperator comparisonOperator) {
Condition condition = createNoValueCondition(comparisonOperator);
return withCondition(propertyName, condition);
}
/**
* Adds a condition to the query criteria for the specified property.
* @param propertyName the property name to add the condition for
* @param condition the condition to add
* @return this query criteria for method chaining
*/
@NonNull
public DynamoDBQueryCriteria<T, ID> withCondition(@NonNull String propertyName, Condition condition) {
attributeConditions.add(getAttributeName(propertyName), condition);
propertyConditions.add(propertyName, condition);
return this;
}
/**
* Gets the property attribute value, applying any custom attribute converters if configured.
* @param <V> the type of the property value
* @param propertyName the property name
* @param value the property value
* @return the converted attribute value
*/
@SuppressWarnings("unchecked")
protected <V> Object getPropertyAttributeValue(final String propertyName, final V value) {
// SDK v2: Check for custom attribute converters configured via @DynamoDbConvertedBy
AttributeConverter<?> attributeConverter = entityInformation.getAttributeConverterForProperty(propertyName);
if (attributeConverter != null) {
// Cast is safe because the converter is for this specific property
AttributeConverter<V> typedConverter = (AttributeConverter<V>) attributeConverter;
// Convert the value using the custom converter
return typedConverter.transformFrom(value);
}
// For standard types without custom converters, return the value as-is.
// The TableSchema in SDK v2's Enhanced Client handles type conversions internally
// when building and executing queries.
return value;
}
/**
* Creates a Condition object for operators that do not require a value.
* Used for NULL and NOT_NULL comparison operators.
* @param <V> the type parameter (unused but preserved for API consistency)
* @param comparisonOperator the comparison operator (typically NULL or NOT_NULL)
* @return a Condition object with the specified operator but no attribute values
*/
protected <V> Condition createNoValueCondition(ComparisonOperator comparisonOperator) {
return Condition.builder().comparisonOperator(comparisonOperator)
.build();
}
@NonNull
private List<String> getNumberListAsStringList(@NonNull List<Number> numberList) {
List<String> list = new ArrayList<>();
for (Number number : numberList) {
if (number != null) {
list.add(number.toString());
} else {
list.add(null);
}
}
return list;
}
@NonNull
@SuppressWarnings("deprecation")
private List<String> getDateListAsStringList(@NonNull List<Date> dateList, MarshallingMode mode) {
List<String> list = new ArrayList<>();
if (mode == MarshallingMode.SDK_V1_COMPATIBLE) {
// SDK v1 compatibility: Date marshalled to ISO format string
Date2IsoDynamoDBMarshaller marshaller = new Date2IsoDynamoDBMarshaller();
for (Date date : dateList) {
if (date != null) {
list.add(marshaller.marshall(date));
} else {
list.add(null);
}
}
} else {
// SDK v2 native: Date as epoch milliseconds in Number format
for (Date date : dateList) {
if (date != null) {
list.add(String.valueOf(date.getTime()));
} else {
list.add(null);
}
}
}
return list;
}
@NonNull
@SuppressWarnings("deprecation")
private List<String> getInstantListAsStringList(@NonNull List<Instant> dateList, MarshallingMode mode) {
// Both SDK v1 and v2 store Instant as String (ISO-8601 format)
// AWS SDK v2 uses InstantAsStringAttributeConverter by default
List<String> list = new ArrayList<>();
if (mode == MarshallingMode.SDK_V1_COMPATIBLE) {
// SDK v1 compatibility: Instant marshalled to ISO format string with millisecond precision
Instant2IsoDynamoDBMarshaller marshaller = new Instant2IsoDynamoDBMarshaller();
for (Instant date : dateList) {
if (date != null) {
list.add(marshaller.marshall(date));
} else {
list.add(null);
}
}
} else {
// SDK v2 native: Instant as ISO-8601 string (matches AWS SDK v2 InstantAsStringAttributeConverter)
// Format: ISO-8601 with nanosecond precision, e.g., "1970-01-01T00:00:00.001Z"
for (Instant date : dateList) {
if (date != null) {
list.add(date.toString());
} else {
list.add(null);
}
}
}
return list;
}
@NonNull
private List<String> getBooleanListAsStringList(@NonNull List<Boolean> booleanList) {
// Note: DynamoDB doesn't support a BOOL set type (only SS/NS/BS)
// Boolean lists must always be stored as Number set "1"/"0" regardless of marshalling mode
List<String> list = new ArrayList<>();
for (Boolean booleanValue : booleanList) {
if (booleanValue != null) {
list.add(booleanValue ? "1" : "0");
} else {
list.add(null);
}
}
return list;
}
@SuppressWarnings("unchecked")
@Nullable
private <P> List<P> getAttributeValueAsList(@Nullable Object attributeValue) {
if (attributeValue == null) {
return null;
}
boolean isIterable = ClassUtils.isAssignable(Iterable.class, attributeValue.getClass());
if (isIterable) {
List<P> attributeValueAsList = new ArrayList<>();
Iterable<P> iterable = (Iterable<P>) attributeValue;
for (P attributeValueElement : iterable) {
attributeValueAsList.add(attributeValueElement);
}
return attributeValueAsList;
}
return null;
}
/**
* Adds an attribute value to the list, handling type conversions and collection expansion.
*
* @param <P> the type of the property
* @param attributeValueList the list to add the attribute value to
* @param attributeValue the value to add
* @param propertyType the type of the property
* @param expandCollectionValues whether to expand collection values into DynamoDB sets
*/
protected <P> void addAttributeValue(@NonNull List<AttributeValue> attributeValueList,
@Nullable Object attributeValue, @NonNull Class<P> propertyType, boolean expandCollectionValues) {
AttributeValue.Builder attributeValueBuilder = AttributeValue.builder();
if (ClassUtils.isAssignable(String.class, propertyType)) {
List<String> attributeValueAsList = getAttributeValueAsList(attributeValue);
if (expandCollectionValues && attributeValueAsList != null) {
attributeValueBuilder.ss(attributeValueAsList);
} else {
attributeValueBuilder.s((String) attributeValue);
}
} else if (ClassUtils.isAssignable(Number.class, propertyType)) {
List<Number> attributeValueAsList = getAttributeValueAsList(attributeValue);
if (expandCollectionValues && attributeValueAsList != null) {
List<String> attributeValueAsStringList = getNumberListAsStringList(attributeValueAsList);
attributeValueBuilder.ns(attributeValueAsStringList);
} else {
assert attributeValue != null;
attributeValueBuilder.n(attributeValue.toString());
}
} else if (ClassUtils.isAssignable(Boolean.class, propertyType)) {
List<Boolean> attributeValueAsList = getAttributeValueAsList(attributeValue);
if (expandCollectionValues && attributeValueAsList != null) {
if (mappingContext.getMarshallingMode() == MarshallingMode.SDK_V1_COMPATIBLE) {
// SDK v1 compatibility: Boolean list stored as Number set "1"/"0"
List<String> attributeValueAsStringList = getBooleanListAsStringList(attributeValueAsList);
attributeValueBuilder.ns(attributeValueAsStringList);
} else {
// SDK v2 native: Boolean list not directly supported in DynamoDB, use Number set
// (DynamoDB doesn't have a BOOL set type, only SS/NS/BS)
List<String> attributeValueAsStringList = getBooleanListAsStringList(attributeValueAsList);
attributeValueBuilder.ns(attributeValueAsStringList);
}
} else {
if (mappingContext.getMarshallingMode() == MarshallingMode.SDK_V1_COMPATIBLE) {
// SDK v1 compatibility: Boolean stored as Number "1"/"0"
assert attributeValue != null;
boolean boolValue = (Boolean) attributeValue;
attributeValueBuilder.n(boolValue ? "1" : "0");
} else {
// SDK v2 native: Boolean stored as BOOL type
attributeValueBuilder.bool((Boolean) attributeValue);
}
}
} else if (ClassUtils.isAssignable(Date.class, propertyType)) {
List<Date> attributeValueAsList = getAttributeValueAsList(attributeValue);
if (expandCollectionValues && attributeValueAsList != null) {
if (mappingContext.getMarshallingMode() == MarshallingMode.SDK_V1_COMPATIBLE) {
// SDK v1 compatibility: Date list stored as String set (ISO format)
List<String> attributeValueAsStringList = getDateListAsStringList(attributeValueAsList, MarshallingMode.SDK_V1_COMPATIBLE);
attributeValueBuilder.ss(attributeValueAsStringList);
} else {
// SDK v2 native: Date list stored as Number set (epoch milliseconds)
List<String> attributeValueAsStringList = getDateListAsStringList(attributeValueAsList, MarshallingMode.SDK_V2_NATIVE);
attributeValueBuilder.ns(attributeValueAsStringList);
}
} else {
if (mappingContext.getMarshallingMode() == MarshallingMode.SDK_V1_COMPATIBLE) {
// SDK v1 compatibility: Date stored as ISO format string
Date date = (Date) attributeValue;
String marshalledDate = new Date2IsoDynamoDBMarshaller().marshall(date);
attributeValueBuilder.s(marshalledDate);
} else {
// SDK v2 native: Date stored as epoch milliseconds in Number format
assert attributeValue != null;
attributeValueBuilder.n(String.valueOf(((Date) attributeValue).getTime()));
}
}
} else if (ClassUtils.isAssignable(Instant.class, propertyType)) {
// Both SDK v1 and v2 store Instant as String (ISO-8601 format)
// AWS SDK v2 uses InstantAsStringAttributeConverter by default
List<Instant> attributeValueAsList = getAttributeValueAsList(attributeValue);
if (expandCollectionValues && attributeValueAsList != null) {
// Instant lists always stored as String set (ISO-8601 format) in both modes
List<String> attributeValueAsStringList = getInstantListAsStringList(attributeValueAsList, mappingContext.getMarshallingMode());
attributeValueBuilder.ss(attributeValueAsStringList);
} else {
if (mappingContext.getMarshallingMode() == MarshallingMode.SDK_V1_COMPATIBLE) {
// SDK v1 compatibility: Instant stored as ISO format string with millisecond precision
Instant date = (Instant) attributeValue;
String marshalledDate = new Instant2IsoDynamoDBMarshaller().marshall(date);
attributeValueBuilder.s(marshalledDate);
} else {
// SDK v2 native: Instant stored as ISO-8601 string (matches AWS SDK v2 InstantAsStringAttributeConverter)
// Format: ISO-8601 with nanosecond precision, e.g., "1970-01-01T00:00:00.001Z"
assert attributeValue != null;
attributeValueBuilder.s(attributeValue.toString());
}
}
} else {
assert attributeValue != null;
throw new RuntimeException("Cannot create condition for type:" + attributeValue.getClass()
+ " property conditions must be String,Number or Boolean, or have an AttributeConverter configured");
}
attributeValueList.add(attributeValueBuilder.build());
}
/**
* Creates a Condition object with a single value for the specified property.
*
* @param propertyName the property name
* @param comparisonOperator the comparison operator
* @param o the condition value
* @param propertyType the type of the property
* @param alreadyMarshalledIfRequired whether the value is already marshalled
* @return a Condition object ready for use in queries
* @throws IllegalArgumentException if o is null
*/
protected Condition createSingleValueCondition(String propertyName, ComparisonOperator comparisonOperator, Object o,
Class<?> propertyType, boolean alreadyMarshalledIfRequired) {
Assert.notNull(o, "Creating conditions on null property values not supported: please specify a value for '"
+ propertyName + "'");
List<AttributeValue> attributeValueList = new ArrayList<>(1);
Object attributeValue = !alreadyMarshalledIfRequired ? getPropertyAttributeValue(propertyName, o) : o;
if (ClassUtils.isAssignableValue(AttributeValue.class, attributeValue)) {
attributeValueList.add((AttributeValue) attributeValue);
} else {
boolean marshalled = !alreadyMarshalledIfRequired && attributeValue != o
&& !entityInformation.isCompositeHashAndRangeKeyProperty(propertyName);
Class<?> targetPropertyType = marshalled ? String.class : propertyType;
addAttributeValue(attributeValueList, attributeValue, targetPropertyType, true);
}
return Condition.builder().comparisonOperator(comparisonOperator).attributeValueList(attributeValueList)
.build();
}
/**
* Creates a Condition object with multiple values for the specified property.
* Used for IN and BETWEEN conditions.
*
* @param propertyName the property name
* @param comparisonOperator the comparison operator
* @param o an iterable of values for the condition
* @param propertyType the type of the property
* @return a Condition object ready for use in queries
* @throws IllegalArgumentException if o is null or empty
*/
protected Condition createCollectionCondition(String propertyName, ComparisonOperator comparisonOperator,
@NonNull Iterable<?> o, Class<?> propertyType) {
Assert.notNull(o, "Creating conditions on null property values not supported: please specify a value for '"
+ propertyName + "'");
List<AttributeValue> attributeValueList = new ArrayList<>();
boolean marshalled = false;
for (Object object : o) {
Object attributeValue = getPropertyAttributeValue(propertyName, object);
if (ClassUtils.isAssignableValue(AttributeValue.class, attributeValue)) {
attributeValueList.add((AttributeValue) attributeValue);
} else {
if (attributeValue != null) {
marshalled = attributeValue != object
&& !entityInformation.isCompositeHashAndRangeKeyProperty(propertyName);
}
Class<?> targetPropertyType = marshalled ? String.class : propertyType;
addAttributeValue(attributeValueList, attributeValue, targetPropertyType, false);
}
}
return Condition.builder().comparisonOperator(comparisonOperator).attributeValueList(attributeValueList)
.build();
}
/**
* Sets the sort order for the query results.
* Implementation of interface method.
* @param sort the sort specification
*/
@Override
public void withSort(Sort sort) {
this.sort = sort;
}
/**
* Sets the projection expression to limit the attributes returned.
* Implementation of interface method.
* @param projection the projection expression specifying which attributes to return
*/
@Override
public void withProjection(@Nullable String projection) {
this.projection = projection;
}
/**
* Sets the maximum number of items to return from the query.
* Implementation of interface method.
* @param limit the maximum number of items to return
*/
@Override
public void withLimit(@Nullable Integer limit) {
this.limit = limit;
}
/**
* Sets the filter expression to further filter query results.
* Implementation of interface method.
* @param filter the filter expression to apply
*/
@Override
public void withFilterExpression(@Nullable String filter) {
this.filterExpression = filter;
}
/**
* Sets the expression attribute names for the query.
* Implementation of interface method.
* @param names an array of expression attribute names
*/
@Override
public void withExpressionAttributeNames(@Nullable ExpressionAttribute[] names) {
if (names != null)
this.expressionAttributeNames = names.clone();
}
/**
* Sets the expression attribute values for the query.
* Implementation of interface method.
* @param values an array of expression attribute values
*/
@Override
public void withExpressionAttributeValues(@Nullable ExpressionAttribute[] values) {
if (values != null)
this.expressionAttributeValues = values.clone();
}
/**
* Sets the consistent read mode for the query.
* Implementation of interface method.
* @param consistentReads the consistent read mode
*/
@Override
public void withConsistentReads(QueryConstants.ConsistentReadMode consistentReads) {
this.consistentReads = consistentReads;
}
/**
* Sets mapped expression values for parameter substitution.
* Implementation of interface method.
* @param map a map of parameter names to their string values
*/
@Override
public void withMappedExpressionValues(Map<String, String> map) {
this.mappedExpressionValues = map;
}
}