diff --git a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java index 640cb7cc..5c9f8930 100644 --- a/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java +++ b/document-store/src/integrationTest/java/org/hypertrace/core/documentstore/DocStoreQueryV1Test.java @@ -5975,326 +5975,409 @@ private static Collection getFlatCollection(String dataStoreName) { @Nested class FlatCollectionLegacySearchMethod { + /** Collects the `id` field from every document in the iterator, in iteration order. */ + private List collectIds(Iterator iterator) throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + List ids = new ArrayList<>(); + while (iterator.hasNext()) { + ids.add(mapper.readTree(iterator.next().toJson()).get("id").asText()); + } + return ids; + } + + private List assertLegacyV2Parity( + Collection flatCollection, + org.hypertrace.core.documentstore.Query legacyQuery, + Query v2Query) + throws JsonProcessingException { + List legacyIds = collectIds(flatCollection.search(legacyQuery)); + List v2Ids = collectIds(flatCollection.find(v2Query)); + assertEquals(new HashSet<>(legacyIds), new HashSet<>(v2Ids)); + assertEquals(legacyIds.size(), v2Ids.size()); + return legacyIds; + } + @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithNoFilter(String dataStoreName) { + void testSearchWithNoFilter(String dataStoreName) throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Test legacy search() method with no filter - should return all documents org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query(); + Query v2Query = Query.builder().build(); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - assertEquals(10, count); // All 10 documents in flat collection + // no filter, so should return all documents + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(10, ids.size()); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithEqFilter(String dataStoreName) throws JsonProcessingException { + void testSearchCompleteQuery(String dataStoreName) throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Test legacy search() with EQ filter on scalar column + org.hypertrace.core.documentstore.Filter itemFilter = + new org.hypertrace.core.documentstore.Filter( + org.hypertrace.core.documentstore.Filter.Op.EQ, "item", "Soap"); + org.hypertrace.core.documentstore.Filter priceFilter = + new org.hypertrace.core.documentstore.Filter( + org.hypertrace.core.documentstore.Filter.Op.GT, "price", 5); + org.hypertrace.core.documentstore.Filter quantityFilter = + new org.hypertrace.core.documentstore.Filter( + org.hypertrace.core.documentstore.Filter.Op.IN, "quantity", List.of(2, 5)); + + org.hypertrace.core.documentstore.Filter compositeFilter = + new org.hypertrace.core.documentstore.Filter(); + compositeFilter.setOp(org.hypertrace.core.documentstore.Filter.Op.AND); + compositeFilter.setChildFilters( + new org.hypertrace.core.documentstore.Filter[] {itemFilter, priceFilter, quantityFilter}); + + // Both queries explicitly select `id` so the parity helper can compare by id. org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query() - .withFilter( - new org.hypertrace.core.documentstore.Filter( - org.hypertrace.core.documentstore.Filter.Op.EQ, "item", "Soap")); + .withFilter(compositeFilter) + .withSelection("id") + .withSelection("item") + .withSelection("price") + .withOrderBy(new OrderBy("price", false)) // DESC + .withLimit(2) + .withOffset(1); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - assertEquals("Soap", json.get("item").asText()); - count++; - } - assertEquals(3, count); // 3 Soap items (IDs 1, 5, 8) + Query v2Query = + Query.builder() + .setFilter( + LogicalExpression.builder() + .operator(AND) + .operand( + RelationalExpression.of( + IdentifierExpression.of("item"), EQ, ConstantExpression.of("Soap"))) + .operand( + RelationalExpression.of( + IdentifierExpression.of("price"), GT, ConstantExpression.of(5))) + .operand( + RelationalExpression.of( + IdentifierExpression.of("quantity"), + IN, + ConstantExpression.ofNumbers(List.of(2, 5)))) + .build()) + .addSelection(IdentifierExpression.of("id")) + .addSelection(IdentifierExpression.of("item")) + .addSelection(IdentifierExpression.of("price")) + .addSort(IdentifierExpression.of("price"), DESC) + .setPagination(Pagination.builder().limit(2).offset(1).build()) + .build(); + + // Pre-pagination matches: id 1 (Soap, price 10, qty 2), id 5 (Soap, price 20, qty 5), + // id 8 (Soap, price 10, qty 5). Sorted by price DESC: id 5, then (id 1, id 8) tied at 10. + // limit=2, offset=1 -> 2 docs from the tail of the sorted set. + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(2, ids.size()); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithInFilter(String dataStoreName) { + void testSearchWithUnknownFieldFallback(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); - // Test legacy search() with IN filter + // Both legacy and v2 entry points must fail when the query references unknown columns. org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query() - .withFilter( - new org.hypertrace.core.documentstore.Filter( - org.hypertrace.core.documentstore.Filter.Op.IN, - "item", - List.of("Soap", "Mirror"))); + .withSelection("nonexistent_field") + .withOrderBy(new OrderBy("another_unknown", true)) + .withLimit(1); + assertThrows( + Exception.class, + () -> { + Iterator results = flatCollection.search(legacyQuery); + while (results.hasNext()) { + results.next(); + } + }); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - assertEquals(4, count); // 3 Soap + 1 Mirror = 4 + Query v2Query = + Query.builder() + .addSelection(IdentifierExpression.of("nonexistent_field")) + .addSort(IdentifierExpression.of("another_unknown"), ASC) + .setPagination(Pagination.builder().limit(1).offset(0).build()) + .build(); + assertThrows( + Exception.class, + () -> { + Iterator results = flatCollection.find(v2Query); + while (results.hasNext()) { + results.next(); + } + }); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithNumericFilter(String dataStoreName) throws JsonProcessingException { + void testSearchWithInOnTextArrayColumn(String dataStoreName) throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Test legacy search() with GT filter on integer column org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query() .withFilter( new org.hypertrace.core.documentstore.Filter( - org.hypertrace.core.documentstore.Filter.Op.GT, "price", 15)); - - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - assertTrue(json.get("price").asInt() > 15); - count++; - } - assertTrue(count > 0); - } - - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testSearchWithCompositeAndFilter(String dataStoreName) throws JsonProcessingException { - Collection flatCollection = getFlatCollection(dataStoreName); - - // Test legacy search() with composite AND filter - org.hypertrace.core.documentstore.Filter itemFilter = - new org.hypertrace.core.documentstore.Filter( - org.hypertrace.core.documentstore.Filter.Op.EQ, "item", "Soap"); - org.hypertrace.core.documentstore.Filter priceFilter = - new org.hypertrace.core.documentstore.Filter( - org.hypertrace.core.documentstore.Filter.Op.GTE, "price", 10); - - org.hypertrace.core.documentstore.Filter compositeFilter = - new org.hypertrace.core.documentstore.Filter(); - compositeFilter.setOp(org.hypertrace.core.documentstore.Filter.Op.AND); - compositeFilter.setChildFilters( - new org.hypertrace.core.documentstore.Filter[] {itemFilter, priceFilter}); + org.hypertrace.core.documentstore.Filter.Op.IN, + "tags", + List.of("hygiene", "grooming"))); - org.hypertrace.core.documentstore.Query legacyQuery = - new org.hypertrace.core.documentstore.Query().withFilter(compositeFilter); + Query v2Query = + Query.builder() + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.ofStrings("tags"), + IN, + ConstantExpression.ofStrings(List.of("hygiene", "grooming")))) + .build(); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - assertEquals("Soap", json.get("item").asText()); - assertTrue(json.get("price").asInt() >= 10); - count++; - } - assertTrue(count > 0); + // ids 1, 5, 8 (hygiene) + ids 6, 7 (grooming) = 5 docs whose tags array overlaps the filter + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(5, ids.size()); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithOrderBy(String dataStoreName) throws JsonProcessingException { + void testSearchWithInOnIntegerArrayColumn(String dataStoreName) throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Test legacy search() with ORDER BY org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query() - .withOrderBy(new OrderBy("price", true)); // ASC + .withFilter( + new org.hypertrace.core.documentstore.Filter( + org.hypertrace.core.documentstore.Filter.Op.IN, "numbers", List.of(1, 10))); - Iterator results = flatCollection.search(legacyQuery); - int previousPrice = Integer.MIN_VALUE; - int count = 0; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - int currentPrice = json.get("price").asInt(); - assertTrue(currentPrice >= previousPrice, "Results should be sorted by price ASC"); - previousPrice = currentPrice; - count++; - } - assertEquals(10, count); + Query v2Query = + Query.builder() + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.ofInts("numbers"), + IN, + ConstantExpression.ofNumbers(List.of(1, 10)))) + .build(); + + // numbers containing 1: ids 1, 4, 8; containing 10: ids 2, 3, 7, 8 + // -> union = ids 1, 2, 3, 4, 7, 8 + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(6, ids.size()); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithPagination(String dataStoreName) { + void testSearchWithInOnDoubleArrayColumn(String dataStoreName) throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Test legacy search() with LIMIT and OFFSET org.hypertrace.core.documentstore.Query legacyQuery = - new org.hypertrace.core.documentstore.Query().withLimit(3).withOffset(2); + new org.hypertrace.core.documentstore.Query() + .withFilter( + new org.hypertrace.core.documentstore.Filter( + org.hypertrace.core.documentstore.Filter.Op.IN, "scores", List.of(3.0, 5.0))); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - assertEquals(3, count); // Should return exactly 3 documents + Query v2Query = + Query.builder() + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.ofDoubles("scores"), + IN, + ConstantExpression.ofNumbers(List.of(3.0, 5.0)))) + .build(); + + // scores containing 5.0: id 4 ({5.0, 10.0}) + id 8 ({2.5, 5.0}); + // scores containing 3.0: id 7 ({3.0}) -> union = ids 4, 7, 8 + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(3, ids.size()); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithSelections(String dataStoreName) throws JsonProcessingException { + void testSearchWithEqOnTextArrayColumnReturnsArrayElementMembership(String dataStoreName) + throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Test legacy search() with selections + // Legacy EQ on a top-level array column with a scalar RHS is internally rewritten by + // IdentifierExpressionFactory to ArrayIdentifierExpression.ofStrings("tags") + EQ, which + // the v2 parser resolves to array-element membership ('hygiene' = ANY(tags)). org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query() - .withSelection("item") - .withSelection("price") - .withLimit(5); + .withFilter( + new org.hypertrace.core.documentstore.Filter( + org.hypertrace.core.documentstore.Filter.Op.EQ, "tags", "hygiene")); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - // Should have item and price fields - assertNotNull(json.get("item")); - assertNotNull(json.get("price")); - count++; - } - assertEquals(5, count); + Query v2Query = + Query.builder() + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.ofStrings("tags"), + EQ, + ConstantExpression.of("hygiene"))) + .build(); + + // ids 1, 5, 8 all have "hygiene" in their tags array + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(Set.of("1", "5", "8"), new HashSet<>(ids)); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithJsonbFilter(String dataStoreName) throws JsonProcessingException { + void testSearchWithInOnNestedJsonbStringField(String dataStoreName) + throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Test legacy search() with filter on JSONB nested field (props.brand) org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query() .withFilter( new org.hypertrace.core.documentstore.Filter( - org.hypertrace.core.documentstore.Filter.Op.EQ, "props.brand", "Dettol")); + org.hypertrace.core.documentstore.Filter.Op.IN, + "props.brand", + List.of("Dettol", "Lifebuoy"))); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - JsonNode props = json.get("props"); - assertNotNull(props); - assertEquals("Dettol", props.get("brand").asText()); - count++; - } - assertEquals(1, count); // Only 1 document with brand=Dettol + Query v2Query = + Query.builder() + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), + IN, + ConstantExpression.ofStrings(List.of("Dettol", "Lifebuoy")))) + .build(); + + // id=1 (Dettol) + id=5 (Lifebuoy) = 2 docs + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(2, ids.size()); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchCompleteQuery(String dataStoreName) throws JsonProcessingException { + void testSearchWithLikeOnNestedJsonbStringField(String dataStoreName) + throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Test legacy search() with filter, orderBy, selections, and pagination + // The v2 LIKE operator emits a case-insensitive Postgres regex match (`lhs ~* ?`), so the + // value is treated as a regex, not a SQL LIKE pattern. org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query() .withFilter( new org.hypertrace.core.documentstore.Filter( - org.hypertrace.core.documentstore.Filter.Op.GTE, "price", 5)) - .withSelection("item") - .withSelection("price") - .withOrderBy(new OrderBy("price", false)) // DESC - .withLimit(5) - .withOffset(0); + org.hypertrace.core.documentstore.Filter.Op.LIKE, "props.brand", "^Sun")); - Iterator results = flatCollection.search(legacyQuery); - int previousPrice = Integer.MAX_VALUE; - int count = 0; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - int currentPrice = json.get("price").asInt(); - assertTrue(currentPrice >= 5, "Price should be >= 5"); - assertTrue(currentPrice <= previousPrice, "Results should be sorted by price DESC"); - previousPrice = currentPrice; - count++; - } - assertEquals(5, count); + Query v2Query = + Query.builder() + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), + LIKE, + ConstantExpression.of("^Sun"))) + .build(); + + // Only id=3 has props.brand starting with "Sun" ("Sunsilk") + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(1, ids.size()); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithFilterSelectionsOrderByAndOffset(String dataStoreName) + void testSearchWithEqOnDeeplyNestedJsonbStringField(String dataStoreName) throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Test covering: filter, selections, orderBy, and offset (pagination) org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query() .withFilter( new org.hypertrace.core.documentstore.Filter( - org.hypertrace.core.documentstore.Filter.Op.GTE, "price", 5)) - .withSelection("item") - .withSelection("price") - .withOrderBy(new OrderBy("price", true)) // ASC - .withLimit(3) - .withOffset(1); // Skip first result + org.hypertrace.core.documentstore.Filter.Op.EQ, + "props.seller.address.city", + "Mumbai")); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - int previousPrice = Integer.MIN_VALUE; - while (results.hasNext()) { - Document doc = results.next(); - JsonNode json = new ObjectMapper().readTree(doc.toJson()); - assertTrue(json.has("item")); - assertTrue(json.has("price")); - int price = json.get("price").asInt(); - assertTrue(price >= previousPrice); // ASC order - previousPrice = price; - count++; - } - assertEquals(3, count); + Query v2Query = + Query.builder() + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of( + "props", JsonFieldType.STRING, "seller", "address", "city"), + EQ, + ConstantExpression.of("Mumbai"))) + .build(); + + // ids 1 and 3 both have props.seller.address.city = "Mumbai" + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(2, ids.size()); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithNullFilterEmptySelectionsNoOrderBy(String dataStoreName) { + void testSearchWithCompositeAndFilterIncludingNestedJsonbField(String dataStoreName) + throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Test covering null/empty branches: - // - No filter (null filter path) - // - No selections (empty selections path) - // - No orderBy (empty orderBys path) - // - Limit without offset (offset defaults to 0) + // Composite filter mixing a top-level scalar column and a nested JSONB string path: + // price >= 15 AND props.size = "S" + org.hypertrace.core.documentstore.Filter priceFilter = + new org.hypertrace.core.documentstore.Filter( + org.hypertrace.core.documentstore.Filter.Op.GTE, "price", 15); + org.hypertrace.core.documentstore.Filter sizeFilter = + new org.hypertrace.core.documentstore.Filter( + org.hypertrace.core.documentstore.Filter.Op.EQ, "props.size", "S"); + + org.hypertrace.core.documentstore.Filter compositeFilter = + new org.hypertrace.core.documentstore.Filter(); + compositeFilter.setOp(org.hypertrace.core.documentstore.Filter.Op.AND); + compositeFilter.setChildFilters( + new org.hypertrace.core.documentstore.Filter[] {priceFilter, sizeFilter}); + org.hypertrace.core.documentstore.Query legacyQuery = - new org.hypertrace.core.documentstore.Query().withLimit(5); + new org.hypertrace.core.documentstore.Query().withFilter(compositeFilter); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - assertEquals(5, count); + Query v2Query = + Query.builder() + .setFilter( + LogicalExpression.builder() + .operator(AND) + .operand( + RelationalExpression.of( + IdentifierExpression.of("price"), GTE, ConstantExpression.of(15))) + .operand( + RelationalExpression.of( + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "size"), + EQ, + ConstantExpression.of("S"))) + .build()) + .build(); + + // id=5 (Lifebuoy, price=20, size=S) is the only match + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(1, ids.size()); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithUnknownFieldFallback(String dataStoreName) { + void testSearchSelectsNestedJsonbStringAndStringArrayFields(String dataStoreName) + throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query() - .withSelection("nonexistent_field") - .withOrderBy(new OrderBy("another_unknown", true)) - .withLimit(1); + .withSelection("id") + .withSelection("props.brand") + .withSelection("props.colors") + .withFilter( + new org.hypertrace.core.documentstore.Filter( + org.hypertrace.core.documentstore.Filter.Op.EQ, "props.brand", "Dettol")); - assertThrows( - Exception.class, - () -> { - Iterator results = flatCollection.search(legacyQuery); - while (results.hasNext()) { - results.next(); - } - }); + Query v2Query = + Query.builder() + .addSelection(IdentifierExpression.of("id")) + .addSelection(JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand")) + .addSelection(JsonIdentifierExpression.of("props", JsonFieldType.STRING, "colors")) + .setFilter( + RelationalExpression.of( + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), + EQ, + ConstantExpression.of("Dettol"))) + .build(); + + // Only id=1 has props.brand = "Dettol" + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(1, ids.size()); } } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/IdentifierExpressionFactory.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/IdentifierExpressionFactory.java new file mode 100644 index 00000000..eb611fb8 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/IdentifierExpressionFactory.java @@ -0,0 +1,70 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.transformer; + +import org.hypertrace.core.documentstore.commons.ColumnMetadata; +import org.hypertrace.core.documentstore.expression.impl.ArrayIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.DataType; +import org.hypertrace.core.documentstore.expression.impl.IdentifierExpression; + +final class IdentifierExpressionFactory { + + private IdentifierExpressionFactory() {} + + static IdentifierExpression createIdentifierFromColumn( + final String name, final ColumnMetadata column) { + if (column == null) { + return IdentifierExpression.of(name); + } + final DataType type = column.getCanonicalType(); + if (type == null || type == DataType.UNSPECIFIED || type == DataType.JSON) { + return IdentifierExpression.of(name); + } + return column.isArray() ? createTypedArray(name, type) : createTypedScalar(name, type); + } + + private static IdentifierExpression createTypedScalar(final String name, final DataType type) { + switch (type) { + case STRING: + return IdentifierExpression.ofString(name); + case INTEGER: + return IdentifierExpression.ofInt(name); + case LONG: + return IdentifierExpression.ofLong(name); + case FLOAT: + return IdentifierExpression.ofFloat(name); + case DOUBLE: + return IdentifierExpression.ofDouble(name); + case BOOLEAN: + return IdentifierExpression.ofBoolean(name); + case TIMESTAMPTZ: + return IdentifierExpression.ofTimestampTz(name); + case DATE: + return IdentifierExpression.ofDate(name); + default: + return IdentifierExpression.of(name); + } + } + + private static ArrayIdentifierExpression createTypedArray( + final String name, final DataType elementType) { + switch (elementType) { + case STRING: + return ArrayIdentifierExpression.ofStrings(name); + case INTEGER: + return ArrayIdentifierExpression.ofInts(name); + case LONG: + return ArrayIdentifierExpression.ofLongs(name); + case FLOAT: + return ArrayIdentifierExpression.ofFloats(name); + case DOUBLE: + return ArrayIdentifierExpression.ofDoubles(name); + case BOOLEAN: + return ArrayIdentifierExpression.ofBooleans(name); + case TIMESTAMPTZ: + return ArrayIdentifierExpression.ofTimestampsTz(name); + case DATE: + return ArrayIdentifierExpression.ofDates(name); + default: + return ArrayIdentifierExpression.of(name); + } + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyFilterToQueryFilterTransformer.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyFilterToQueryFilterTransformer.java index 267f900b..023260e7 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyFilterToQueryFilterTransformer.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyFilterToQueryFilterTransformer.java @@ -108,8 +108,11 @@ private FilterTypeExpression transformLeafFilter( *

Uses the schema registry to determine if a field is: * *

    - *
  • A direct column → IdentifierExpression - *
  • A JSONB nested path → JsonIdentifierExpression with inferred field type + *
  • A direct column → typed {@link IdentifierExpression} (or {@link + * org.hypertrace.core.documentstore.expression.impl.ArrayIdentifierExpression} for array + * columns) when the schema carries usable type info, falling back to an untyped {@code + * IdentifierExpression} otherwise + *
  • A JSONB nested path → {@link JsonIdentifierExpression} with inferred field type *
*/ private SelectTypeExpression createIdentifierExpression( @@ -119,8 +122,10 @@ private SelectTypeExpression createIdentifierExpression( } // Check if the full path is a direct column - if (schemaRegistry.getColumnOrRefresh(tableName, fieldName).isPresent()) { - return IdentifierExpression.of(fieldName); + Optional directColumn = + schemaRegistry.getColumnOrRefresh(tableName, fieldName); + if (directColumn.isPresent()) { + return IdentifierExpressionFactory.createIdentifierFromColumn(fieldName, directColumn.get()); } // Try to find a JSONB column prefix @@ -132,7 +137,7 @@ private SelectTypeExpression createIdentifierExpression( return JsonIdentifierExpression.of(columnName, fieldType, jsonPath); } - // Fallback: treat as direct column (will fail at query time if column doesn't exist) + // Fallback: treat as direct column return IdentifierExpression.of(fieldName); } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyQueryToV2QueryTransformer.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyQueryToV2QueryTransformer.java index 92002e47..b4dbcf63 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyQueryToV2QueryTransformer.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyQueryToV2QueryTransformer.java @@ -105,8 +105,11 @@ public Query transform(org.hypertrace.core.documentstore.Query legacyQuery) { *

Uses the schema registry to determine if a field is: * *

    - *
  • A direct column → IdentifierExpression - *
  • A JSONB nested path → JsonIdentifierExpression with STRING type (default for + *
  • A direct column → typed {@link IdentifierExpression} (or {@link + * org.hypertrace.core.documentstore.expression.impl.ArrayIdentifierExpression} for array + * columns) when the schema carries usable type info, falling back to an untyped {@code + * IdentifierExpression} otherwise + *
  • A JSONB nested path → {@link JsonIdentifierExpression} with STRING type (default for * selections/orderBy since we don't have a value to infer type from) *
* @@ -119,8 +122,10 @@ private IdentifierExpression createIdentifierExpression(String fieldName) { fieldName != null && !fieldName.isEmpty(), "Field name cannot be null or empty"); // Check if the full path is a direct column - if (schemaRegistry.getColumnOrRefresh(tableName, fieldName).isPresent()) { - return IdentifierExpression.of(fieldName); + Optional directColumn = + schemaRegistry.getColumnOrRefresh(tableName, fieldName); + if (directColumn.isPresent()) { + return IdentifierExpressionFactory.createIdentifierFromColumn(fieldName, directColumn.get()); } // Try to find a JSONB column prefix diff --git a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyQueryToV2QueryTransformerTest.java b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyQueryToV2QueryTransformerTest.java index aa27b52f..af97c186 100644 --- a/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyQueryToV2QueryTransformerTest.java +++ b/document-store/src/test/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/LegacyQueryToV2QueryTransformerTest.java @@ -1,26 +1,33 @@ package org.hypertrace.core.documentstore.postgres.query.v1.transformer; +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.EQ; +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.GT; +import static org.hypertrace.core.documentstore.expression.operators.RelationalOperator.IN; +import static org.hypertrace.core.documentstore.expression.operators.SortOrder.ASC; +import static org.hypertrace.core.documentstore.expression.operators.SortOrder.DESC; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.util.List; import java.util.Optional; import org.hypertrace.core.documentstore.Filter; import org.hypertrace.core.documentstore.OrderBy; import org.hypertrace.core.documentstore.commons.SchemaRegistry; +import org.hypertrace.core.documentstore.expression.impl.ArrayIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.ConstantExpression; import org.hypertrace.core.documentstore.expression.impl.DataType; import org.hypertrace.core.documentstore.expression.impl.IdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.JsonFieldType; import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; -import org.hypertrace.core.documentstore.expression.operators.SortOrder; +import org.hypertrace.core.documentstore.expression.impl.LogicalExpression; +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; import org.hypertrace.core.documentstore.postgres.model.PostgresColumnMetadata; +import org.hypertrace.core.documentstore.query.Pagination; import org.hypertrace.core.documentstore.query.Query; -import org.hypertrace.core.documentstore.query.SelectionSpec; -import org.hypertrace.core.documentstore.query.SortingSpec; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -41,28 +48,15 @@ void setUp() { class TransformNullOrEmptyQuery { @Test - void transformNullQuery_returnsEmptyV2Query() { - Query result = transformer.transform(null); - - assertNotNull(result); - assertTrue(result.getSelections().isEmpty()); - assertTrue(result.getFilter().isEmpty()); - assertTrue(result.getSorts().isEmpty()); - assertTrue(result.getPagination().isEmpty()); + void transformNullQueryReturnsEmptyV2Query() { + assertEquals(Query.builder().build(), transformer.transform(null)); } @Test - void transformEmptyQuery_returnsEmptyV2Query() { - org.hypertrace.core.documentstore.Query legacyQuery = - new org.hypertrace.core.documentstore.Query(); - - Query result = transformer.transform(legacyQuery); - - assertNotNull(result); - assertTrue(result.getSelections().isEmpty()); - assertTrue(result.getFilter().isEmpty()); - assertTrue(result.getSorts().isEmpty()); - assertTrue(result.getPagination().isEmpty()); + void transformEmptyQueryReturnsEmptyV2Query() { + assertEquals( + Query.builder().build(), + transformer.transform(new org.hypertrace.core.documentstore.Query())); } } @@ -70,7 +64,8 @@ void transformEmptyQuery_returnsEmptyV2Query() { class TransformSelections { @Test - void transformDirectColumnSelection_createsIdentifierExpression() { + void transformDirectColumnSelectionCreatesIdentifierExpression() { + // Stubbed column has no canonical type -> factory yields a bare IdentifierExpression. PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "status")) .thenReturn(Optional.of(columnMeta)); @@ -78,16 +73,14 @@ void transformDirectColumnSelection_createsIdentifierExpression() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withSelection("status"); - Query result = transformer.transform(legacyQuery); - - assertEquals(1, result.getSelections().size()); - SelectionSpec spec = result.getSelections().get(0); - assertTrue(spec.getExpression() instanceof IdentifierExpression); - assertEquals("status", ((IdentifierExpression) spec.getExpression()).getName()); + Query expected = Query.builder().addSelection(IdentifierExpression.of("status")).build(); + assertEquals(expected, transformer.transform(legacyQuery)); } @Test - void transformJsonbPathSelection_createsJsonIdentifierExpression() { + void transformJsonbPathSelectionCreatesJsonIdentifierExpression() { + // "customAttr" resolves to a JSON column; ".myField" becomes the JSON path. Selections + // default JsonFieldType to STRING (no value available to infer from). PostgresColumnMetadata jsonbColumnMeta = mock(PostgresColumnMetadata.class); when(jsonbColumnMeta.getCanonicalType()).thenReturn(DataType.JSON); when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "customAttr.myField")) @@ -98,19 +91,16 @@ void transformJsonbPathSelection_createsJsonIdentifierExpression() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withSelection("customAttr.myField"); - Query result = transformer.transform(legacyQuery); - - assertEquals(1, result.getSelections().size()); - SelectionSpec spec = result.getSelections().get(0); - assertTrue(spec.getExpression() instanceof JsonIdentifierExpression); - JsonIdentifierExpression jsonExpr = (JsonIdentifierExpression) spec.getExpression(); - assertEquals("customAttr", jsonExpr.getColumnName()); - assertEquals(1, jsonExpr.getJsonPath().size()); - assertEquals("myField", jsonExpr.getJsonPath().get(0)); + Query expected = + Query.builder() + .addSelection( + JsonIdentifierExpression.of("customAttr", JsonFieldType.STRING, "myField")) + .build(); + assertEquals(expected, transformer.transform(legacyQuery)); } @Test - void transformMultipleSelections_createsMultipleExpressions() { + void transformMultipleSelectionsCreatesMultipleExpressions() { PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "col1")) .thenReturn(Optional.of(columnMeta)); @@ -120,13 +110,16 @@ void transformMultipleSelections_createsMultipleExpressions() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withSelection("col1").withSelection("col2"); - Query result = transformer.transform(legacyQuery); - - assertEquals(2, result.getSelections().size()); + Query expected = + Query.builder() + .addSelection(IdentifierExpression.of("col1")) + .addSelection(IdentifierExpression.of("col2")) + .build(); + assertEquals(expected, transformer.transform(legacyQuery)); } @Test - void transformNullFieldName_throwsException() { + void transformNullFieldNameThrowsException() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query(); legacyQuery.addSelection(null); @@ -139,7 +132,7 @@ void transformNullFieldName_throwsException() { class TransformOrderBy { @Test - void transformAscendingOrderBy_createsSortWithAscOrder() { + void transformAscendingOrderByCreatesSortWithAscOrder() { PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "createdAt")) .thenReturn(Optional.of(columnMeta)); @@ -147,16 +140,12 @@ void transformAscendingOrderBy_createsSortWithAscOrder() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withOrderBy(new OrderBy("createdAt", true)); - Query result = transformer.transform(legacyQuery); - - assertEquals(1, result.getSorts().size()); - SortingSpec sortSpec = result.getSorts().get(0); - assertEquals(SortOrder.ASC, sortSpec.getOrder()); - assertTrue(sortSpec.getExpression() instanceof IdentifierExpression); + Query expected = Query.builder().addSort(IdentifierExpression.of("createdAt"), ASC).build(); + assertEquals(expected, transformer.transform(legacyQuery)); } @Test - void transformDescendingOrderBy_createsSortWithDescOrder() { + void transformDescendingOrderByCreatesSortWithDescOrder() { PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "updatedAt")) .thenReturn(Optional.of(columnMeta)); @@ -165,15 +154,12 @@ void transformDescendingOrderBy_createsSortWithDescOrder() { new org.hypertrace.core.documentstore.Query() .withOrderBy(new OrderBy("updatedAt", false)); - Query result = transformer.transform(legacyQuery); - - assertEquals(1, result.getSorts().size()); - SortingSpec sortSpec = result.getSorts().get(0); - assertEquals(SortOrder.DESC, sortSpec.getOrder()); + Query expected = Query.builder().addSort(IdentifierExpression.of("updatedAt"), DESC).build(); + assertEquals(expected, transformer.transform(legacyQuery)); } @Test - void transformJsonbPathOrderBy_createsJsonIdentifierExpression() { + void transformJsonbPathOrderByCreatesJsonIdentifierExpression() { PostgresColumnMetadata jsonbColumnMeta = mock(PostgresColumnMetadata.class); when(jsonbColumnMeta.getCanonicalType()).thenReturn(DataType.JSON); when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "props.priority")) @@ -185,11 +171,11 @@ void transformJsonbPathOrderBy_createsJsonIdentifierExpression() { new org.hypertrace.core.documentstore.Query() .withOrderBy(new OrderBy("props.priority", true)); - Query result = transformer.transform(legacyQuery); - - assertEquals(1, result.getSorts().size()); - SortingSpec sortSpec = result.getSorts().get(0); - assertTrue(sortSpec.getExpression() instanceof JsonIdentifierExpression); + Query expected = + Query.builder() + .addSort(JsonIdentifierExpression.of("props", JsonFieldType.STRING, "priority"), ASC) + .build(); + assertEquals(expected, transformer.transform(legacyQuery)); } } @@ -197,47 +183,41 @@ void transformJsonbPathOrderBy_createsJsonIdentifierExpression() { class TransformPagination { @Test - void transformLimitOnly_createsPaginationWithZeroOffset() { + void transformLimitOnlyCreatesPaginationWithZeroOffset() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withLimit(10); - Query result = transformer.transform(legacyQuery); - - assertTrue(result.getPagination().isPresent()); - assertEquals(10, result.getPagination().get().getLimit()); - assertEquals(0, result.getPagination().get().getOffset()); + Query expected = + Query.builder().setPagination(Pagination.builder().offset(0).limit(10).build()).build(); + assertEquals(expected, transformer.transform(legacyQuery)); } @Test - void transformLimitAndOffset_createsPaginationWithBoth() { + void transformLimitAndOffsetCreatesPaginationWithBoth() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withLimit(20).withOffset(5); - Query result = transformer.transform(legacyQuery); - - assertTrue(result.getPagination().isPresent()); - assertEquals(20, result.getPagination().get().getLimit()); - assertEquals(5, result.getPagination().get().getOffset()); + Query expected = + Query.builder().setPagination(Pagination.builder().offset(5).limit(20).build()).build(); + assertEquals(expected, transformer.transform(legacyQuery)); } @Test - void transformNegativeLimit_noPagination() { + void transformNegativeLimitNoPagination() { + // Negative limits are dropped silently; nothing else is set, so the result is an empty + // v2 Query. org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withLimit(-1); - Query result = transformer.transform(legacyQuery); - - assertTrue(result.getPagination().isEmpty()); + assertEquals(Query.builder().build(), transformer.transform(legacyQuery)); } @Test - void transformNullLimit_noPagination() { + void transformNullLimitNoPagination() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query(); - Query result = transformer.transform(legacyQuery); - - assertTrue(result.getPagination().isEmpty()); + assertEquals(Query.builder().build(), transformer.transform(legacyQuery)); } } @@ -245,7 +225,7 @@ void transformNullLimit_noPagination() { class TransformFilter { @Test - void transformSimpleEqFilter_createsRelationalExpression() { + void transformSimpleEqFilterCreatesRelationalExpression() { PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "status")) .thenReturn(Optional.of(columnMeta)); @@ -254,13 +234,17 @@ void transformSimpleEqFilter_createsRelationalExpression() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withFilter(legacyFilter); - Query result = transformer.transform(legacyQuery); - - assertTrue(result.getFilter().isPresent()); + Query expected = + Query.builder() + .setFilter( + RelationalExpression.of( + IdentifierExpression.of("status"), EQ, ConstantExpression.of("active"))) + .build(); + assertEquals(expected, transformer.transform(legacyQuery)); } @Test - void transformCompositeAndFilter_createsLogicalExpression() { + void transformCompositeAndFilterCreatesLogicalExpression() { PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); when(schemaRegistry.getColumnOrRefresh(anyString(), anyString())) .thenReturn(Optional.of(columnMeta)); @@ -274,9 +258,16 @@ void transformCompositeAndFilter_createsLogicalExpression() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withFilter(compositeFilter); - Query result = transformer.transform(legacyQuery); - - assertTrue(result.getFilter().isPresent()); + Query expected = + Query.builder() + .setFilter( + LogicalExpression.and( + RelationalExpression.of( + IdentifierExpression.of("status"), EQ, ConstantExpression.of("active")), + RelationalExpression.of( + IdentifierExpression.of("count"), GT, ConstantExpression.of(10)))) + .build(); + assertEquals(expected, transformer.transform(legacyQuery)); } } @@ -284,7 +275,7 @@ void transformCompositeAndFilter_createsLogicalExpression() { class TransformCompleteQuery { @Test - void transformCompleteQuery_allComponentsTransformed() { + void transformCompleteQueryAllComponentsTransformed() { PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); when(schemaRegistry.getColumnOrRefresh(eq(TABLE_NAME), anyString())) .thenReturn(Optional.of(columnMeta)); @@ -299,14 +290,168 @@ void transformCompleteQuery_allComponentsTransformed() { .withLimit(50) .withOffset(10); - Query result = transformer.transform(legacyQuery); + Query expected = + Query.builder() + .addSelection(IdentifierExpression.of("id")) + .addSelection(IdentifierExpression.of("name")) + .setFilter( + RelationalExpression.of( + IdentifierExpression.of("status"), EQ, ConstantExpression.of("active"))) + .addSort(IdentifierExpression.of("createdAt"), DESC) + .setPagination(Pagination.builder().offset(10).limit(50).build()) + .build(); + assertEquals(expected, transformer.transform(legacyQuery)); + } + } + + /** + * Tests that verify the transformer wraps directly-resolved columns in typed identifier + * expressions (so downstream Postgres parsers can emit array-aware SQL like {@code col && ?}, + * {@code col @> ARRAY[?]::int8[]}, {@code col = ANY(?)}) when the {@link PostgresColumnMetadata} + * carries a non-UNSPECIFIED canonical type. + * + *

Each test fixes a known column shape via Mockito stubbing and asserts the resulting + * expression's runtime class and (for arrays) element type. + */ + @Nested + class TypedAndArrayColumnIdentifiers { + + @Test + void textArrayColumnInFilterBuildsArrayIdentifierWithStringElementType() { + Query expected = leafEqFilterQuery(ArrayIdentifierExpression.ofStrings("labels")); + assertEquals(expected, transformLeafFilter("labels", DataType.STRING, true)); + } + + @Test + void bigintArrayColumnInFilterBuildsArrayIdentifierWithLongElementType() { + Query expected = leafEqFilterQuery(ArrayIdentifierExpression.ofLongs("sensitivity")); + assertEquals(expected, transformLeafFilter("sensitivity", DataType.LONG, true)); + } + + @Test + void doublePrecisionArrayColumnInFilterBuildsArrayIdentifierWithDoubleElementType() { + Query expected = leafEqFilterQuery(ArrayIdentifierExpression.ofDoubles("riskScores")); + assertEquals(expected, transformLeafFilter("riskScores", DataType.DOUBLE, true)); + } + + @Test + void scalarTextColumnInFilterBuildsTypedScalarIdentifier() { + Query expected = leafEqFilterQuery(IdentifierExpression.ofString("status")); + assertEquals(expected, transformLeafFilter("status", DataType.STRING, false)); + } + + @Test + void scalarBigintColumnInFilterBuildsTypedScalarIdentifierWithLongType() { + Query expected = leafEqFilterQuery(IdentifierExpression.ofLong("statusCode")); + assertEquals(expected, transformLeafFilter("statusCode", DataType.LONG, false)); + } + + @Test + void unspecifiedTypeColumnInFilterFallsBackToUntypedIdentifier() { + // No canonical type stubbed -> Mockito returns null, transformer must fall back to untyped. + PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); + when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "legacyCol")) + .thenReturn(Optional.of(columnMeta)); + + Filter legacyFilter = new Filter(Filter.Op.EQ, "legacyCol", "x"); + org.hypertrace.core.documentstore.Query legacyQuery = + new org.hypertrace.core.documentstore.Query().withFilter(legacyFilter); + + Query expected = + Query.builder() + .setFilter( + RelationalExpression.of( + IdentifierExpression.of("legacyCol"), EQ, ConstantExpression.of("x"))) + .build(); + assertEquals(expected, transformer.transform(legacyQuery)); + } + + @Test + void jsonColumnDirectlySelectedDoesNotGetWrappedAsTypedScalar() { + // JSON columns are handled separately via JsonIdentifierExpression elsewhere; the factory + // must NOT promote them to typed scalar IdentifierExpression. + PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); + when(columnMeta.getCanonicalType()).thenReturn(DataType.JSON); + when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "props")) + .thenReturn(Optional.of(columnMeta)); + + Filter legacyFilter = new Filter(Filter.Op.EQ, "props", "x"); + org.hypertrace.core.documentstore.Query legacyQuery = + new org.hypertrace.core.documentstore.Query().withFilter(legacyFilter); + + Query expected = + Query.builder() + .setFilter( + RelationalExpression.of( + IdentifierExpression.of("props"), EQ, ConstantExpression.of("x"))) + .build(); + assertEquals(expected, transformer.transform(legacyQuery)); + } - assertEquals(2, result.getSelections().size()); - assertTrue(result.getFilter().isPresent()); - assertEquals(1, result.getSorts().size()); - assertTrue(result.getPagination().isPresent()); - assertEquals(50, result.getPagination().get().getLimit()); - assertEquals(10, result.getPagination().get().getOffset()); + @Test + void textArrayColumnInSelectionBuildsArrayIdentifierExpression() { + // Verifies the LegacyQueryToV2QueryTransformer.createIdentifierExpression path used for + // selections/orderBy (separate code site from the filter path above). + PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); + when(columnMeta.getCanonicalType()).thenReturn(DataType.STRING); + when(columnMeta.isArray()).thenReturn(true); + when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "labels")) + .thenReturn(Optional.of(columnMeta)); + + org.hypertrace.core.documentstore.Query legacyQuery = + new org.hypertrace.core.documentstore.Query().withSelection("labels"); + + Query expected = + Query.builder().addSelection(ArrayIdentifierExpression.ofStrings("labels")).build(); + assertEquals(expected, transformer.transform(legacyQuery)); + } + + private Query transformLeafFilter(String columnName, DataType canonicalType, boolean isArray) { + PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); + when(columnMeta.getCanonicalType()).thenReturn(canonicalType); + when(columnMeta.isArray()).thenReturn(isArray); + when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, columnName)) + .thenReturn(Optional.of(columnMeta)); + + Filter legacyFilter = new Filter(Filter.Op.EQ, columnName, "any"); + org.hypertrace.core.documentstore.Query legacyQuery = + new org.hypertrace.core.documentstore.Query().withFilter(legacyFilter); + + return transformer.transform(legacyQuery); } + + /** + * Builds the expected v2 Query for a single-leaf {@code EQ "any"} filter -- the shape + * produced by {@link #transformLeafFilter}. + */ + private Query leafEqFilterQuery( + org.hypertrace.core.documentstore.expression.type.SelectTypeExpression lhs) { + return Query.builder() + .setFilter(RelationalExpression.of(lhs, EQ, ConstantExpression.of("any"))) + .build(); + } + } + + @Test + void inferJsonFieldTypeListOfStringsYieldsStringNotStringArray() { + PostgresColumnMetadata propsMeta = mock(PostgresColumnMetadata.class); + when(propsMeta.getCanonicalType()).thenReturn(DataType.JSON); + when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "props.brand")).thenReturn(Optional.empty()); + when(schemaRegistry.getColumnOrRefresh(TABLE_NAME, "props")).thenReturn(Optional.of(propsMeta)); + + org.hypertrace.core.documentstore.Query legacyQuery = + new org.hypertrace.core.documentstore.Query() + .withFilter(new Filter(Filter.Op.IN, "props.brand", List.of("Dettol", "Lifebuoy"))); + + Query expected = + Query.builder() + .setFilter( + RelationalExpression.of( + // STRING (not STRING_ARRAY) -- documents the legacy-API gap. + JsonIdentifierExpression.of("props", JsonFieldType.STRING, "brand"), + IN, + ConstantExpression.ofStrings(List.of("Dettol", "Lifebuoy")))) + .build(); + assertEquals(expected, transformer.transform(legacyQuery)); } }