diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresCollection.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresCollection.java index 5582889a..6d3b1284 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresCollection.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresCollection.java @@ -104,20 +104,50 @@ public class PostgresCollection implements Collection { private final PostgresSubDocumentUpdater subDocUpdater; private final PostgresQueryExecutor queryExecutor; private final UpdateValidator updateValidator; + private final PostgresColumnRegistry columnRegistry; public PostgresCollection(final PostgresClient client, final String collectionName) { - this(client, PostgresTableIdentifier.parse(collectionName)); + this( + client, + PostgresTableIdentifier.parse(collectionName), + new PostgresColumnRegistryFallback()); + } + + public PostgresCollection( + final PostgresClient client, + final String collectionName, + final PostgresColumnRegistry columnRegistry) { + this(client, PostgresTableIdentifier.parse(collectionName), columnRegistry); } PostgresCollection(final PostgresClient client, final PostgresTableIdentifier tableIdentifier) { + this(client, tableIdentifier, new PostgresColumnRegistryFallback()); + } + + PostgresCollection( + final PostgresClient client, + final PostgresTableIdentifier tableIdentifier, + final PostgresColumnRegistry columnRegistry) { this.client = client; this.tableIdentifier = tableIdentifier; + this.columnRegistry = + columnRegistry != null ? columnRegistry : new PostgresColumnRegistryFallback(); this.subDocUpdater = - new PostgresSubDocumentUpdater(new PostgresQueryBuilder(this.tableIdentifier)); + new PostgresSubDocumentUpdater( + new PostgresQueryBuilder(this.tableIdentifier, this.columnRegistry)); this.queryExecutor = new PostgresQueryExecutor(this.tableIdentifier); this.updateValidator = new CommonUpdateValidator(); } + /** + * Gets the column registry for this collection. + * + * @return the PostgresColumnRegistry instance + */ + public PostgresColumnRegistry getColumnRegistry() { + return columnRegistry; + } + @Override public boolean upsert(Key key, Document document) throws IOException { try (PreparedStatement preparedStatement = @@ -488,7 +518,7 @@ private CloseableIterator search(Query query, boolean removeDocumentId @Override public CloseableIterator find( final org.hypertrace.core.documentstore.query.Query query) { - return queryExecutor.execute(client.getConnection(), query); + return queryExecutor.execute(client.getConnection(), query, null, columnRegistry); } @Override @@ -496,7 +526,8 @@ public CloseableIterator query( final org.hypertrace.core.documentstore.query.Query query, final QueryOptions queryOptions) { String flatStructureCollectionName = client.getCustomParameters().get(FLAT_STRUCTURE_COLLECTION_KEY); - return queryExecutor.execute(client.getConnection(), query, flatStructureCollectionName); + return queryExecutor.execute( + client.getConnection(), query, flatStructureCollectionName, columnRegistry); } @Override @@ -545,7 +576,7 @@ public Optional update( .build(); try (final CloseableIterator iterator = - queryExecutor.execute(connection, findByIdQuery)) { + queryExecutor.execute(connection, findByIdQuery, null, columnRegistry)) { returnDocument = getFirstDocument(iterator).orElseThrow(); } } else if (updateOptions.getReturnDocumentType() == NONE) { diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresColumnRegistry.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresColumnRegistry.java new file mode 100644 index 00000000..ca635fcf --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresColumnRegistry.java @@ -0,0 +1,47 @@ +package org.hypertrace.core.documentstore.postgres; + +import java.util.Optional; +import java.util.Set; + +/** + * Registry for PostgreSQL column metadata that provides type-aware query generation support. + * + *

This interface replaces hardcoded column checks with dynamic database metadata lookups, + * enabling support for typed columns (String, Long, Double, Boolean, TextArray) while maintaining + * JSONB fallback for complex types. + */ +public interface PostgresColumnRegistry { + + /** + * Determines if a field should be treated as a first-class typed column rather than a JSONB + * document field. + * + * @param fieldName the field name to check + * @return true if the field has a first-class column mapping, false if it should use JSONB + * processing + */ + boolean isFirstClassColumn(String fieldName); + + /** + * Gets the PostgreSQL data type for a field. + * + * @param fieldName the field name to look up + * @return Optional containing the data type if mapped, empty if not found + */ + Optional getColumnDataType(String fieldName); + + /** + * Gets all field names that have first-class column mappings. + * + * @return set of all first-class column field names + */ + Set getAllFirstClassColumns(); + + /** + * Checks if the registry supports a specific PostgreSQL data type. + * + * @param dataType the data type to check + * @return true if the data type is supported for first-class column processing + */ + boolean supportsDataType(PostgresDataType dataType); +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresColumnRegistryFallback.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresColumnRegistryFallback.java new file mode 100644 index 00000000..f539a34d --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresColumnRegistryFallback.java @@ -0,0 +1,56 @@ +package org.hypertrace.core.documentstore.postgres; + +import java.util.Optional; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.hypertrace.core.documentstore.postgres.utils.PostgresUtils; + +/** + * Fallback implementation of PostgresColumnRegistry that maintains original hardcoded behavior when + * database metadata queries fail. + * + *

This ensures system continues to function even if registry creation encounters errors, falling + * back to the original OUTER_COLUMNS approach. + */ +@Slf4j +public class PostgresColumnRegistryFallback implements PostgresColumnRegistry { + + public PostgresColumnRegistryFallback() { + log.debug("Created PostgresColumnRegistryFallback using hardcoded OUTER_COLUMNS"); + } + + @Override + public boolean isFirstClassColumn(String fieldName) { + // Fall back to original hardcoded behavior + return fieldName != null && PostgresUtils.OUTER_COLUMNS.contains(fieldName); + } + + @Override + public Optional getColumnDataType(String fieldName) { + // Map the hardcoded columns to their known types + if (fieldName == null) { + return Optional.empty(); + } + + switch (fieldName) { + case "id": + return Optional.of(PostgresDataType.TEXT); + case "created_at": + case "updated_at": + // These are timestamp types, but we'll map them as first-class for compatibility + return Optional.of(PostgresDataType.TEXT); // Can be enhanced to support timestamp later + default: + return Optional.empty(); + } + } + + @Override + public Set getAllFirstClassColumns() { + return Set.copyOf(PostgresUtils.OUTER_COLUMNS); + } + + @Override + public boolean supportsDataType(PostgresDataType dataType) { + return dataType != null && dataType.isFirstClassType(); + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresColumnRegistryImpl.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresColumnRegistryImpl.java new file mode 100644 index 00000000..6761c2c8 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresColumnRegistryImpl.java @@ -0,0 +1,123 @@ +package org.hypertrace.core.documentstore.postgres; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.hypertrace.core.documentstore.postgres.utils.PostgresUtils; + +/** + * Implementation of PostgresColumnRegistry that queries database metadata to build dynamic + * field-to-datatype mappings. + * + *

This implementation replaces hardcoded OUTER_COLUMNS with dynamic database metadata discovery, + * enabling support for typed columns while maintaining JSONB fallback compatibility. + */ +@Slf4j +public class PostgresColumnRegistryImpl implements PostgresColumnRegistry { + + private final Map columnTypes; + private final String tableName; + + /** + * Creates a new registry by querying database metadata for the specified table. + * + * @param connection database connection for metadata queries + * @param tableName the table name to analyze + */ + public PostgresColumnRegistryImpl(Connection connection, String tableName) { + this.tableName = tableName; + this.columnTypes = buildColumnMappings(connection, tableName); + + log.debug( + "Created PostgresColumnRegistry for table '{}' with {} mapped columns: {}", + tableName, + columnTypes.size(), + columnTypes.keySet()); + } + + @Override + public boolean isFirstClassColumn(String fieldName) { + if (fieldName == null) { + return false; + } + + // Check dynamic registry mappings first + if (columnTypes.containsKey(fieldName)) { + PostgresDataType type = columnTypes.get(fieldName); + return type.isFirstClassType(); + } + + // Fallback to original hardcoded columns for compatibility + // This ensures existing functionality continues to work during migration + return PostgresUtils.OUTER_COLUMNS.contains(fieldName); + } + + @Override + public Optional getColumnDataType(String fieldName) { + return Optional.ofNullable(columnTypes.get(fieldName)); + } + + @Override + public Set getAllFirstClassColumns() { + return columnTypes.entrySet().stream() + .filter(entry -> entry.getValue().isFirstClassType()) + .map(Map.Entry::getKey) + .collect(java.util.stream.Collectors.toSet()); + } + + @Override + public boolean supportsDataType(PostgresDataType dataType) { + return dataType != null && dataType.isFirstClassType(); + } + + /** + * Queries database metadata to build field-to-datatype mappings. + * + * @param connection database connection + * @param table table name to analyze + * @return map of field names to PostgreSQL data types + */ + private Map buildColumnMappings(Connection connection, String table) { + Map mappings = new HashMap<>(); + + String sql = + "SELECT column_name, data_type, udt_name " + + "FROM information_schema.columns " + + "WHERE table_name = ? " + + "AND table_schema = current_schema() " + + "ORDER BY ordinal_position"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, table); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String columnName = rs.getString("column_name"); + String dataType = rs.getString("data_type"); + String udtName = rs.getString("udt_name"); + + // Use udt_name for array types (e.g., "_text"), data_type for others + String typeToMap = "ARRAY".equals(dataType) ? udtName : dataType; + PostgresDataType postgresType = PostgresDataType.fromPostgresTypeName(typeToMap); + + mappings.put(columnName, postgresType); + + log.debug("Mapped column '{}' with type '{}' -> {}", columnName, typeToMap, postgresType); + } + } + + } catch (SQLException e) { + log.warn("Failed to query column metadata for table '{}': {}", table, e.getMessage()); + log.debug("SQLException details", e); + // Return empty map on error - will fall back to JSONB processing + } + + return mappings; + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresDataType.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresDataType.java new file mode 100644 index 00000000..74da9f7e --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresDataType.java @@ -0,0 +1,75 @@ +package org.hypertrace.core.documentstore.postgres; + +import java.util.Arrays; +import java.util.Set; + +/** + * Enumeration of PostgreSQL data types supported for first-class column processing. + * + *

Maps PostgreSQL native type names to our supported data types, enabling dynamic type-based + * query generation and parser selection. + */ +public enum PostgresDataType { + + /** Text/string types - maps to String in Java */ + TEXT("text", "varchar"), + + /** Big integer type - maps to Long in Java */ + BIGINT("bigint", "int8"), + + /** Double precision floating point - maps to Double in Java */ + DOUBLE_PRECISION("double precision", "float8"), + + /** Boolean type - maps to Boolean in Java */ + BOOLEAN("boolean", "bool"), + + /** Text array type - maps to TextArray/String[] in Java */ + TEXT_ARRAY("_text"), + + /** Additional integer type for future support */ + INTEGER("integer", "int4"), + + /** JSONB type - fallback for complex types (excluded from first-class processing) */ + JSONB("jsonb"); + + private final Set postgresTypeNames; + + PostgresDataType(String... typeNames) { + this.postgresTypeNames = Set.of(typeNames); + } + + /** + * Maps a PostgreSQL type name to our enum value. + * + * @param postgresTypeName the PostgreSQL type name from database metadata + * @return the corresponding enum value, or JSONB as fallback + */ + public static PostgresDataType fromPostgresTypeName(String postgresTypeName) { + if (postgresTypeName == null) { + return JSONB; + } + + return Arrays.stream(values()) + .filter(type -> type.postgresTypeNames.contains(postgresTypeName.toLowerCase())) + .findFirst() + .orElse(JSONB); + } + + /** + * Checks if this data type should be treated as a first-class column. + * + * @return true if this type supports first-class column processing + */ + public boolean isFirstClassType() { + return this != JSONB; + } + + /** + * Gets the PostgreSQL type names that map to this enum value. + * + * @return set of PostgreSQL type names + */ + public Set getPostgresTypeNames() { + return postgresTypeNames; + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresDatastore.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresDatastore.java index 84678ca7..c96c6da0 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresDatastore.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresDatastore.java @@ -15,6 +15,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.hypertrace.core.documentstore.Collection; @@ -36,6 +37,7 @@ public class PostgresDatastore implements Datastore { private final PostgresClient client; private final String database; private final DocStoreMetricProvider docStoreMetricProvider; + private final Map registryCache = new ConcurrentHashMap<>(); public PostgresDatastore(@NonNull final DatastoreConfig datastoreConfig) { final ConnectionConfig connectionConfig = datastoreConfig.connectionConfig(); @@ -148,7 +150,25 @@ public Collection getCollection(String collectionName) { if (!tables.contains(collectionName)) { createCollection(collectionName, null); } - return new PostgresCollection(client, collectionName); + + // Create or retrieve cached registry for this collection + PostgresColumnRegistry registry = + registryCache.computeIfAbsent( + collectionName, + name -> { + try { + return new PostgresColumnRegistryImpl(client.getConnection(), name); + } catch (Exception e) { + log.warn( + "Failed to create column registry for collection '{}': {}. Using fallback registry.", + name, + e.getMessage()); + // Return a fallback registry that behaves like the original hardcoded approach + return new PostgresColumnRegistryFallback(); + } + }); + + return new PostgresCollection(client, collectionName, registry); } @Override diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresQueryBuilder.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresQueryBuilder.java index 9edb5ab3..ba25e212 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresQueryBuilder.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresQueryBuilder.java @@ -16,7 +16,6 @@ import java.util.Map; import java.util.Stack; import lombok.Getter; -import lombok.RequiredArgsConstructor; import org.hypertrace.core.documentstore.model.subdoc.SubDocumentUpdate; import org.hypertrace.core.documentstore.model.subdoc.UpdateOperator; import org.hypertrace.core.documentstore.postgres.Params.Builder; @@ -31,7 +30,6 @@ import org.hypertrace.core.documentstore.postgres.update.parser.PostgresUpdateOperationParser.UpdateParserInput; import org.hypertrace.core.documentstore.query.Query; -@RequiredArgsConstructor public class PostgresQueryBuilder { private static final Map UPDATE_PARSER_MAP = @@ -44,12 +42,24 @@ public class PostgresQueryBuilder { entry(APPEND_TO_LIST, new PostgresAppendToListParser())); @Getter private final PostgresTableIdentifier tableIdentifier; + @Getter private final PostgresColumnRegistry columnRegistry; + + public PostgresQueryBuilder( + PostgresTableIdentifier tableIdentifier, PostgresColumnRegistry columnRegistry) { + this.tableIdentifier = tableIdentifier; + this.columnRegistry = columnRegistry; + } + + public PostgresQueryBuilder(PostgresTableIdentifier tableIdentifier) { + this(tableIdentifier, null); + } public String getSubDocUpdateQuery( final Query query, final Collection updates, final Params.Builder paramBuilder) { - final PostgresQueryParser baseQueryParser = new PostgresQueryParser(tableIdentifier, query); + final PostgresQueryParser baseQueryParser = + new PostgresQueryParser(tableIdentifier, query, null, columnRegistry); String selectQuery = String.format( "(SELECT %s, %s FROM %s AS t0 %s)", diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresQueryExecutor.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresQueryExecutor.java index 389a52b8..c233d62a 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresQueryExecutor.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/PostgresQueryExecutor.java @@ -21,14 +21,22 @@ public class PostgresQueryExecutor { private final PostgresTableIdentifier tableIdentifier; public CloseableIterator execute(final Connection connection, final Query query) { - return execute(connection, query, null); + return execute(connection, query, null, null); } public CloseableIterator execute( final Connection connection, final Query query, String flatStructureCollectionName) { + return execute(connection, query, flatStructureCollectionName, null); + } + + public CloseableIterator execute( + final Connection connection, + final Query query, + String flatStructureCollectionName, + PostgresColumnRegistry columnRegistry) { final org.hypertrace.core.documentstore.postgres.query.v1.PostgresQueryParser queryParser = new org.hypertrace.core.documentstore.postgres.query.v1.PostgresQueryParser( - tableIdentifier, transformAndLog(query), flatStructureCollectionName); + tableIdentifier, transformAndLog(query), flatStructureCollectionName, columnRegistry); final String sqlQuery = queryParser.parse(); try { final PreparedStatement preparedStatement = diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/PostgresQueryParser.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/PostgresQueryParser.java index c9a7a55b..7e2270b5 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/PostgresQueryParser.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/PostgresQueryParser.java @@ -10,6 +10,7 @@ import lombok.Setter; import org.hypertrace.core.documentstore.postgres.Params; import org.hypertrace.core.documentstore.postgres.Params.Builder; +import org.hypertrace.core.documentstore.postgres.PostgresColumnRegistry; import org.hypertrace.core.documentstore.postgres.PostgresTableIdentifier; import org.hypertrace.core.documentstore.postgres.query.v1.transformer.FieldToPgColumnTransformer; import org.hypertrace.core.documentstore.postgres.query.v1.vistors.PostgresAggregationFilterTypeExpressionVisitor; @@ -29,6 +30,7 @@ public class PostgresQueryParser { @Getter private final PostgresTableIdentifier tableIdentifier; @Getter private final Query query; @Getter private final String flatStructureCollectionName; + @Getter private final PostgresColumnRegistry columnRegistry; @Setter String finalTableName; @Getter private final Builder paramsBuilder = Params.newBuilder(); @@ -46,16 +48,25 @@ public class PostgresQueryParser { @Getter private final FieldToPgColumnTransformer toPgColumnTransformer; public PostgresQueryParser( - PostgresTableIdentifier tableIdentifier, Query query, String flatStructureCollectionName) { + PostgresTableIdentifier tableIdentifier, + Query query, + String flatStructureCollectionName, + PostgresColumnRegistry columnRegistry) { this.tableIdentifier = tableIdentifier; this.query = query; this.flatStructureCollectionName = flatStructureCollectionName; + this.columnRegistry = columnRegistry; this.finalTableName = tableIdentifier.toString(); toPgColumnTransformer = new FieldToPgColumnTransformer(this); } + public PostgresQueryParser( + PostgresTableIdentifier tableIdentifier, Query query, String flatStructureCollectionName) { + this(tableIdentifier, query, flatStructureCollectionName, null); + } + public PostgresQueryParser(PostgresTableIdentifier tableIdentifier, Query query) { - this(tableIdentifier, query, null); + this(tableIdentifier, query, null, null); } public String parse() { diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresRelationalFilterParserFactoryImpl.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresRelationalFilterParserFactoryImpl.java index 8b4ef735..2bf08e2c 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresRelationalFilterParserFactoryImpl.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresRelationalFilterParserFactoryImpl.java @@ -14,24 +14,42 @@ import java.util.Map; import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; import org.hypertrace.core.documentstore.expression.operators.RelationalOperator; +import org.hypertrace.core.documentstore.postgres.PostgresColumnRegistry; import org.hypertrace.core.documentstore.postgres.query.v1.PostgresQueryParser; import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresContainsRelationalFilterParserNonJsonField; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresExistsRelationalFilterParserNonJsonField; import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresInRelationalFilterParserNonJsonField; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresLikeRelationalFilterParserNonJsonField; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresNotContainsRelationalFilterParserNonJsonField; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresNotExistsRelationalFilterParserNonJsonField; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresNotInRelationalFilterParserNonJsonField; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresStandardRelationalFilterParserNonJsonField; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field.PostgresStartsWithRelationalFilterParserNonJsonField; public class PostgresRelationalFilterParserFactoryImpl implements PostgresRelationalFilterParserFactory { - private static final Map parserMap = + // JSON-based parsers for JSONB document fields (fallback) + private static final Map jsonParserMap = Maps.immutableEnumMap( Map.ofEntries( - // CONTAINS is conditionally chosen between JSON and non-JSON versions entry(NOT_CONTAINS, new PostgresNotContainsRelationalFilterParser()), entry(EXISTS, new PostgresExistsRelationalFilterParser()), entry(NOT_EXISTS, new PostgresNotExistsRelationalFilterParser()), - // IN are conditionally chosen between JSON and non-JSON versions entry(NOT_IN, new PostgresNotInRelationalFilterParser()), entry(LIKE, new PostgresLikeRelationalFilterParser()), entry(STARTS_WITH, new PostgresStartsWithRelationalFilterParser()))); + // Non-JSON parsers for first-class typed columns + private static final Map nonJsonParserMap = + Maps.immutableEnumMap( + Map.ofEntries( + entry(NOT_CONTAINS, new PostgresNotContainsRelationalFilterParserNonJsonField()), + entry(EXISTS, new PostgresExistsRelationalFilterParserNonJsonField()), + entry(NOT_EXISTS, new PostgresNotExistsRelationalFilterParserNonJsonField()), + entry(NOT_IN, new PostgresNotInRelationalFilterParserNonJsonField()), + entry(LIKE, new PostgresLikeRelationalFilterParserNonJsonField()), + entry(STARTS_WITH, new PostgresStartsWithRelationalFilterParserNonJsonField()))); + // IN filter parsers private static final PostgresInRelationalFilterParserInterface jsonFieldInFilterParser = new PostgresInRelationalFilterParser(); @@ -44,25 +62,73 @@ public class PostgresRelationalFilterParserFactoryImpl private static final PostgresContainsRelationalFilterParserInterface nonJsonFieldContainsParser = new PostgresContainsRelationalFilterParserNonJsonField(); + // Standard parsers for EQ, NEQ, GT, LT, GTE, LTE operations private static final PostgresStandardRelationalFilterParser postgresStandardRelationalFilterParser = new PostgresStandardRelationalFilterParser(); + private static final PostgresStandardRelationalFilterParserNonJsonField + postgresStandardRelationalFilterParserNonJsonField = + new PostgresStandardRelationalFilterParserNonJsonField(); @Override public PostgresRelationalFilterParser parser( final RelationalExpression expression, final PostgresQueryParser postgresQueryParser) { + // Extract field name from LHS of the expression to check registry + String fieldName = extractFieldName(expression); + + // Get the column registry from the query parser context + PostgresColumnRegistry registry = postgresQueryParser.getColumnRegistry(); + + // Determine if this field should use first-class column processing - support both old and new + // patterns + boolean isFirstClassField = false; + + // NEW: Registry-based check (preferred) + if (registry != null && registry.isFirstClassColumn(fieldName)) { + isFirstClassField = true; + } + + // OLD: flatStructureCollection check (for backward compatibility) String flatStructureCollection = postgresQueryParser.getFlatStructureCollectionName(); - boolean isFirstClassField = - flatStructureCollection != null - && flatStructureCollection.equals( - postgresQueryParser.getTableIdentifier().getTableName()); + if (flatStructureCollection != null + && flatStructureCollection.equals( + postgresQueryParser.getTableIdentifier().getTableName())) { + isFirstClassField = true; + } + // Select appropriate parser based on field type and operator if (expression.getOperator() == CONTAINS) { return isFirstClassField ? nonJsonFieldContainsParser : jsonFieldContainsParser; } else if (expression.getOperator() == IN) { return isFirstClassField ? nonJsonFieldInFilterParser : jsonFieldInFilterParser; } - return parserMap.getOrDefault(expression.getOperator(), postgresStandardRelationalFilterParser); + // For other operators, choose between JSON and non-JSON parser maps + if (isFirstClassField) { + return nonJsonParserMap.getOrDefault( + expression.getOperator(), postgresStandardRelationalFilterParserNonJsonField); + } else { + return jsonParserMap.getOrDefault( + expression.getOperator(), postgresStandardRelationalFilterParser); + } + } + + /** + * Extracts the field name from the LHS of a relational expression. This is used to check if the + * field is a first-class typed column. + */ + private String extractFieldName(RelationalExpression expression) { + // For now, we'll use a simple approach - this may need refinement based on expression structure + // The LHS is typically a FieldExpression that contains the field name + String lhsString = expression.getLhs().toString(); + + // Handle simple field names (most common case) + if (lhsString != null && !lhsString.contains(".") && !lhsString.contains("(")) { + return lhsString; + } + + // For complex expressions, we may not be able to extract a simple field name + // In this case, fall back to JSON processing + return null; } } diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresStandardRelationalOperatorMapper.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresStandardRelationalOperatorMapper.java index bba352dd..5eec641b 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresStandardRelationalOperatorMapper.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/PostgresStandardRelationalOperatorMapper.java @@ -14,7 +14,7 @@ import java.util.Optional; import org.hypertrace.core.documentstore.expression.operators.RelationalOperator; -class PostgresStandardRelationalOperatorMapper { +public class PostgresStandardRelationalOperatorMapper { private static final Map mapping = Maps.immutableEnumMap( Map.ofEntries( @@ -28,7 +28,7 @@ class PostgresStandardRelationalOperatorMapper { private static final Map nullRhsOperatorMapping = Maps.immutableEnumMap(Map.ofEntries(entry(EQ, "IS"), entry(NEQ, "IS NOT"))); - String getMapping(final RelationalOperator operator, Object parsedRhs) { + public String getMapping(final RelationalOperator operator, Object parsedRhs) { if (Objects.nonNull(parsedRhs)) { return Optional.ofNullable(mapping.get(operator)) .orElseThrow( diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresExistsRelationalFilterParserNonJsonField.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresExistsRelationalFilterParserNonJsonField.java new file mode 100644 index 00000000..23d52da6 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresExistsRelationalFilterParserNonJsonField.java @@ -0,0 +1,24 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field; + +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresRelationalFilterParser; + +/** + * Implementation of EXISTS operator for non-JSON fields (regular PostgreSQL typed columns). Uses + * the PostgreSQL IS NOT NULL operator for checking if a column value exists. + * + *

This class is optimized for first-class typed columns rather than JSON document fields. + */ +public class PostgresExistsRelationalFilterParserNonJsonField + implements PostgresRelationalFilterParser { + + @Override + public String parse( + final RelationalExpression expression, + final PostgresRelationalFilterParser.PostgresRelationalFilterContext context) { + final String parsedLhs = expression.getLhs().accept(context.lhsParser()); + + // For typed columns, EXISTS is simply IS NOT NULL + return String.format("%s IS NOT NULL", parsedLhs); + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresLikeRelationalFilterParserNonJsonField.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresLikeRelationalFilterParserNonJsonField.java new file mode 100644 index 00000000..52007cf6 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresLikeRelationalFilterParserNonJsonField.java @@ -0,0 +1,27 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field; + +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresRelationalFilterParser; + +/** + * Implementation of LIKE operator for non-JSON fields (regular PostgreSQL text columns). Uses the + * PostgreSQL LIKE operator for pattern matching on text columns. + * + *

This class is optimized for first-class text columns rather than JSON document fields. + */ +public class PostgresLikeRelationalFilterParserNonJsonField + implements PostgresRelationalFilterParser { + + @Override + public String parse( + final RelationalExpression expression, + final PostgresRelationalFilterParser.PostgresRelationalFilterContext context) { + final String parsedLhs = expression.getLhs().accept(context.lhsParser()); + final Object parsedRhs = expression.getRhs().accept(context.rhsParser()); + + context.getParamsBuilder().addObjectParam(parsedRhs); + + // For typed text columns, LIKE is a direct operator + return String.format("%s LIKE ?", parsedLhs); + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresNotContainsRelationalFilterParserNonJsonField.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresNotContainsRelationalFilterParserNonJsonField.java new file mode 100644 index 00000000..890e8314 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresNotContainsRelationalFilterParserNonJsonField.java @@ -0,0 +1,40 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field; + +import java.util.Collection; +import java.util.Collections; +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresRelationalFilterParser; + +/** + * Implementation of NOT_CONTAINS operator for non-JSON fields (regular PostgreSQL arrays). Uses the + * PostgreSQL array containment operator (@>) with NOT for checking if one array does not contain + * another. + * + *

This class is optimized for first-class array columns rather than JSON document fields. + */ +public class PostgresNotContainsRelationalFilterParserNonJsonField + implements PostgresRelationalFilterParser { + + @Override + public String parse( + final RelationalExpression expression, + final PostgresRelationalFilterParser.PostgresRelationalFilterContext context) { + final String parsedLhs = expression.getLhs().accept(context.lhsParser()); + final Object parsedRhs = expression.getRhs().accept(context.rhsParser()); + + Object normalizedRhs = normalizeValue(parsedRhs); + context.getParamsBuilder().addObjectParam(normalizedRhs); + + return String.format("NOT(%s @> ARRAY[?]::text[])", parsedLhs); + } + + private Object normalizeValue(final Object value) { + if (value == null) { + return null; + } else if (value instanceof Collection) { + return value; + } else { + return Collections.singletonList(value); + } + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresNotExistsRelationalFilterParserNonJsonField.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresNotExistsRelationalFilterParserNonJsonField.java new file mode 100644 index 00000000..c13ac086 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresNotExistsRelationalFilterParserNonJsonField.java @@ -0,0 +1,24 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field; + +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresRelationalFilterParser; + +/** + * Implementation of NOT_EXISTS operator for non-JSON fields (regular PostgreSQL typed columns). + * Uses the PostgreSQL IS NULL operator for checking if a column value does not exist. + * + *

This class is optimized for first-class typed columns rather than JSON document fields. + */ +public class PostgresNotExistsRelationalFilterParserNonJsonField + implements PostgresRelationalFilterParser { + + @Override + public String parse( + final RelationalExpression expression, + final PostgresRelationalFilterParser.PostgresRelationalFilterContext context) { + final String parsedLhs = expression.getLhs().accept(context.lhsParser()); + + // For typed columns, NOT_EXISTS is simply IS NULL + return String.format("%s IS NULL", parsedLhs); + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresNotInRelationalFilterParserNonJsonField.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresNotInRelationalFilterParserNonJsonField.java new file mode 100644 index 00000000..29633f9b --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresNotInRelationalFilterParserNonJsonField.java @@ -0,0 +1,43 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field; + +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresRelationalFilterParser; + +/** + * Implementation of NOT_IN operator for non-JSON fields (regular PostgreSQL typed columns). Uses + * the PostgreSQL NOT IN operator for checking if a column value is not in a list of values. + * + *

This class is optimized for first-class typed columns rather than JSON document fields. + */ +public class PostgresNotInRelationalFilterParserNonJsonField + implements PostgresRelationalFilterParser { + + @Override + public String parse( + final RelationalExpression expression, + final PostgresRelationalFilterParser.PostgresRelationalFilterContext context) { + final String parsedLhs = expression.getLhs().accept(context.lhsParser()); + final Object parsedRhs = expression.getRhs().accept(context.rhsParser()); + + if (parsedRhs instanceof Iterable) { + // Build parameterized query with placeholders for each value + Iterable values = (Iterable) parsedRhs; + String placeholders = + StreamSupport.stream(values.spliterator(), false) + .map( + value -> { + context.getParamsBuilder().addObjectParam(value); + return "?"; + }) + .collect(Collectors.joining(", ")); + + return String.format("%s NOT IN (%s)", parsedLhs, placeholders); + } else { + // Single value case + context.getParamsBuilder().addObjectParam(parsedRhs); + return String.format("%s NOT IN (?)", parsedLhs); + } + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresStandardRelationalFilterParserNonJsonField.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresStandardRelationalFilterParserNonJsonField.java new file mode 100644 index 00000000..6c6a8b72 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresStandardRelationalFilterParserNonJsonField.java @@ -0,0 +1,34 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field; + +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresRelationalFilterParser; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresStandardRelationalOperatorMapper; + +/** + * Implementation of standard relational operators (EQ, NEQ, GT, LT, GTE, LTE) for non-JSON fields + * (regular PostgreSQL typed columns). Uses direct column comparison operators. + * + *

This class is optimized for first-class typed columns rather than JSON document fields. + */ +public class PostgresStandardRelationalFilterParserNonJsonField + implements PostgresRelationalFilterParser { + + private static final PostgresStandardRelationalOperatorMapper mapper = + new PostgresStandardRelationalOperatorMapper(); + + @Override + public String parse( + final RelationalExpression expression, + final PostgresRelationalFilterParser.PostgresRelationalFilterContext context) { + final Object parsedLhs = expression.getLhs().accept(context.lhsParser()); + final Object parsedRhs = expression.getRhs().accept(context.rhsParser()); + final String operator = mapper.getMapping(expression.getOperator(), parsedRhs); + + if (parsedRhs != null) { + context.getParamsBuilder().addObjectParam(parsedRhs); + return String.format("%s %s ?", parsedLhs, operator); + } else { + return String.format("%s %s NULL", parsedLhs, operator); + } + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresStartsWithRelationalFilterParserNonJsonField.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresStartsWithRelationalFilterParserNonJsonField.java new file mode 100644 index 00000000..4fd0eca5 --- /dev/null +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/parser/filter/nonjson/field/PostgresStartsWithRelationalFilterParserNonJsonField.java @@ -0,0 +1,29 @@ +package org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.nonjson.field; + +import org.hypertrace.core.documentstore.expression.impl.RelationalExpression; +import org.hypertrace.core.documentstore.postgres.query.v1.parser.filter.PostgresRelationalFilterParser; + +/** + * Implementation of STARTS_WITH operator for non-JSON fields (regular PostgreSQL text columns). + * Uses the PostgreSQL LIKE operator with % wildcard for prefix matching on text columns. + * + *

This class is optimized for first-class text columns rather than JSON document fields. + */ +public class PostgresStartsWithRelationalFilterParserNonJsonField + implements PostgresRelationalFilterParser { + + @Override + public String parse( + final RelationalExpression expression, + final PostgresRelationalFilterParser.PostgresRelationalFilterContext context) { + final String parsedLhs = expression.getLhs().accept(context.lhsParser()); + final Object parsedRhs = expression.getRhs().accept(context.rhsParser()); + + // Add % wildcard to make it a prefix match pattern + String pattern = parsedRhs + "%"; + context.getParamsBuilder().addObjectParam(pattern); + + // For typed text columns, STARTS_WITH becomes LIKE with % suffix + return String.format("%s LIKE ?", parsedLhs); + } +} diff --git a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/FieldToPgColumnTransformer.java b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/FieldToPgColumnTransformer.java index 7e5769ef..a2747a6f 100644 --- a/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/FieldToPgColumnTransformer.java +++ b/document-store/src/main/java/org/hypertrace/core/documentstore/postgres/query/v1/transformer/FieldToPgColumnTransformer.java @@ -16,11 +16,24 @@ public FieldToPgColumnTransformer(PostgresQueryParser postgresQueryParser) { } public FieldToPgColumn transform(String orgFieldName) { - // TODO: Forcing to map to the first class fields + // Check if field is a first-class typed column - support both old and new patterns + boolean isFirstClassField = false; + + // NEW: Registry-based check (preferred) + if (postgresQueryParser.getColumnRegistry() != null + && postgresQueryParser.getColumnRegistry().isFirstClassColumn(orgFieldName)) { + isFirstClassField = true; + } + + // OLD: flatStructureCollection check (for backward compatibility) String flatStructureCollection = postgresQueryParser.getFlatStructureCollectionName(); if (flatStructureCollection != null && flatStructureCollection.equals( postgresQueryParser.getTableIdentifier().getTableName())) { + isFirstClassField = true; + } + + if (isFirstClassField) { return new FieldToPgColumn(null, PostgresUtils.wrapFieldNamesWithDoubleQuotes(orgFieldName)); } Optional parentField =