From bf88af0c30d6f1bcf36a0a2a1348f27d9618ae6f Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Tue, 19 May 2026 10:42:49 +0530 Subject: [PATCH 1/7] Create IdentifierExpressionFactory for use in LegacyFilterToQueryFilterTransformer --- .../IdentifierExpressionFactory.java | 100 ++++++++++++ .../LegacyFilterToQueryFilterTransformer.java | 13 +- .../LegacyQueryToV2QueryTransformer.java | 13 +- .../LegacyQueryToV2QueryTransformerTest.java | 144 ++++++++++++++++++ 4 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/IdentifierExpressionFactory.java 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 000000000..c55f7bc07 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/IdentifierExpressionFactory.java @@ -0,0 +1,100 @@ +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; + +/** + * Builds typed {@link IdentifierExpression}s (or {@link ArrayIdentifierExpression}s for array + * columns) from {@link ColumnMetadata}. Used by the legacy-query transformers so that downstream + * Postgres parsers can emit array-aware SQL for typed and array-typed columns: + * + * + * + *

Falls back to an untyped {@code IdentifierExpression} when type information is missing, + * UNSPECIFIED, or {@code JSON} (JSON columns are handled separately via {@code + * JsonIdentifierExpression} paths), preserving backward-compatible behavior for callers that have + * not yet wired column type metadata into their {@code SchemaRegistry}. + */ +final class IdentifierExpressionFactory { + + private IdentifierExpressionFactory() {} + + /** + * Creates a typed identifier expression for a directly-resolved column. + * + * @param name the column name to use as the identifier + * @param column the column metadata describing the column's canonical type and array-ness; if + * {@code null} or carrying no usable type information, returns an untyped identifier + * @return an {@link ArrayIdentifierExpression} for array columns with a known element type, a + * typed {@link IdentifierExpression} for scalar columns with a known type, or an untyped + * {@link IdentifierExpression} otherwise + */ + static IdentifierExpression createIdentifierFromColumn( + final String name, final ColumnMetadata column) { + if (column == null) { + return IdentifierExpression.of(name); + } + final DataType type = column.getCanonicalType(); + // JSON columns are accessed via JsonIdentifierExpression paths elsewhere; do not wrap them as + // typed scalar identifiers here. + 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 267f900b4..90871993f 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: * *

*/ 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 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 92002e47c..b4dbcf63d 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: * *

* @@ -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 aa27b52ff..1df9d71af 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,6 +1,7 @@ package org.hypertrace.core.documentstore.postgres.query.v1.transformer; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -13,9 +14,11 @@ 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.DataType; import org.hypertrace.core.documentstore.expression.impl.IdentifierExpression; import org.hypertrace.core.documentstore.expression.impl.JsonIdentifierExpression; +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; import org.hypertrace.core.documentstore.expression.operators.SortOrder; import org.hypertrace.core.documentstore.postgres.model.PostgresColumnMetadata; import org.hypertrace.core.documentstore.query.Query; @@ -309,4 +312,145 @@ void transformCompleteQuery_allComponentsTransformed() { assertEquals(10, result.getPagination().get().getOffset()); } } + + /** + * 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 textArrayColumn_inFilter_buildsArrayIdentifierWithStringElementType() { + RelationalExpression rel = transformLeafFilter("labels", DataType.STRING, true); + + assertTrue( + rel.getLhs() instanceof ArrayIdentifierExpression, + "text[] column should be wrapped as ArrayIdentifierExpression"); + ArrayIdentifierExpression arr = (ArrayIdentifierExpression) rel.getLhs(); + assertEquals("labels", arr.getName()); + assertEquals(DataType.STRING, arr.getElementDataType()); + } + + @Test + void bigintArrayColumn_inFilter_buildsArrayIdentifierWithLongElementType() { + RelationalExpression rel = transformLeafFilter("sensitivity", DataType.LONG, true); + + ArrayIdentifierExpression arr = (ArrayIdentifierExpression) rel.getLhs(); + assertEquals(DataType.LONG, arr.getElementDataType()); + } + + @Test + void doublePrecisionArrayColumn_inFilter_buildsArrayIdentifierWithDoubleElementType() { + RelationalExpression rel = transformLeafFilter("riskScores", DataType.DOUBLE, true); + + ArrayIdentifierExpression arr = (ArrayIdentifierExpression) rel.getLhs(); + assertEquals(DataType.DOUBLE, arr.getElementDataType()); + } + + @Test + void scalarTextColumn_inFilter_buildsTypedScalarIdentifier() { + RelationalExpression rel = transformLeafFilter("status", DataType.STRING, false); + + assertTrue(rel.getLhs() instanceof IdentifierExpression); + // Scalar typed identifier is NOT an ArrayIdentifierExpression. + assertFalse(rel.getLhs() instanceof ArrayIdentifierExpression); + IdentifierExpression id = (IdentifierExpression) rel.getLhs(); + assertEquals("status", id.getName()); + assertEquals(DataType.STRING, id.getDataType()); + } + + @Test + void scalarBigintColumn_inFilter_buildsTypedScalarIdentifierWithLongType() { + RelationalExpression rel = transformLeafFilter("statusCode", DataType.LONG, false); + + IdentifierExpression id = (IdentifierExpression) rel.getLhs(); + assertEquals(DataType.LONG, id.getDataType()); + } + + @Test + void unspecifiedTypeColumn_inFilter_fallsBackToUntypedIdentifier() { + // 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 result = transformer.transform(legacyQuery); + + RelationalExpression rel = (RelationalExpression) result.getFilter().orElseThrow(); + assertTrue(rel.getLhs() instanceof IdentifierExpression); + assertFalse(rel.getLhs() instanceof ArrayIdentifierExpression); + assertEquals(DataType.UNSPECIFIED, ((IdentifierExpression) rel.getLhs()).getDataType()); + } + + @Test + void jsonColumn_directlySelected_doesNotGetWrappedAsTypedScalar() { + // 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 result = transformer.transform(legacyQuery); + + RelationalExpression rel = (RelationalExpression) result.getFilter().orElseThrow(); + assertEquals(DataType.UNSPECIFIED, ((IdentifierExpression) rel.getLhs()).getDataType()); + } + + @Test + void textArrayColumn_inSelection_buildsArrayIdentifierExpression() { + // 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 result = transformer.transform(legacyQuery); + + assertEquals(1, result.getSelections().size()); + SelectionSpec spec = result.getSelections().get(0); + assertTrue(spec.getExpression() instanceof ArrayIdentifierExpression); + ArrayIdentifierExpression arr = (ArrayIdentifierExpression) spec.getExpression(); + assertEquals(DataType.STRING, arr.getElementDataType()); + } + + /** + * Stubs a column with the given canonical type / array-ness, then runs a single-leaf EQ filter + * on it through the transformer and returns the resulting v2 {@link RelationalExpression}. + */ + private RelationalExpression 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); + + Query result = transformer.transform(legacyQuery); + return (RelationalExpression) result.getFilter().orElseThrow(); + } + } } From 03632a2127cc527fea2f0fac58ff381c9a8c5634 Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Tue, 19 May 2026 10:42:57 +0530 Subject: [PATCH 2/7] Add test cases --- .../documentstore/DocStoreQueryV1Test.java | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) 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 640cb7ccd..6f762bd55 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 @@ -6296,6 +6296,126 @@ void testSearchWithUnknownFieldFallback(String dataStoreName) { } }); } + + // The tests below exercise the legacy search() path against Postgres top-level array columns. + // Before the IdentifierExpressionFactory fix, the legacy v1 -> v2 transformer dropped column + // type information, so filters on text[] / integer[] / double precision[] columns rendered + // SQL that Postgres rejected at execution time (e.g. "tags IN (?)" with a text RHS against a + // text[] column). With the fix, the transformer now emits an ArrayIdentifierExpression with + // the correct element type, so the Postgres parsers generate array-aware SQL (&&, @>, =). + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testSearchWithInOnTextArrayColumn(String dataStoreName) { + Collection flatCollection = getFlatCollection(dataStoreName); + + 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, + "tags", + List.of("hygiene", "grooming"))); + + Iterator results = flatCollection.search(legacyQuery); + int count = 0; + while (results.hasNext()) { + results.next(); + count++; + } + // ids 1, 5, 8 (hygiene) + ids 6, 7 (grooming) = 5 docs whose tags array overlaps the filter + assertEquals(5, count); + } + + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testSearchWithInOnIntegerArrayColumn(String dataStoreName) { + Collection flatCollection = getFlatCollection(dataStoreName); + + 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, "numbers", List.of(1, 10))); + + Iterator results = flatCollection.search(legacyQuery); + int count = 0; + while (results.hasNext()) { + results.next(); + count++; + } + // numbers containing 1: ids 1, 4, 8; containing 10: ids 2, 3, 7, 8 + // -> union = ids 1, 2, 3, 4, 7, 8 + assertEquals(6, count); + } + + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testSearchWithInOnDoubleArrayColumn(String dataStoreName) { + Collection flatCollection = getFlatCollection(dataStoreName); + + 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, "scores", List.of(3.0, 5.0))); + + Iterator results = flatCollection.search(legacyQuery); + int count = 0; + while (results.hasNext()) { + results.next(); + count++; + } + // 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 + assertEquals(3, count); + } + + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testSearchWithEqOnTextArrayColumnReturnsArrayElementMembership(String dataStoreName) { + Collection flatCollection = getFlatCollection(dataStoreName); + + // Legacy EQ on a top-level array column with a scalar RHS resolves to array-element + // membership semantics (any tag == "hygiene") via the typed parsers wired up by the + // factory. Without the fix this produced "tags = ?" which Postgres rejects against text[]. + 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, "tags", "hygiene")); + + Iterator results = flatCollection.search(legacyQuery); + int count = 0; + while (results.hasNext()) { + results.next(); + count++; + } + // ids 1, 5, 8 all have "hygiene" in their tags array + assertEquals(3, count); + } + + @ParameterizedTest + @ArgumentsSource(PostgresProvider.class) + void testSearchWithScalarColumnsStillUseTypedScalarIdentifier(String dataStoreName) { + // Sanity check: the factory must NOT promote scalar columns to ArrayIdentifierExpression. + // Scalar EQ on an INTEGER column should continue to work after the fix. + Collection flatCollection = getFlatCollection(dataStoreName); + + 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, "price", 10)); + + Iterator results = flatCollection.search(legacyQuery); + int count = 0; + while (results.hasNext()) { + results.next(); + count++; + } + // ids 1 and 8 both have price = 10 + assertEquals(2, count); + } } @Nested From 5ceda3ec6f4637efa1db181d886a697678407037 Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Tue, 19 May 2026 10:43:47 +0530 Subject: [PATCH 3/7] Remove unnecessary comments --- .../IdentifierExpressionFactory.java | 28 ------------------- 1 file changed, 28 deletions(-) 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 index c55f7bc07..b9a8a5894 100644 --- 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 @@ -5,38 +5,10 @@ import org.hypertrace.core.documentstore.expression.impl.DataType; import org.hypertrace.core.documentstore.expression.impl.IdentifierExpression; -/** - * Builds typed {@link IdentifierExpression}s (or {@link ArrayIdentifierExpression}s for array - * columns) from {@link ColumnMetadata}. Used by the legacy-query transformers so that downstream - * Postgres parsers can emit array-aware SQL for typed and array-typed columns: - * - *

- * - *

Falls back to an untyped {@code IdentifierExpression} when type information is missing, - * UNSPECIFIED, or {@code JSON} (JSON columns are handled separately via {@code - * JsonIdentifierExpression} paths), preserving backward-compatible behavior for callers that have - * not yet wired column type metadata into their {@code SchemaRegistry}. - */ final class IdentifierExpressionFactory { private IdentifierExpressionFactory() {} - /** - * Creates a typed identifier expression for a directly-resolved column. - * - * @param name the column name to use as the identifier - * @param column the column metadata describing the column's canonical type and array-ness; if - * {@code null} or carrying no usable type information, returns an untyped identifier - * @return an {@link ArrayIdentifierExpression} for array columns with a known element type, a - * typed {@link IdentifierExpression} for scalar columns with a known type, or an untyped - * {@link IdentifierExpression} otherwise - */ static IdentifierExpression createIdentifierFromColumn( final String name, final ColumnMetadata column) { if (column == null) { From a036600075269b08e5e6e07e69c34b956f6360f2 Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Wed, 20 May 2026 11:44:54 +0530 Subject: [PATCH 4/7] Refactor tests --- .../documentstore/DocStoreQueryV1Test.java | 569 ++++++++---------- .../IdentifierExpressionFactory.java | 2 - .../LegacyFilterToQueryFilterTransformer.java | 2 +- 3 files changed, 267 insertions(+), 306 deletions(-) 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 6f762bd55..5c9f8930f 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,337 +5975,253 @@ private static Collection getFlatCollection(String dataStoreName) { @Nested class FlatCollectionLegacySearchMethod { - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testSearchWithNoFilter(String dataStoreName) { - 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(); - - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - assertEquals(10, count); // All 10 documents in flat collection - } - - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testSearchWithEqFilter(String dataStoreName) throws JsonProcessingException { - Collection flatCollection = getFlatCollection(dataStoreName); - - // Test legacy search() with EQ filter on scalar 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.EQ, "item", "Soap")); - - 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++; + /** 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()); } - assertEquals(3, count); // 3 Soap items (IDs 1, 5, 8) + return ids; } - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testSearchWithInFilter(String dataStoreName) { - Collection flatCollection = getFlatCollection(dataStoreName); - - // Test legacy search() with IN filter - 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"))); - - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - assertEquals(4, count); // 3 Soap + 1 Mirror = 4 + 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 testSearchWithNumericFilter(String dataStoreName) throws JsonProcessingException { + void testSearchWithNoFilter(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)); + new org.hypertrace.core.documentstore.Query(); + Query v2Query = Query.builder().build(); - 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); + // no filter, so should return all documents + List ids = assertLegacyV2Parity(flatCollection, legacyQuery, v2Query); + assertEquals(10, ids.size()); } @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithCompositeAndFilter(String dataStoreName) throws JsonProcessingException { + void testSearchCompleteQuery(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.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}); + new org.hypertrace.core.documentstore.Filter[] {itemFilter, priceFilter, quantityFilter}); - org.hypertrace.core.documentstore.Query legacyQuery = - new org.hypertrace.core.documentstore.Query().withFilter(compositeFilter); - - 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); - } - - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testSearchWithOrderBy(String dataStoreName) throws JsonProcessingException { - Collection flatCollection = getFlatCollection(dataStoreName); - - // Test legacy search() with ORDER BY + // 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() - .withOrderBy(new OrderBy("price", true)); // ASC - - 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); - } - - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testSearchWithPagination(String dataStoreName) { - Collection flatCollection = getFlatCollection(dataStoreName); + .withFilter(compositeFilter) + .withSelection("id") + .withSelection("item") + .withSelection("price") + .withOrderBy(new OrderBy("price", false)) // DESC + .withLimit(2) + .withOffset(1); - // Test legacy search() with LIMIT and OFFSET - org.hypertrace.core.documentstore.Query legacyQuery = - new org.hypertrace.core.documentstore.Query().withLimit(3).withOffset(2); + 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(); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - assertEquals(3, count); // Should return exactly 3 documents + // 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 testSearchWithSelections(String dataStoreName) throws JsonProcessingException { + void testSearchWithUnknownFieldFallback(String dataStoreName) { Collection flatCollection = getFlatCollection(dataStoreName); - // Test legacy search() with selections + // 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() - .withSelection("item") - .withSelection("price") - .withLimit(5); + .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()) { - 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() + .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 testSearchWithJsonbFilter(String dataStoreName) throws JsonProcessingException { + void testSearchWithInOnTextArrayColumn(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, + "tags", + List.of("hygiene", "grooming"))); - 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( + ArrayIdentifierExpression.ofStrings("tags"), + IN, + ConstantExpression.ofStrings(List.of("hygiene", "grooming")))) + .build(); + + // 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 testSearchCompleteQuery(String dataStoreName) throws JsonProcessingException { + void testSearchWithInOnIntegerArrayColumn(String dataStoreName) throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Test legacy search() with filter, orderBy, selections, and 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", false)) // DESC - .withLimit(5) - .withOffset(0); + org.hypertrace.core.documentstore.Filter.Op.IN, "numbers", List.of(1, 10))); - 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( + 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 testSearchWithFilterSelectionsOrderByAndOffset(String dataStoreName) - throws JsonProcessingException { + void testSearchWithInOnDoubleArrayColumn(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 - - 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); - } - - @ParameterizedTest - @ArgumentsSource(PostgresProvider.class) - void testSearchWithNullFilterEmptySelectionsNoOrderBy(String dataStoreName) { - Collection flatCollection = getFlatCollection(dataStoreName); + org.hypertrace.core.documentstore.Filter.Op.IN, "scores", List.of(3.0, 5.0))); - // 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) - org.hypertrace.core.documentstore.Query legacyQuery = - new org.hypertrace.core.documentstore.Query().withLimit(5); + Query v2Query = + Query.builder() + .setFilter( + RelationalExpression.of( + ArrayIdentifierExpression.ofDoubles("scores"), + IN, + ConstantExpression.ofNumbers(List.of(3.0, 5.0)))) + .build(); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - assertEquals(5, count); + // 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 testSearchWithUnknownFieldFallback(String dataStoreName) { + void testSearchWithEqOnTextArrayColumnReturnsArrayElementMembership(String dataStoreName) + throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); + // 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("nonexistent_field") - .withOrderBy(new OrderBy("another_unknown", true)) - .withLimit(1); + .withFilter( + new org.hypertrace.core.documentstore.Filter( + org.hypertrace.core.documentstore.Filter.Op.EQ, "tags", "hygiene")); - assertThrows( - Exception.class, - () -> { - Iterator results = flatCollection.search(legacyQuery); - while (results.hasNext()) { - results.next(); - } - }); + 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)); } - // The tests below exercise the legacy search() path against Postgres top-level array columns. - // Before the IdentifierExpressionFactory fix, the legacy v1 -> v2 transformer dropped column - // type information, so filters on text[] / integer[] / double precision[] columns rendered - // SQL that Postgres rejected at execution time (e.g. "tags IN (?)" with a text RHS against a - // text[] column). With the fix, the transformer now emits an ArrayIdentifierExpression with - // the correct element type, so the Postgres parsers generate array-aware SQL (&&, @>, =). @ParameterizedTest @ArgumentsSource(PostgresProvider.class) - void testSearchWithInOnTextArrayColumn(String dataStoreName) { + void testSearchWithInOnNestedJsonbStringField(String dataStoreName) + throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); org.hypertrace.core.documentstore.Query legacyQuery = @@ -6313,108 +6229,155 @@ void testSearchWithInOnTextArrayColumn(String dataStoreName) { .withFilter( new org.hypertrace.core.documentstore.Filter( org.hypertrace.core.documentstore.Filter.Op.IN, - "tags", - List.of("hygiene", "grooming"))); + "props.brand", + List.of("Dettol", "Lifebuoy"))); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - // ids 1, 5, 8 (hygiene) + ids 6, 7 (grooming) = 5 docs whose tags array overlaps the filter - assertEquals(5, count); + 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 testSearchWithInOnIntegerArrayColumn(String dataStoreName) { + void testSearchWithLikeOnNestedJsonbStringField(String dataStoreName) + throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); + // 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.IN, "numbers", List.of(1, 10))); + org.hypertrace.core.documentstore.Filter.Op.LIKE, "props.brand", "^Sun")); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - // numbers containing 1: ids 1, 4, 8; containing 10: ids 2, 3, 7, 8 - // -> union = ids 1, 2, 3, 4, 7, 8 - assertEquals(6, 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 testSearchWithInOnDoubleArrayColumn(String dataStoreName) { + void testSearchWithEqOnDeeplyNestedJsonbStringField(String dataStoreName) + throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); 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, "scores", List.of(3.0, 5.0))); + org.hypertrace.core.documentstore.Filter.Op.EQ, + "props.seller.address.city", + "Mumbai")); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - // 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 - 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 testSearchWithEqOnTextArrayColumnReturnsArrayElementMembership(String dataStoreName) { + void testSearchWithCompositeAndFilterIncludingNestedJsonbField(String dataStoreName) + throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); - // Legacy EQ on a top-level array column with a scalar RHS resolves to array-element - // membership semantics (any tag == "hygiene") via the typed parsers wired up by the - // factory. Without the fix this produced "tags = ?" which Postgres rejects against text[]. + // 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() - .withFilter( - new org.hypertrace.core.documentstore.Filter( - org.hypertrace.core.documentstore.Filter.Op.EQ, "tags", "hygiene")); + new org.hypertrace.core.documentstore.Query().withFilter(compositeFilter); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - // ids 1, 5, 8 all have "hygiene" in their tags array - assertEquals(3, 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 testSearchWithScalarColumnsStillUseTypedScalarIdentifier(String dataStoreName) { - // Sanity check: the factory must NOT promote scalar columns to ArrayIdentifierExpression. - // Scalar EQ on an INTEGER column should continue to work after the fix. + void testSearchSelectsNestedJsonbStringAndStringArrayFields(String dataStoreName) + throws JsonProcessingException { Collection flatCollection = getFlatCollection(dataStoreName); org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query() + .withSelection("id") + .withSelection("props.brand") + .withSelection("props.colors") .withFilter( new org.hypertrace.core.documentstore.Filter( - org.hypertrace.core.documentstore.Filter.Op.EQ, "price", 10)); + org.hypertrace.core.documentstore.Filter.Op.EQ, "props.brand", "Dettol")); - Iterator results = flatCollection.search(legacyQuery); - int count = 0; - while (results.hasNext()) { - results.next(); - count++; - } - // ids 1 and 8 both have price = 10 - assertEquals(2, count); + 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 index b9a8a5894..eb611fb8e 100644 --- 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 @@ -15,8 +15,6 @@ static IdentifierExpression createIdentifierFromColumn( return IdentifierExpression.of(name); } final DataType type = column.getCanonicalType(); - // JSON columns are accessed via JsonIdentifierExpression paths elsewhere; do not wrap them as - // typed scalar identifiers here. if (type == null || type == DataType.UNSPECIFIED || type == DataType.JSON) { return IdentifierExpression.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 90871993f..023260e7c 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 @@ -137,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); } From cc6f80ab55a2a6bdf52dcb499280912feb2f64f5 Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Wed, 20 May 2026 18:56:55 +0530 Subject: [PATCH 5/7] Refactor --- .../LegacyQueryToV2QueryTransformerTest.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 1df9d71af..59c33709c 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 @@ -326,7 +326,7 @@ void transformCompleteQuery_allComponentsTransformed() { class TypedAndArrayColumnIdentifiers { @Test - void textArrayColumn_inFilter_buildsArrayIdentifierWithStringElementType() { + void textArrayColumnInFilterBuildsArrayIdentifierWithStringElementType() { RelationalExpression rel = transformLeafFilter("labels", DataType.STRING, true); assertTrue( @@ -338,7 +338,7 @@ void textArrayColumn_inFilter_buildsArrayIdentifierWithStringElementType() { } @Test - void bigintArrayColumn_inFilter_buildsArrayIdentifierWithLongElementType() { + void bigintArrayColumnInFilterBuildsArrayIdentifierWithLongElementType() { RelationalExpression rel = transformLeafFilter("sensitivity", DataType.LONG, true); ArrayIdentifierExpression arr = (ArrayIdentifierExpression) rel.getLhs(); @@ -346,7 +346,7 @@ void bigintArrayColumn_inFilter_buildsArrayIdentifierWithLongElementType() { } @Test - void doublePrecisionArrayColumn_inFilter_buildsArrayIdentifierWithDoubleElementType() { + void doublePrecisionArrayColumnInFilterBuildsArrayIdentifierWithDoubleElementType() { RelationalExpression rel = transformLeafFilter("riskScores", DataType.DOUBLE, true); ArrayIdentifierExpression arr = (ArrayIdentifierExpression) rel.getLhs(); @@ -354,7 +354,7 @@ void doublePrecisionArrayColumn_inFilter_buildsArrayIdentifierWithDoubleElementT } @Test - void scalarTextColumn_inFilter_buildsTypedScalarIdentifier() { + void scalarTextColumnInFilterBuildsTypedScalarIdentifier() { RelationalExpression rel = transformLeafFilter("status", DataType.STRING, false); assertTrue(rel.getLhs() instanceof IdentifierExpression); @@ -366,7 +366,7 @@ void scalarTextColumn_inFilter_buildsTypedScalarIdentifier() { } @Test - void scalarBigintColumn_inFilter_buildsTypedScalarIdentifierWithLongType() { + void scalarBigintColumnInFilterBuildsTypedScalarIdentifierWithLongType() { RelationalExpression rel = transformLeafFilter("statusCode", DataType.LONG, false); IdentifierExpression id = (IdentifierExpression) rel.getLhs(); @@ -374,7 +374,7 @@ void scalarBigintColumn_inFilter_buildsTypedScalarIdentifierWithLongType() { } @Test - void unspecifiedTypeColumn_inFilter_fallsBackToUntypedIdentifier() { + 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")) @@ -393,7 +393,7 @@ void unspecifiedTypeColumn_inFilter_fallsBackToUntypedIdentifier() { } @Test - void jsonColumn_directlySelected_doesNotGetWrappedAsTypedScalar() { + 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); @@ -412,7 +412,7 @@ void jsonColumn_directlySelected_doesNotGetWrappedAsTypedScalar() { } @Test - void textArrayColumn_inSelection_buildsArrayIdentifierExpression() { + void textArrayColumnInSelectionBuildsArrayIdentifierExpression() { // Verifies the LegacyQueryToV2QueryTransformer.createIdentifierExpression path used for // selections/orderBy (separate code site from the filter path above). PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); From 8738bd98136875cecb85a11bea0fb4f3a5733b04 Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Wed, 20 May 2026 19:12:40 +0530 Subject: [PATCH 6/7] Change tests --- .../LegacyQueryToV2QueryTransformerTest.java | 288 ++++++++---------- 1 file changed, 132 insertions(+), 156 deletions(-) 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 59c33709c..d00167aad 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,10 +1,11 @@ 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.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.assertFalse; -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; @@ -15,15 +16,16 @@ 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.impl.LogicalExpression; import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; -import org.hypertrace.core.documentstore.expression.operators.SortOrder; 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; @@ -44,28 +46,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())); } } @@ -73,7 +62,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)); @@ -81,16 +71,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")) @@ -101,19 +89,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)); @@ -123,13 +108,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); @@ -142,7 +130,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)); @@ -150,16 +138,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)); @@ -168,15 +152,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")) @@ -188,11 +169,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)); } } @@ -200,47 +181,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)); } } @@ -248,7 +223,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)); @@ -257,13 +232,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)); @@ -277,9 +256,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)); } } @@ -287,7 +273,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)); @@ -302,14 +288,17 @@ void transformCompleteQuery_allComponentsTransformed() { .withLimit(50) .withOffset(10); - Query result = 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()); + 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)); } } @@ -327,50 +316,32 @@ class TypedAndArrayColumnIdentifiers { @Test void textArrayColumnInFilterBuildsArrayIdentifierWithStringElementType() { - RelationalExpression rel = transformLeafFilter("labels", DataType.STRING, true); - - assertTrue( - rel.getLhs() instanceof ArrayIdentifierExpression, - "text[] column should be wrapped as ArrayIdentifierExpression"); - ArrayIdentifierExpression arr = (ArrayIdentifierExpression) rel.getLhs(); - assertEquals("labels", arr.getName()); - assertEquals(DataType.STRING, arr.getElementDataType()); + Query expected = leafEqFilterQuery(ArrayIdentifierExpression.ofStrings("labels")); + assertEquals(expected, transformLeafFilter("labels", DataType.STRING, true)); } @Test void bigintArrayColumnInFilterBuildsArrayIdentifierWithLongElementType() { - RelationalExpression rel = transformLeafFilter("sensitivity", DataType.LONG, true); - - ArrayIdentifierExpression arr = (ArrayIdentifierExpression) rel.getLhs(); - assertEquals(DataType.LONG, arr.getElementDataType()); + Query expected = leafEqFilterQuery(ArrayIdentifierExpression.ofLongs("sensitivity")); + assertEquals(expected, transformLeafFilter("sensitivity", DataType.LONG, true)); } @Test void doublePrecisionArrayColumnInFilterBuildsArrayIdentifierWithDoubleElementType() { - RelationalExpression rel = transformLeafFilter("riskScores", DataType.DOUBLE, true); - - ArrayIdentifierExpression arr = (ArrayIdentifierExpression) rel.getLhs(); - assertEquals(DataType.DOUBLE, arr.getElementDataType()); + Query expected = leafEqFilterQuery(ArrayIdentifierExpression.ofDoubles("riskScores")); + assertEquals(expected, transformLeafFilter("riskScores", DataType.DOUBLE, true)); } @Test void scalarTextColumnInFilterBuildsTypedScalarIdentifier() { - RelationalExpression rel = transformLeafFilter("status", DataType.STRING, false); - - assertTrue(rel.getLhs() instanceof IdentifierExpression); - // Scalar typed identifier is NOT an ArrayIdentifierExpression. - assertFalse(rel.getLhs() instanceof ArrayIdentifierExpression); - IdentifierExpression id = (IdentifierExpression) rel.getLhs(); - assertEquals("status", id.getName()); - assertEquals(DataType.STRING, id.getDataType()); + Query expected = leafEqFilterQuery(IdentifierExpression.ofString("status")); + assertEquals(expected, transformLeafFilter("status", DataType.STRING, false)); } @Test void scalarBigintColumnInFilterBuildsTypedScalarIdentifierWithLongType() { - RelationalExpression rel = transformLeafFilter("statusCode", DataType.LONG, false); - - IdentifierExpression id = (IdentifierExpression) rel.getLhs(); - assertEquals(DataType.LONG, id.getDataType()); + Query expected = leafEqFilterQuery(IdentifierExpression.ofLong("statusCode")); + assertEquals(expected, transformLeafFilter("statusCode", DataType.LONG, false)); } @Test @@ -384,12 +355,13 @@ void unspecifiedTypeColumnInFilterFallsBackToUntypedIdentifier() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withFilter(legacyFilter); - Query result = transformer.transform(legacyQuery); - - RelationalExpression rel = (RelationalExpression) result.getFilter().orElseThrow(); - assertTrue(rel.getLhs() instanceof IdentifierExpression); - assertFalse(rel.getLhs() instanceof ArrayIdentifierExpression); - assertEquals(DataType.UNSPECIFIED, ((IdentifierExpression) rel.getLhs()).getDataType()); + Query expected = + Query.builder() + .setFilter( + RelationalExpression.of( + IdentifierExpression.of("legacyCol"), EQ, ConstantExpression.of("x"))) + .build(); + assertEquals(expected, transformer.transform(legacyQuery)); } @Test @@ -405,10 +377,13 @@ void jsonColumnDirectlySelectedDoesNotGetWrappedAsTypedScalar() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withFilter(legacyFilter); - Query result = transformer.transform(legacyQuery); - - RelationalExpression rel = (RelationalExpression) result.getFilter().orElseThrow(); - assertEquals(DataType.UNSPECIFIED, ((IdentifierExpression) rel.getLhs()).getDataType()); + Query expected = + Query.builder() + .setFilter( + RelationalExpression.of( + IdentifierExpression.of("props"), EQ, ConstantExpression.of("x"))) + .build(); + assertEquals(expected, transformer.transform(legacyQuery)); } @Test @@ -424,21 +399,12 @@ void textArrayColumnInSelectionBuildsArrayIdentifierExpression() { org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withSelection("labels"); - Query result = transformer.transform(legacyQuery); - - assertEquals(1, result.getSelections().size()); - SelectionSpec spec = result.getSelections().get(0); - assertTrue(spec.getExpression() instanceof ArrayIdentifierExpression); - ArrayIdentifierExpression arr = (ArrayIdentifierExpression) spec.getExpression(); - assertEquals(DataType.STRING, arr.getElementDataType()); + Query expected = + Query.builder().addSelection(ArrayIdentifierExpression.ofStrings("labels")).build(); + assertEquals(expected, transformer.transform(legacyQuery)); } - /** - * Stubs a column with the given canonical type / array-ness, then runs a single-leaf EQ filter - * on it through the transformer and returns the resulting v2 {@link RelationalExpression}. - */ - private RelationalExpression transformLeafFilter( - String columnName, DataType canonicalType, boolean isArray) { + private Query transformLeafFilter(String columnName, DataType canonicalType, boolean isArray) { PostgresColumnMetadata columnMeta = mock(PostgresColumnMetadata.class); when(columnMeta.getCanonicalType()).thenReturn(canonicalType); when(columnMeta.isArray()).thenReturn(isArray); @@ -449,8 +415,18 @@ private RelationalExpression transformLeafFilter( org.hypertrace.core.documentstore.Query legacyQuery = new org.hypertrace.core.documentstore.Query().withFilter(legacyFilter); - Query result = transformer.transform(legacyQuery); - return (RelationalExpression) result.getFilter().orElseThrow(); + 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(); } } } From 38ed2e80deffff2d8ad13a4ffa908599a9ad16d2 Mon Sep 17 00:00:00 2001 From: Prashant Pandey Date: Wed, 20 May 2026 19:20:04 +0530 Subject: [PATCH 7/7] Refactor --- .../LegacyQueryToV2QueryTransformerTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 d00167aad..af97c186c 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 @@ -2,6 +2,7 @@ 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; @@ -11,6 +12,7 @@ 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; @@ -429,4 +431,27 @@ private Query leafEqFilterQuery( .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)); + } }