Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1615,6 +1615,284 @@ void testUpdateReturnsBeforeDocument() throws Exception {
}
}

@Nested
@DisplayName("ADD Operator Tests")
class AddSubdocOperatorTests {

@Test
@DisplayName("Should increment top-level numeric column with ADD operator")
void testAddTopLevelColumn() throws Exception {
// Row 1 has price = 10
Query query =
Query.builder()
.setFilter(
RelationalExpression.of(
IdentifierExpression.of("id"),
RelationalOperator.EQ,
ConstantExpression.of("1")))
.build();

// ADD 5 to price (10 + 5 = 15)
List<SubDocumentUpdate> updates =
List.of(
SubDocumentUpdate.builder()
.subDocument("price")
.operator(UpdateOperator.ADD)
.subDocumentValue(
org.hypertrace.core.documentstore.model.subdoc.SubDocumentValue.of(5))
.build());

UpdateOptions options =
UpdateOptions.builder().returnDocumentType(ReturnDocumentType.AFTER_UPDATE).build();

Optional<Document> result = flatCollection.update(query, updates, options);

assertTrue(result.isPresent());
JsonNode resultJson = OBJECT_MAPPER.readTree(result.get().toJson());
assertEquals(15, resultJson.get("price").asInt());

// Verify in database
PostgresDatastore pgDatastore = (PostgresDatastore) postgresDatastore;
try (Connection conn = pgDatastore.getPostgresClient();
PreparedStatement ps =
conn.prepareStatement(
String.format(
"SELECT \"price\" FROM \"%s\" WHERE \"id\" = '1'", FLAT_COLLECTION_NAME));
ResultSet rs = ps.executeQuery()) {
assertTrue(rs.next());
assertEquals(15, rs.getInt("price"));
}
}

@Test
@DisplayName("Should handle ADD on NULL column (treat as 0)")
void testAddOnNullColumn() throws Exception {
// Create a document with NULL price
String docId = "add-null-test";
Key key = new SingleValueKey(DEFAULT_TENANT, docId);
ObjectNode node = OBJECT_MAPPER.createObjectNode();
node.put("item", "NullPriceItem");
// price is not set, will be NULL
flatCollection.create(key, new JSONDocument(node));

Query query =
Query.builder()
.setFilter(
RelationalExpression.of(
IdentifierExpression.of("id"),
RelationalOperator.EQ,
ConstantExpression.of(key.toString())))
.build();

// ADD 100 to NULL price (COALESCE(NULL, 0) + 100 = 100)
List<SubDocumentUpdate> updates =
List.of(
SubDocumentUpdate.builder()
.subDocument("price")
.operator(UpdateOperator.ADD)
.subDocumentValue(
org.hypertrace.core.documentstore.model.subdoc.SubDocumentValue.of(100))
.build());

UpdateOptions options =
UpdateOptions.builder().returnDocumentType(ReturnDocumentType.AFTER_UPDATE).build();

Optional<Document> result = flatCollection.update(query, updates, options);

assertTrue(result.isPresent());
JsonNode resultJson = OBJECT_MAPPER.readTree(result.get().toJson());
assertEquals(100, resultJson.get("price").asInt());
}

@Test
@DisplayName("Should ADD with negative value (decrement)")
void testAddNegativeValue() throws Exception {
// Row 2 has price = 20
Query query =
Query.builder()
.setFilter(
RelationalExpression.of(
IdentifierExpression.of("id"),
RelationalOperator.EQ,
ConstantExpression.of("2")))
.build();

// ADD -5 to price (20 - 5 = 15)
List<SubDocumentUpdate> updates =
List.of(
SubDocumentUpdate.builder()
.subDocument("price")
.operator(UpdateOperator.ADD)
.subDocumentValue(
org.hypertrace.core.documentstore.model.subdoc.SubDocumentValue.of(-5))
.build());

UpdateOptions options =
UpdateOptions.builder().returnDocumentType(ReturnDocumentType.AFTER_UPDATE).build();

Optional<Document> result = flatCollection.update(query, updates, options);

assertTrue(result.isPresent());
JsonNode resultJson = OBJECT_MAPPER.readTree(result.get().toJson());
assertEquals(15, resultJson.get("price").asInt());
}

@Test
@DisplayName("Should ADD with floating point value")
void testAddFloatingPointValue() throws Exception {
// Row 3 has price = 30
Query query =
Query.builder()
.setFilter(
RelationalExpression.of(
IdentifierExpression.of("id"),
RelationalOperator.EQ,
ConstantExpression.of("3")))
.build();

// ADD 0.5 to price (30 + 0.5 = 30.5, but price is INTEGER so it might truncate)
// Testing with a column that supports decimals - weight is DOUBLE PRECISION
List<SubDocumentUpdate> updates =
List.of(
SubDocumentUpdate.builder()
.subDocument("weight")
.operator(UpdateOperator.ADD)
.subDocumentValue(
org.hypertrace.core.documentstore.model.subdoc.SubDocumentValue.of(2.5))
.build());

UpdateOptions options =
UpdateOptions.builder().returnDocumentType(ReturnDocumentType.AFTER_UPDATE).build();

Optional<Document> result = flatCollection.update(query, updates, options);

assertTrue(result.isPresent());
JsonNode resultJson = OBJECT_MAPPER.readTree(result.get().toJson());
// Initial weight is NULL, so COALESCE(NULL, 0) + 2.5 = 2.5
assertEquals(2.5, resultJson.get("weight").asDouble(), 0.01);
}

@Test
@DisplayName("Should ADD to nested JSONB numeric field")
void testAddNestedJsonbField() throws Exception {
// First, set up a document with a JSONB field containing a numeric value
String docId = "add-jsonb-test";
Key key = new SingleValueKey(DEFAULT_TENANT, docId);
ObjectNode node = OBJECT_MAPPER.createObjectNode();
node.put("item", "JsonbItem");
ObjectNode sales = OBJECT_MAPPER.createObjectNode();
sales.put("total", 100);
sales.put("count", 5);
node.set("sales", sales);
flatCollection.create(key, new JSONDocument(node));

Query query =
Query.builder()
.setFilter(
RelationalExpression.of(
IdentifierExpression.of("id"),
RelationalOperator.EQ,
ConstantExpression.of(key.toString())))
.build();

// ADD 50 to sales.total (100 + 50 = 150)
List<SubDocumentUpdate> updates =
List.of(
SubDocumentUpdate.builder()
.subDocument("sales.total")
.operator(UpdateOperator.ADD)
.subDocumentValue(
org.hypertrace.core.documentstore.model.subdoc.SubDocumentValue.of(50))
.build());

UpdateOptions options =
UpdateOptions.builder().returnDocumentType(ReturnDocumentType.AFTER_UPDATE).build();

Optional<Document> result = flatCollection.update(query, updates, options);

assertTrue(result.isPresent());
JsonNode resultJson = OBJECT_MAPPER.readTree(result.get().toJson());
assertEquals(150, resultJson.get("sales").get("total").asInt());
// Verify count wasn't affected
assertEquals(5, resultJson.get("sales").get("count").asInt());
}

@Test
@DisplayName("Should ADD to nested JSONB field that doesn't exist (creates with value)")
void testAddNestedJsonbFieldNotExists() throws Exception {
// Document with empty JSONB or no such nested key
String docId = "add-jsonb-new-key";
Key key = new SingleValueKey(DEFAULT_TENANT, docId);
ObjectNode node = OBJECT_MAPPER.createObjectNode();
node.put("item", "NewKeyItem");
ObjectNode sales = OBJECT_MAPPER.createObjectNode();
sales.put("region", "US");
// No 'total' key
node.set("sales", sales);
flatCollection.create(key, new JSONDocument(node));

Query query =
Query.builder()
.setFilter(
RelationalExpression.of(
IdentifierExpression.of("id"),
RelationalOperator.EQ,
ConstantExpression.of(key.toString())))
.build();

// ADD 75 to sales.total (non-existent, should become 0 + 75 = 75)
List<SubDocumentUpdate> updates =
List.of(
SubDocumentUpdate.builder()
.subDocument("sales.total")
.operator(UpdateOperator.ADD)
.subDocumentValue(
org.hypertrace.core.documentstore.model.subdoc.SubDocumentValue.of(75))
.build());

UpdateOptions options =
UpdateOptions.builder().returnDocumentType(ReturnDocumentType.AFTER_UPDATE).build();

Optional<Document> result = flatCollection.update(query, updates, options);

assertTrue(result.isPresent());
JsonNode resultJson = OBJECT_MAPPER.readTree(result.get().toJson());
assertEquals(75.0, resultJson.get("sales").get("total").asDouble(), 0.01);
// Verify existing key wasn't affected
assertEquals("US", resultJson.get("sales").get("region").asText());
}

@Test
@DisplayName("Should throw IllegalArgumentException for non-numeric value")
void testAddNonNumericValue() {
Query query =
Query.builder()
.setFilter(
RelationalExpression.of(
IdentifierExpression.of("id"),
RelationalOperator.EQ,
ConstantExpression.of("1")))
.build();

// ADD with a string value should fail
List<SubDocumentUpdate> updates =
List.of(
SubDocumentUpdate.builder()
.subDocument("price")
.operator(UpdateOperator.ADD)
.subDocumentValue(
org.hypertrace.core.documentstore.model.subdoc.SubDocumentValue.of(
"not-a-number"))
.build());

UpdateOptions options =
UpdateOptions.builder().returnDocumentType(ReturnDocumentType.AFTER_UPDATE).build();

assertThrows(
IllegalArgumentException.class, () -> flatCollection.update(query, updates, options));
}
}

@Test
@DisplayName("Should return empty when no document matches query")
void testUpdateNoMatch() throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.hypertrace.core.documentstore.model.options.ReturnDocumentType.AFTER_UPDATE;
import static org.hypertrace.core.documentstore.model.options.ReturnDocumentType.BEFORE_UPDATE;
import static org.hypertrace.core.documentstore.model.subdoc.UpdateOperator.ADD;
import static org.hypertrace.core.documentstore.model.subdoc.UpdateOperator.SET;

import com.fasterxml.jackson.databind.JsonNode;
Expand Down Expand Up @@ -46,6 +47,7 @@
import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresDataType;
import org.hypertrace.core.documentstore.postgres.query.v1.transformer.FlatPostgresFieldTransformer;
import org.hypertrace.core.documentstore.postgres.update.FlatUpdateContext;
import org.hypertrace.core.documentstore.postgres.update.parser.FlatCollectionSubDocAddOperatorParser;
import org.hypertrace.core.documentstore.postgres.update.parser.FlatCollectionSubDocSetOperatorParser;
import org.hypertrace.core.documentstore.postgres.update.parser.FlatCollectionSubDocUpdateOperatorParser;
import org.hypertrace.core.documentstore.postgres.utils.PostgresUtils;
Expand All @@ -72,7 +74,10 @@ public class FlatPostgresCollection extends PostgresCollection {
private static final String DEFAULT_PRIMARY_KEY_COLUMN = "key";

private static final Map<UpdateOperator, FlatCollectionSubDocUpdateOperatorParser>
SUB_DOC_UPDATE_PARSERS = Map.of(SET, new FlatCollectionSubDocSetOperatorParser());
SUB_DOC_UPDATE_PARSERS =
Map.of(
SET, new FlatCollectionSubDocSetOperatorParser(),
ADD, new FlatCollectionSubDocAddOperatorParser());

private final PostgresLazyilyLoadedSchemaRegistry schemaRegistry;

Expand Down
Loading
Loading