|
62 | 62 | import static org.junit.jupiter.api.Assertions.assertThrows; |
63 | 63 | import static org.junit.jupiter.api.Assertions.assertTrue; |
64 | 64 |
|
| 65 | +import com.fasterxml.jackson.core.JsonProcessingException; |
65 | 66 | import com.fasterxml.jackson.databind.JsonNode; |
66 | 67 | import com.fasterxml.jackson.databind.ObjectMapper; |
67 | 68 | import com.typesafe.config.Config; |
68 | 69 | import com.typesafe.config.ConfigFactory; |
69 | 70 | import java.io.IOException; |
70 | 71 | import java.util.HashMap; |
| 72 | +import java.util.HashSet; |
71 | 73 | import java.util.Iterator; |
72 | 74 | import java.util.List; |
73 | 75 | import java.util.Map; |
74 | 76 | import java.util.Optional; |
75 | 77 | import java.util.Random; |
| 78 | +import java.util.Set; |
76 | 79 | import java.util.Spliterator; |
77 | 80 | import java.util.UUID; |
78 | 81 | import java.util.concurrent.Callable; |
|
85 | 88 | import org.hypertrace.core.documentstore.commons.DocStoreConstants; |
86 | 89 | import org.hypertrace.core.documentstore.expression.impl.AggregateExpression; |
87 | 90 | import org.hypertrace.core.documentstore.expression.impl.AliasedIdentifierExpression; |
| 91 | +import org.hypertrace.core.documentstore.expression.impl.ArrayIdentifierExpression; |
88 | 92 | import org.hypertrace.core.documentstore.expression.impl.ArrayRelationalFilterExpression; |
89 | 93 | import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; |
90 | 94 | import org.hypertrace.core.documentstore.expression.impl.FunctionExpression; |
91 | 95 | import org.hypertrace.core.documentstore.expression.impl.IdentifierExpression; |
| 96 | +import org.hypertrace.core.documentstore.expression.impl.JsonArrayIdentifierExpression; |
92 | 97 | import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; |
93 | 98 | import org.hypertrace.core.documentstore.expression.impl.KeyExpression; |
94 | 99 | import org.hypertrace.core.documentstore.expression.impl.LogicalExpression; |
@@ -206,7 +211,8 @@ private static void createFlatCollectionSchema( |
206 | 211 | + "\"sales\" JSONB," |
207 | 212 | + "\"numbers\" INTEGER[]," |
208 | 213 | + "\"scores\" DOUBLE PRECISION[]," |
209 | | - + "\"flags\" BOOLEAN[]" |
| 214 | + + "\"flags\" BOOLEAN[]," |
| 215 | + + "\"attributes\" JSONB" |
210 | 216 | + ");", |
211 | 217 | collectionName); |
212 | 218 |
|
@@ -4362,6 +4368,199 @@ void testJsonbNumericComparisonOperators(String dataStoreName) { |
4362 | 4368 | } |
4363 | 4369 | } |
4364 | 4370 |
|
| 4371 | + @Nested |
| 4372 | + class FlatCollectionArrayBehaviourTest { |
| 4373 | + |
| 4374 | + /** |
| 4375 | + * Test EXISTS filter on top-level arrays. It should only return arrays that are non-empty (have |
| 4376 | + * at-least one element) |
| 4377 | + */ |
| 4378 | + @ParameterizedTest |
| 4379 | + @ArgumentsSource(PostgresProvider.class) |
| 4380 | + void testExistsFilterOnArray(String dataStoreName) throws JsonProcessingException { |
| 4381 | + Datastore datastore = datastoreMap.get(dataStoreName); |
| 4382 | + Collection flatCollection = |
| 4383 | + datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); |
| 4384 | + |
| 4385 | + // Query using EXISTS on array field (simulating ArrayIdentifierExpression behavior) |
| 4386 | + // tags column has: NULL (row 9), empty '{}' (rows 10, 11, 13), non-empty (rows 1-8, 12, 14) |
| 4387 | + // Using EXISTS with 'null' parameter (matching entity-service pattern) |
| 4388 | + Query query = |
| 4389 | + Query.builder() |
| 4390 | + .addSelection(IdentifierExpression.of("item")) |
| 4391 | + .addSelection(IdentifierExpression.of("tags")) |
| 4392 | + .setFilter( |
| 4393 | + RelationalExpression.of( |
| 4394 | + ArrayIdentifierExpression.of("tags"), EXISTS, ConstantExpression.of("null"))) |
| 4395 | + .build(); |
| 4396 | + |
| 4397 | + Iterator<Document> results = flatCollection.find(query); |
| 4398 | + |
| 4399 | + int count = 0; |
| 4400 | + while (results.hasNext()) { |
| 4401 | + Document doc = results.next(); |
| 4402 | + JsonNode json = new ObjectMapper().readTree(doc.toJson()); |
| 4403 | + count++; |
| 4404 | + // Verify that ALL returned documents have non-empty arrays |
| 4405 | + JsonNode tags = json.get("tags"); |
| 4406 | + assertTrue( |
| 4407 | + tags.isArray() && !tags.isEmpty(), "tags should be non-empty array, but was: " + tags); |
| 4408 | + } |
| 4409 | + |
| 4410 | + // Should return only documents with non-empty arrays |
| 4411 | + // From test data: rows 1-8 have non-empty arrays (8 docs) |
| 4412 | + // Plus rows 12, 14 have non-empty arrays (2 docs) |
| 4413 | + // Total: 10 documents |
| 4414 | + assertEquals(10, count, "Should return a total of 10 docs that have non-empty tags"); |
| 4415 | + } |
| 4416 | + |
| 4417 | + /** |
| 4418 | + * Test NOT_EXISTS filter with ArrayIdentifierExpression. This validates that NOT_EXISTS on |
| 4419 | + * array fields returns both NULL and empty arrays, excluding only non-empty arrays. |
| 4420 | + */ |
| 4421 | + @ParameterizedTest |
| 4422 | + @ArgumentsSource(PostgresProvider.class) |
| 4423 | + void testNotExistsFilterOnArrays(String dataStoreName) throws JsonProcessingException { |
| 4424 | + Datastore datastore = datastoreMap.get(dataStoreName); |
| 4425 | + Collection flatCollection = |
| 4426 | + datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); |
| 4427 | + |
| 4428 | + // Query using NOT_EXISTS on array field (simulating ArrayIdentifierExpression behavior) |
| 4429 | + // Using NOT_EXISTS with 'null' parameter (matching entity-service pattern) |
| 4430 | + Query query = |
| 4431 | + Query.builder() |
| 4432 | + .addSelection(IdentifierExpression.of("item")) |
| 4433 | + .addSelection(IdentifierExpression.of("tags")) |
| 4434 | + .setFilter( |
| 4435 | + RelationalExpression.of( |
| 4436 | + ArrayIdentifierExpression.of("tags"), |
| 4437 | + NOT_EXISTS, |
| 4438 | + ConstantExpression.of("null"))) |
| 4439 | + .build(); |
| 4440 | + |
| 4441 | + Iterator<Document> results = flatCollection.find(query); |
| 4442 | + |
| 4443 | + int count = 0; |
| 4444 | + while (results.hasNext()) { |
| 4445 | + Document doc = results.next(); |
| 4446 | + JsonNode json = new ObjectMapper().readTree(doc.toJson()); |
| 4447 | + count++; |
| 4448 | + // Verify that ALL returned documents have NULL or empty arrays |
| 4449 | + JsonNode tags = json.get("tags"); |
| 4450 | + assertTrue( |
| 4451 | + tags == null || !tags.isArray() || tags.isEmpty(), |
| 4452 | + "tags should be NULL or empty array, but was: " + tags); |
| 4453 | + } |
| 4454 | + |
| 4455 | + // Should return documents with NULL or empty arrays |
| 4456 | + // From test data: row 9 (NULL), rows 10, 11, 13 (empty arrays) |
| 4457 | + // Total: 4 documents |
| 4458 | + assertEquals(4, count, "Should return at 4 documents with NULL or empty tags"); |
| 4459 | + } |
| 4460 | + |
| 4461 | + /** |
| 4462 | + * Test EXISTS filter on JSONB arrays. Should only return non-empty arrays (with at-least one |
| 4463 | + * element). |
| 4464 | + */ |
| 4465 | + @ParameterizedTest |
| 4466 | + @ArgumentsSource(PostgresProvider.class) |
| 4467 | + void testExistsFilterOnJsonArrays(String dataStoreName) throws JsonProcessingException { |
| 4468 | + Datastore datastore = datastoreMap.get(dataStoreName); |
| 4469 | + Collection flatCollection = |
| 4470 | + datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); |
| 4471 | + |
| 4472 | + // Query using EXISTS on JSONB array field |
| 4473 | + // attributes.certifications has: non-empty (row 1), empty (rows 2, 10, 11), NULL (rest) |
| 4474 | + Query query = |
| 4475 | + Query.builder() |
| 4476 | + .addSelection(IdentifierExpression.of("item")) |
| 4477 | + .addSelection(JsonIdentifierExpression.of("attributes", "certifications")) |
| 4478 | + .setFilter( |
| 4479 | + RelationalExpression.of( |
| 4480 | + JsonArrayIdentifierExpression.of("attributes", "certifications"), |
| 4481 | + EXISTS, |
| 4482 | + ConstantExpression.of("null"))) |
| 4483 | + .build(); |
| 4484 | + |
| 4485 | + Iterator<Document> results = flatCollection.find(query); |
| 4486 | + |
| 4487 | + int count = 0; |
| 4488 | + while (results.hasNext()) { |
| 4489 | + Document doc = results.next(); |
| 4490 | + JsonNode json = new ObjectMapper().readTree(doc.toJson()); |
| 4491 | + count++; |
| 4492 | + |
| 4493 | + // Verify that ALL returned documents have non-empty arrays in attributes.certifications |
| 4494 | + JsonNode attributes = json.get("attributes"); |
| 4495 | + assertTrue(attributes.isObject(), "attributes should be a JSON object"); |
| 4496 | + |
| 4497 | + JsonNode certifications = attributes.get("certifications"); |
| 4498 | + assertTrue( |
| 4499 | + certifications.isArray() && !certifications.isEmpty(), |
| 4500 | + "certifications should be non-empty array, but was: " + certifications); |
| 4501 | + } |
| 4502 | + |
| 4503 | + // Should return only row 1 which has non-empty certifications array |
| 4504 | + assertEquals(1, count, "Should return exactly 1 document with non-empty certifications"); |
| 4505 | + } |
| 4506 | + |
| 4507 | + /** |
| 4508 | + * Test NOT_EXISTS filter on JSONB arrays. This validates that NOT_EXISTS on array fields inside |
| 4509 | + * JSONB returns documents where the field is NULL, the parent object is NULL, or the array is |
| 4510 | + * empty. |
| 4511 | + */ |
| 4512 | + @ParameterizedTest |
| 4513 | + @ArgumentsSource(PostgresProvider.class) |
| 4514 | + void testNotExistsFilterOnJsonArrays(String dataStoreName) throws JsonProcessingException { |
| 4515 | + Datastore datastore = datastoreMap.get(dataStoreName); |
| 4516 | + Collection flatCollection = |
| 4517 | + datastore.getCollectionForType(FLAT_COLLECTION_NAME, DocumentType.FLAT); |
| 4518 | + |
| 4519 | + // Query using NOT_EXISTS on JSONB array field |
| 4520 | + // Test with attributes.colors field |
| 4521 | + Query query = |
| 4522 | + Query.builder() |
| 4523 | + .addSelection(IdentifierExpression.of("item")) |
| 4524 | + .addSelection(JsonIdentifierExpression.of("attributes", "colors")) |
| 4525 | + .setFilter( |
| 4526 | + RelationalExpression.of( |
| 4527 | + JsonArrayIdentifierExpression.of("attributes", "colors"), |
| 4528 | + NOT_EXISTS, |
| 4529 | + ConstantExpression.of("null"))) |
| 4530 | + .build(); |
| 4531 | + |
| 4532 | + Iterator<Document> results = flatCollection.find(query); |
| 4533 | + |
| 4534 | + int count = 0; |
| 4535 | + Set<String> returnedItems = new HashSet<>(); |
| 4536 | + while (results.hasNext()) { |
| 4537 | + Document doc = results.next(); |
| 4538 | + JsonNode json = new ObjectMapper().readTree(doc.toJson()); |
| 4539 | + count++; |
| 4540 | + |
| 4541 | + String item = json.get("item").asText(); |
| 4542 | + returnedItems.add(item); |
| 4543 | + |
| 4544 | + // Verify that returned documents have NULL parent, missing field, or empty arrays |
| 4545 | + JsonNode attributes = json.get("attributes"); |
| 4546 | + if (attributes != null && attributes.isObject()) { |
| 4547 | + JsonNode colors = attributes.get("colors"); |
| 4548 | + assertTrue( |
| 4549 | + colors == null || !colors.isArray() || colors.isEmpty(), |
| 4550 | + "colors should be NULL or empty array for item: " + item + ", but was: " + colors); |
| 4551 | + } |
| 4552 | + // NULL attributes is also valid |
| 4553 | + } |
| 4554 | + |
| 4555 | + // Should include documents where attributes is NULL or attributes.colors is NULL/empty |
| 4556 | + // Row 11 (Pencil) and other rows with empty/NULL colors |
| 4557 | + assertTrue(count > 0, "Should return at least some documents"); |
| 4558 | + assertTrue( |
| 4559 | + returnedItems.contains("Pencil"), |
| 4560 | + "Should include Pencil (has empty colors array in attributes)"); |
| 4561 | + } |
| 4562 | + } |
| 4563 | + |
4365 | 4564 | @Nested |
4366 | 4565 | class BulkUpdateTest { |
4367 | 4566 |
|
|
0 commit comments