From e9c7521c27832d44958e7dd45575152eb4bf45a5 Mon Sep 17 00:00:00 2001 From: Yuqi Du Date: Thu, 16 Oct 2025 10:13:28 -0700 Subject: [PATCH 1/7] [Tables] UDT partial projection and schema projection --- .../operation/tables/TableProjection.java | 156 ++++-- .../projection/TableProjectionDefinition.java | 39 +- .../projection/TableProjectionSelector.java | 68 +++ .../projection/TableProjectionSelectors.java | 271 +++++++++ .../TableUDTProjectionSelector.java | 155 +++++ .../service/schema/tables/ApiUdtType.java | 28 + .../ProjectionTableIntegrationTest.java | 282 ++++++++++ .../api/v1/util/DataApiResponseValidator.java | 21 + .../jsonapi/api/v1/util/TableTemplates.java | 34 ++ .../testdata/TableMetadataTestData.java | 51 +- .../testdata/TableProjectionTestData.java | 253 +++++++++ .../jsonapi/fixtures/testdata/TestData.java | 4 + .../fixtures/testdata/TestDataNames.java | 8 + .../operation/tables/TableProjectionTest.java | 528 ++++++++++++++++++ .../TableProjectionSelectorTest.java | 243 ++++++++ 15 files changed, 2049 insertions(+), 92 deletions(-) create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelector.java create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectors.java create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableUDTProjectionSelector.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionTableIntegrationTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TableProjectionTestData.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjectionTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectorTest.java diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjection.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjection.java index 1ec8046069..a54e50db03 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjection.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjection.java @@ -22,7 +22,11 @@ import io.stargate.sgv2.jsonapi.service.operation.OperationProjection; import io.stargate.sgv2.jsonapi.service.operation.filters.table.codecs.*; import io.stargate.sgv2.jsonapi.service.operation.query.SelectCQLClause; +import io.stargate.sgv2.jsonapi.service.projection.TableProjectionSelector; +import io.stargate.sgv2.jsonapi.service.projection.TableProjectionSelectors; +import io.stargate.sgv2.jsonapi.service.projection.TableUDTProjectionSelector; import io.stargate.sgv2.jsonapi.service.schema.tables.ApiSupportDef; +import io.stargate.sgv2.jsonapi.service.schema.tables.ApiUdtType; import java.util.*; import java.util.function.Predicate; import org.slf4j.Logger; @@ -49,22 +53,35 @@ public class TableProjection implements SelectCQLClause, OperationProjection { private ObjectMapper objectMapper; private TableSchemaObject table; - private List columns; - private ColumnsDescContainer columnsDesc; + + /** + * The columns selected at the top level, based on the projection definition. This is a subset of + * all table columns. + */ + private List selectedColumns; + + /** + * Selectors for precise selection of scalar column and UDT subfields. Since we get the top level + * values from selected {@link #selectedColumns}, we need to use the selectors to do 1. precise + * projection, column level or selected UDT subfields level. 2. precise schema description, column + * level or selected UDT subfields level. + */ + private TableProjectionSelectors preciseSelectors; + private TableSimilarityFunction tableSimilarityFunction; private TableProjection( ObjectMapper objectMapper, TableSchemaObject table, - List columns, - ColumnsDescContainer columnsDesc, - TableSimilarityFunction tableSimilarityFunction) { + List selectedColumns, + TableSimilarityFunction tableSimilarityFunction, + TableProjectionSelectors preciseSelectors) { this.objectMapper = objectMapper; this.table = table; - this.columns = columns; - this.columnsDesc = columnsDesc; + this.selectedColumns = selectedColumns; this.tableSimilarityFunction = tableSimilarityFunction; + this.preciseSelectors = preciseSelectors; } /** @@ -75,18 +92,23 @@ public static TableProjection fromDefinition( CommandContext ctx, ObjectMapper objectMapper, CmdT command) { TableSchemaObject table = ctx.schemaObject(); + + // Build projectionSelectors first + var projectionSelectors = + TableProjectionSelectors.from(command.tableProjectionDefinition(), table); + + // Get column metadata map Map columnsByName = new HashMap<>(); - // TODO: This can also be cached as part of TableSchemaObject than resolving it for every query. table .tableMetadata() .getColumns() .forEach((id, column) -> columnsByName.put(id.asInternal(), column)); - List columns = - command.tableProjectionDefinition().extractSelectedColumns(columnsByName); + // Then compute selected topLevel selectedColumns based on inclusion/exclusion mode + List selectedColumns = projectionSelectors.toCqlColumns(); - // TODO: A table can't be with empty columns. Think a redundant check. - if (columns.isEmpty()) { + // TODO: A table can't be with empty selectedColumns. Think a redundant check. + if (selectedColumns.isEmpty()) { throw ProjectionException.Code.UNKNOWN_TABLE_COLUMNS.get( errVars( table, @@ -99,12 +121,11 @@ public static TableProjection fromDefinition( } // result set has ColumnDefinitions not ColumnMetadata kind of weird - var readApiColumns = table .apiTableDef() .allColumns() - .filterByIdentifiers(columns.stream().map(ColumnMetadata::getName).toList()); + .filterByIdentifiers(selectedColumns.stream().map(ColumnMetadata::getName).toList()); var unsupportedColumns = readApiColumns.filterBySupportToList(MATCH_READ_UNSUPPORTED); if (!unsupportedColumns.isEmpty()) { @@ -117,18 +138,21 @@ public static TableProjection fromDefinition( })); } - return new TableProjection( - objectMapper, - table, - columns, - readApiColumns.getSchemaDescription(SchemaDescSource.DML_USAGE), - TableSimilarityFunction.from(ctx, command)); + TableProjection projection = + new TableProjection( + objectMapper, + table, + selectedColumns, + TableSimilarityFunction.from(ctx, command), + projectionSelectors); + + return projection; } @Override public Select apply(OngoingSelection ongoingSelection) { Set readColumns = new LinkedHashSet<>(); - readColumns.addAll(columns.stream().map(ColumnMetadata::getName).toList()); + readColumns.addAll(selectedColumns.stream().map(ColumnMetadata::getName).toList()); Select select = ongoingSelection.columnsIds(readColumns); // may apply similarity score function @@ -142,8 +166,8 @@ public JsonNode projectRow(Row row) { int skippedNullCount = 0; ObjectNode result = objectMapper.createObjectNode(); - for (int i = 0, len = columns.size(); i < len; ++i) { - final ColumnMetadata column = columns.get(i); + for (int i = 0, len = selectedColumns.size(); i < len; ++i) { + final ColumnMetadata column = selectedColumns.get(i); final String columnName = column.getName().asInternal(); JSONCodec codec; @@ -159,23 +183,20 @@ public JsonNode projectRow(Row row) { // By default, null value will not be returned. // https://github.com/stargate/data-api/issues/1636 issue for adding nullOption switch (columnValue) { - case null -> { - skippedNullCount++; - } - // For set/list/map values, java driver wrap up as empty Collection/Map, Data API only - // returns non-sparse data currently. - case Collection collection when collection.isEmpty() -> { - skippedNullCount++; - } - case Map map when map.isEmpty() -> { - skippedNullCount++; - } + case null -> skippedNullCount++; + case Collection collection when collection.isEmpty() -> + // For set/list/map values, java driver wrap up as empty Collection/Map, Data API only + // returns non-sparse data currently. + skippedNullCount++; + case Map map when map.isEmpty() -> skippedNullCount++; default -> { nonNullCount++; - result.put(columnName, codec.toJSON(objectMapper, columnValue)); + JsonNode projectedValue = projectColumnValue(column, columnValue, codec); + if (projectedValue != null) { + result.set(columnName, projectedValue); + } } } - } catch (ToJSONCodecException e) { throw ErrorCodeV1.UNSUPPORTED_PROJECTION_PARAM.toApiException( e, @@ -191,7 +212,7 @@ public JsonNode projectRow(Row row) { LOGGER.debug( "projectRow() row build durationMs={}, columns.size={}, nonNullCount={}, skippedNullCount={}", durationMs, - columns.size(), + selectedColumns.size(), nonNullCount, skippedNullCount); } @@ -209,8 +230,67 @@ public JsonNode projectRow(Row row) { return result; } + /** + * Projects a column value based on the configured selectors. + * + *

This method handles both simple column values and complex UDT values with subfield + * projections. + * + * @param column the column metadata + * @param columnValue the raw column value from the database row + * @param rootCodec the JSON codec for converting the root column value + * @return the projected JSON value, or null if the column should be excluded entirely + */ + private JsonNode projectColumnValue( + ColumnMetadata column, Object columnValue, JSONCodec rootCodec) throws ToJSONCodecException { + + // Find selector that applies to this root column + TableProjectionSelector targetSelector = + preciseSelectors.getSelectorForColumn(column.getName()); + + JsonNode fullProjectionNode = rootCodec.toJSON(objectMapper, columnValue); + if (fullProjectionNode == null) return null; + + return targetSelector.projectToJsonNode(fullProjectionNode); + } + @Override public ColumnsDescContainer getSchemaDescription() { - return columnsDesc; + // Build projected schema directly from selectors + return buildProjectionSchema(); + } + + /** + * Build projection schema directly from selectors. For non-UDT columns, include the whole column + * schema. For UDT columns with subfield selections, include only the selected field schema. + */ + private ColumnsDescContainer buildProjectionSchema() { + + ColumnsDescContainer projectedSchemaDesc = new ColumnsDescContainer(); + + // Build schema for each selected column based on its selector + for (var selector : preciseSelectors.getSelectors().values()) { + CqlIdentifier columnIdentifier = selector.getColumnIdentifier(); + + if (selector.isProjectOnUDTColumn()) { + var udtSelector = (TableUDTProjectionSelector) selector; + var udtApiType = (ApiUdtType) udtSelector.getColumnDef().type(); + projectedSchemaDesc.put( + columnIdentifier.asInternal(), + udtApiType.projectedSchemaDescription( + SchemaDescSource.DML_USAGE, udtSelector.getSelectedUDTFields())); + } else { + // Non-UDT column - include the whole column schema + var columnDesc = selector.getColumnDef().getSchemaDescription(SchemaDescSource.DML_USAGE); + if (columnDesc != null) { + projectedSchemaDesc.put(selector.getColumnIdentifier(), columnDesc); + } + } + } + return projectedSchemaDesc; + } + + public List getSelectedColumns() { + return selectedColumns; } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionDefinition.java b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionDefinition.java index a1ad1cf422..fd127945e2 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionDefinition.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionDefinition.java @@ -102,43 +102,8 @@ private static TableProjectionDefinition createFromNonEmpty(JsonNode projectionD return new TableProjectionDefinition(inclusionProjection, columnNames); } - /** - * Method that selects columns from a map of column definitions, based on this projection - * definition. - * - * @param columnDefs Column definitions by matching name to proper identifier - * @return Filtered List of matching columns - * @param Actual column identifier type - */ - public List extractSelectedColumns(Map columnDefs) { - // "missing" root layer used as short-cut for include-all/exclude-all - if (columnNames.isEmpty()) { - if (inclusion) { // exclude-all - return Collections.emptyList(); - } - // include-all - return columnDefs.values().stream().toList(); - } - - // Otherwise need to actually determine - List included = new ArrayList<>(); - - if (inclusion) { - for (String columnName : columnNames) { - T columnDef = columnDefs.get(columnName); - if (columnDef != null) { - included.add(columnDef); - } - } - } else { - for (Map.Entry entry : columnDefs.entrySet()) { - if (!columnNames.contains(entry.getKey())) { - included.add(entry.getValue()); - } - } - } - - return included; + public boolean isInclusion() { + return inclusion; } private static boolean extractIncludeOrExclude(String path, JsonNode value) { diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelector.java b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelector.java new file mode 100644 index 0000000000..448cb3647c --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelector.java @@ -0,0 +1,68 @@ +package io.stargate.sgv2.jsonapi.service.projection; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.fasterxml.jackson.databind.JsonNode; +import io.stargate.sgv2.jsonapi.service.schema.tables.ApiColumnDef; + +/** + * Selector for a root table column projection. + * + *

This base selector models projection for non-UDT (non-user-defined type) columns where the + * whole column is either included or excluded at the root level. For UDT columns that support + * per-field sub-selection, see {@link TableUDTProjectionSelector}. + * + *

Responsibilities: - Identify the target column via its {@link CqlIdentifier} and {@link + * ApiColumnDef}. - For non-UDT columns, project the full value (no sub-field pruning). - Act as a + * common type used by {@link TableProjectionSelectors} during inclusion/exclusion resolution. + */ +public class TableProjectionSelector { + + private final CqlIdentifier columnIdentifier; + + private final ApiColumnDef rootColumnDef; + + /** + * Create a selector for whole-column projection. + * + * @param columnDef the API column definition; must represent a non-UDT column for this base + * selector. UDT columns should use {@link TableUDTProjectionSelector} instead + */ + public TableProjectionSelector(ApiColumnDef columnDef) { + this.columnIdentifier = columnDef.name(); + this.rootColumnDef = columnDef; + } + + /** + * Whether this selector targets a UDT column. + * + *

Base implementation returns {@code false}. {@link TableUDTProjectionSelector} overrides and + * returns {@code true} to indicate UDT-specific handling is required. + */ + public boolean isProjectOnUDTColumn() { + return false; + } + + /** + * Apply this selector to the fully-materialized JSON value for the column. + * + *

For non-UDT columns, there is no sub-field pruning; the value is returned unchanged to + * represent whole-column projection. UDT-specific pruning logic is implemented in {@link + * TableUDTProjectionSelector}. + * + * @param fullProjectionNode the JSON node representing the full value of the column + * @return the projected JSON node (unchanged for non-UDT columns) + */ + public JsonNode projectToJsonNode(JsonNode fullProjectionNode) { + return fullProjectionNode; + } + + /** Get the API column definition for the root column targeted by this selector. */ + public ApiColumnDef getColumnDef() { + return rootColumnDef; + } + + /** Get the CQL identifier of the root column targeted by this selector. */ + public CqlIdentifier getColumnIdentifier() { + return columnIdentifier; + } +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectors.java b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectors.java new file mode 100644 index 0000000000..134134445f --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectors.java @@ -0,0 +1,271 @@ +package io.stargate.sgv2.jsonapi.service.projection; + +import static io.stargate.sgv2.jsonapi.exception.ErrorFormatters.errFmtApiColumnDef; +import static io.stargate.sgv2.jsonapi.exception.ErrorFormatters.errVars; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.metadata.schema.ColumnMetadata; +import io.stargate.sgv2.jsonapi.exception.ProjectionException; +import io.stargate.sgv2.jsonapi.service.cqldriver.executor.TableSchemaObject; +import io.stargate.sgv2.jsonapi.service.schema.collections.DocumentPath; +import io.stargate.sgv2.jsonapi.service.schema.tables.ApiColumnDef; +import io.stargate.sgv2.jsonapi.service.schema.tables.ApiColumnDefContainer; +import io.stargate.sgv2.jsonapi.service.schema.tables.ApiTypeName; +import io.stargate.sgv2.jsonapi.service.schema.tables.ApiUdtType; +import io.stargate.sgv2.jsonapi.util.CqlIdentifierUtil; +import java.util.*; + +/** + * Encapsulates a map of table projection selectors for inclusion-only projections. Only tracks what + * we want to include - exclusion logic is handled by not including items. + */ +public final class TableProjectionSelectors { + private final Map selectors; + private final TableSchemaObject table; + + private TableProjectionSelectors( + Map selectors, TableSchemaObject table) { + this.selectors = selectors; + this.table = table; + } + + /** + * Create projection selectors from projection definition. Detailed logics can refer to following + * inclusion and exclusion mode methods. + */ + public static TableProjectionSelectors from( + TableProjectionDefinition definition, TableSchemaObject table) { + var selectors = + definition.isInclusion() + ? buildInclusionSelectors(definition, table) + : buildExclusionSelectors(definition, table); + return selectors; + } + + /** + * Build projection selectors for inclusion mode, keeping only the columns and UDT sub-fields + * explicitly listed in the {@code definition}. + * + *

Rules: - Empty projection in inclusion mode means exclude all (returns an empty selector + * map). - Selecting a whole UDT column (e.g. {"address": 1}) overrides any sub-field selections + * for that column (e.g. {"address.city": 1}). - Only one-level sub-selection is supported for + * UDTs (e.g. {"address.city": 1}). - Any unknown column or invalid sub-field path is collected + * and results in {@link ProjectionException.Code#UNKNOWN_TABLE_COLUMNS}. + * + * @param definition the inclusion projection definition + * @param table the table schema object used to validate column and UDT field names + * @return a {@link TableProjectionSelectors} containing only explicitly included items + * @throws ProjectionException if the definition references unknown columns or invalid UDT fields + */ + private static TableProjectionSelectors buildInclusionSelectors( + TableProjectionDefinition definition, TableSchemaObject table) { + + Map selectorMap = new HashMap<>(); + + // include nothing, means exclude everything + // see {@link TableProjectionDefinition#EXCLUDE_ALL_PROJECTOR} + if (definition.getColumnNames().isEmpty()) { + return new TableProjectionSelectors(selectorMap, table); + } + + final ApiColumnDefContainer allColumnsInTable = table.apiTableDef().allColumns(); + + // gather unknown paths for error reporting + List unknownProjectionPaths = new ArrayList<>(); + + // Build selectors for explicitly included columns/fields + for (String path : definition.getColumnNames()) { + DocumentPath docPath = DocumentPath.from(path); + String root = docPath.getSegment(0); + var rootIdentifier = CqlIdentifier.fromInternal(root); + var rootApiColumnDef = allColumnsInTable.get(rootIdentifier); + + if (rootApiColumnDef == null) { + unknownProjectionPaths.add(path); + continue; + } + + if (docPath.getSegmentsSize() == 1) { + // Whole column requested + if (rootApiColumnDef.type().typeName() == ApiTypeName.UDT) { + // this will override other sub-field selections + // E.G. {"mainAddress": 1} overrides {"mainAddress.city": 1} + selectorMap.put(rootIdentifier, new TableUDTProjectionSelector(rootApiColumnDef)); + } else { + selectorMap.put(rootIdentifier, new TableProjectionSelector(rootApiColumnDef)); + } + } else if (docPath.getSegmentsSize() > 1) { + if (docPath.getSegmentsSize() != 2 + || rootApiColumnDef.type().typeName() != ApiTypeName.UDT) { + // Invalid path: only UDT fields can be sub-selected, and only one level deep + unknownProjectionPaths.add(path); + continue; + } + // UDT field requested + String subField = docPath.getSegment(1); + ApiUdtType udtType = (ApiUdtType) rootApiColumnDef.type(); + if (!udtType + .allFields() + .containsKey(CqlIdentifierUtil.cqlIdentifierFromUserInput(subField))) { + // Invalid sub-field name + unknownProjectionPaths.add(path); + continue; + } + TableUDTProjectionSelector existing = + (TableUDTProjectionSelector) selectorMap.get(rootIdentifier); + if (existing == null) { + // there is no projection required for the udt column or its fields yet + // E.G. There is no {"mainAddress.city": 1} or {"mainAddress": 1} + TableUDTProjectionSelector selector = + new TableUDTProjectionSelector(rootApiColumnDef, subField); + selectorMap.put(rootIdentifier, selector); + } else { + // there is already a projection required for the udt column or its fields + existing.addSubField(subField); + } + } + } + + // Report unknown projection paths + if (!unknownProjectionPaths.isEmpty()) { + throw ProjectionException.Code.UNKNOWN_TABLE_COLUMNS.get( + errVars( + table, + map -> { + map.put("allColumns", errFmtApiColumnDef(table.apiTableDef().allColumns())); + map.put("unknownColumns", unknownProjectionPaths.toString()); + })); + } + + return new TableProjectionSelectors(selectorMap, table); + } + + /** + * Build projection selectors for exclusion mode, starting from all table columns and removing the + * columns and UDT sub-fields explicitly listed in the {@code definition}. + * + *

Rules: - Empty projection in exclude mode means include all (returns a selector map for + * every column). - Excluding a whole column removes it entirely. - For UDTs, only one-level + * sub-selection is supported (e.g. {"address.city": 0}); after exclusions, UDT selectors with no + * remaining sub-fields are removed. - Any unknown column or invalid sub-field path is collected + * and results in {@link ProjectionException.Code#UNKNOWN_TABLE_COLUMNS}. + * + * @param definition the exclusion projection definition + * @param table the table schema object used to enumerate and validate columns and UDT fields + * @return a {@link TableProjectionSelectors} representing all columns except the exclusions + * @throws ProjectionException if the definition references unknown columns or invalid UDT fields + */ + private static TableProjectionSelectors buildExclusionSelectors( + TableProjectionDefinition definition, TableSchemaObject table) { + + // populate the selector map with all table columns/fields first + final Map selectorMap = new HashMap<>(); + final ApiColumnDefContainer allColumnsInTable = table.apiTableDef().allColumns(); + for (Map.Entry columnEntry : allColumnsInTable.entrySet()) { + ApiColumnDef columnDef = columnEntry.getValue(); + if (columnDef.type().typeName() == ApiTypeName.UDT) { + selectorMap.put(columnEntry.getKey(), new TableUDTProjectionSelector(columnDef)); + } else { + selectorMap.put(columnEntry.getKey(), new TableProjectionSelector(columnDef)); + } + } + + // exclude nothing, means include everything + // see {@link TableProjectionDefinition#INCLUDE_ALL_PROJECTOR} + if (definition.getColumnNames().isEmpty()) { + return new TableProjectionSelectors(selectorMap, table); + } + + // gather unknown paths for error reporting + List unknownProjectionPaths = new ArrayList<>(); + + // remove all explicit excluded columns/fields + for (String path : definition.getColumnNames()) { + DocumentPath docPath = DocumentPath.from(path); + String root = docPath.getSegment(0); + var rootIdentifier = CqlIdentifier.fromInternal(root); + var rootApiColumnDef = allColumnsInTable.get(rootIdentifier); + + if (rootApiColumnDef == null) { + unknownProjectionPaths.add(path); + continue; + } + if (docPath.getSegmentsSize() == 1) { + // Whole column excluded - remove it entirely + selectorMap.remove(rootIdentifier); + } else if (docPath.getSegmentsSize() > 1) { + + if (docPath.getSegmentsSize() != 2 + || rootApiColumnDef.type().typeName() != ApiTypeName.UDT) { + // Invalid path: only UDT fields can be sub-selected, and only one level deep + unknownProjectionPaths.add(path); + continue; + } + + String excludedField = docPath.getSegment(1); + TableUDTProjectionSelector udtSelector = + (TableUDTProjectionSelector) selectorMap.get(rootIdentifier); + udtSelector.removeSubField(excludedField); + } + } + + // Report unknown projection paths + if (!unknownProjectionPaths.isEmpty()) { + throw ProjectionException.Code.UNKNOWN_TABLE_COLUMNS.get( + errVars( + table, + map -> { + map.put("allColumns", errFmtApiColumnDef(table.apiTableDef().allColumns())); + map.put("unknownColumns", unknownProjectionPaths.toString()); + })); + } + + // Clean up any UDT selectors that have no fields left after exclusions + selectorMap + .values() + .removeIf( + selector -> + selector instanceof TableUDTProjectionSelector udtSelector + && udtSelector.isEmptyUdtSelector()); + + return new TableProjectionSelectors(selectorMap, table); + } + + /** + * Compute the set of columns to include in the CQL select based on what we want to include. + * + *

Examples: + * + *

    + *
  • Inclusion {"id": 1, "mainAddress.city": 1} → select [id, mainAddress] columns + *
  • Inclusion {"mainAddress": 1} → select [mainAddress] column (whole UDT) + *
  • Exclusion {"mainAddress.city": 0} → select [id, mainAddress, ...] (all columns; prune + * city later) + *
  • Exclusion {"id": 0, "mainAddress.city": 0} → select [mainAddress, ...] (exclude id; keep + * mainAddress for pruning) + *
  • Exclusion {"mainAddress": 0} → select [id, ...] (exclude mainAddress column entirely) + *
+ */ + public List toCqlColumns() { + // Always return the root columns from our selectors + // For inclusion mode: selectors contain only what we want + // For exclusion mode: selectors contain what we want to keep (everything except exclusions) + return table.tableMetadata().getColumns().values().stream() + .filter(col -> selectors.containsKey(col.getName())) + .toList(); + } + + /** + * Get the selector for a specific column by its CQL identifier. + * + * @param columnId the CQL identifier of the column + * @return the selector for the column, or null if no selector exists + */ + public TableProjectionSelector getSelectorForColumn(CqlIdentifier columnId) { + return selectors.get(columnId); + } + + public Map getSelectors() { + return selectors; + } +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableUDTProjectionSelector.java b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableUDTProjectionSelector.java new file mode 100644 index 0000000000..474a7bfa96 --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableUDTProjectionSelector.java @@ -0,0 +1,155 @@ +package io.stargate.sgv2.jsonapi.service.projection; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.stargate.sgv2.jsonapi.service.schema.tables.ApiColumnDef; +import io.stargate.sgv2.jsonapi.service.schema.tables.ApiUdtType; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Selector for projecting a UDT (user-defined type) column. + * + *

One selector is created per root column in a projection. For UDT columns, this selector tracks + * the set of sub-fields to include or exclude, depending on the overall projection mode selected by + * {@link TableProjectionDefinition}. When the whole UDT column is selected, this selector captures + * all sub-fields. When individual sub-fields are selected, it captures only those sub-fields. + * + *

Examples: - Inclusion {"name": 1, "address": 1, "address.city": 1} → two selectors: one for + * non-UDT column "name" and one UDT selector for "address". Selecting the whole UDT ("address") + * overrides any sub-field selections such as {"address.city": 1}. - Exclusion {"address.city": 0} → + * the UDT selector for "address" removes sub-field "city"; - Exclusion {"address.city": 0, + * "address.country": 0}, if no sub-fields remain after exclusions, the selector is dropped. + */ +public class TableUDTProjectionSelector extends TableProjectionSelector { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * The sub-fields to include/exclude for UDT columns. If includeModeForUDTFields is true, these + * are the fields to include. If includeModeForUDTFields is false, these are the fields to + * exclude. + */ + private final Set selectedUDTFields; + + /** + * Create a selector for whole-UDT-column projection. + * + *

All UDT sub-fields are considered selected by default. + * + * @param udtColumnDef the API column definition for the UDT column + */ + public TableUDTProjectionSelector(ApiColumnDef udtColumnDef) { + super(udtColumnDef); + this.selectedUDTFields = new HashSet<>(); + addAllSubFields(udtColumnDef); + } + + /** + * Create a selector for a UDT column when a specific sub-field is selected. + * + *

Only the provided sub-field is initially selected; additional sub-fields may be added via + * {@link #addSubField(String)}. + * + * @param udtColumnDef the API column definition for the UDT column + * @param subFieldName the initial UDT sub-field to select + */ + public TableUDTProjectionSelector(ApiColumnDef udtColumnDef, String subFieldName) { + super(udtColumnDef); + this.selectedUDTFields = new HashSet<>(); + addSubField(subFieldName); + } + + /** Select all sub-fields of the given UDT column definition. */ + private void addAllSubFields(ApiColumnDef udtColumnDef) { + final ApiUdtType udtDataType = (ApiUdtType) udtColumnDef.type(); + for (Map.Entry fieldEntry : udtDataType.allFields().entrySet()) { + selectedUDTFields.add(fieldEntry.getKey()); + } + } + + /** Get the selected UDT sub-fields as CQL identifiers. */ + public Set getSelectedUDTFields() { + return selectedUDTFields; + } + + /** + * Mark a UDT sub-field as selected. + * + * @param subFieldName the sub-field name (as provided by user input) + */ + public void addSubField(String subFieldName) { + this.selectedUDTFields.add(CqlIdentifier.fromInternal(subFieldName)); + } + + /** + * Remove a UDT sub-field from the selection (used in exclusion mode). + * + * @param excludedField the sub-field name to remove + */ + public void removeSubField(String excludedField) { + this.selectedUDTFields.remove(CqlIdentifier.fromInternal(excludedField)); + } + + /** + * Check whether any UDT sub-fields are currently selected. + * + * @return {@code true} if at least one sub-field is selected; {@code false} otherwise + */ + public boolean hasAnySubField() { + return !selectedUDTFields.isEmpty(); + } + + /** Get the selected UDT sub-fields as internal string names. */ + public Set getSubFields() { + return selectedUDTFields.stream() + .map(CqlIdentifier::asInternal) + .collect(java.util.stream.Collectors.toSet()); + } + + /** + * Returns {@code true} when this UDT selector has no selected sub-fields. + * + *

Example: UDT address (country text, city text) Projection: { "address.country": 0, + * "address.city": 0 } // all sub-fields explicitly excluded In this case the selector is + * considered empty and should be excluded from the projection. + */ + public boolean isEmptyUdtSelector() { + return selectedUDTFields.isEmpty(); + } + + /** + * Apply this selector to the fully materialized JSON value of the UDT column. + * + *

Only the selected sub-fields are included in the returned object. + * + *

Since we are not returning sparse data, null fields are not included in the result even if + * they are selected. + * + * @param fullProjectionNode the JSON node representing the full UDT value (object) + * @return a new object node containing only the selected sub-fields + */ + @Override + public JsonNode projectToJsonNode(JsonNode fullProjectionNode) { + // Inclusion mode: include only the selected fields + ObjectNode obj = OBJECT_MAPPER.createObjectNode(); + for (String subField : getSubFields()) { + JsonNode leaf = fullProjectionNode.get(subField); + // Note, we are not returning sparse data + // so null fields are not included in the result even if they are selected. + if (leaf != null && !leaf.isNull()) { + obj.set(subField, leaf); + } + } + return obj; + } + + /** Indicates that this selector targets a UDT column. */ + @Override + public boolean isProjectOnUDTColumn() { + return true; + } +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/schema/tables/ApiUdtType.java b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/tables/ApiUdtType.java index 9e46819bfd..cbc75b1d48 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/schema/tables/ApiUdtType.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/tables/ApiUdtType.java @@ -11,6 +11,7 @@ import com.datastax.oss.protocol.internal.ProtocolConstants; import edu.umd.cs.findbugs.annotations.NonNull; import io.stargate.sgv2.jsonapi.api.model.command.table.SchemaDescSource; +import io.stargate.sgv2.jsonapi.api.model.command.table.definition.ColumnsDescContainer; import io.stargate.sgv2.jsonapi.api.model.command.table.definition.TypeDefinitionDesc; import io.stargate.sgv2.jsonapi.api.model.command.table.definition.datatype.*; import io.stargate.sgv2.jsonapi.exception.SchemaException; @@ -93,6 +94,33 @@ public ColumnDesc getSchemaDescription(SchemaDescSource schemaDescSource) { }; } + /** + * When projecting a UDT, we may only want a subset of the fields in the UDT schema description. + * Note, this is only used when projecting UDT in a table. Other schema description path still + * goes through {@link #getSchemaDescription(SchemaDescSource)} + */ + public ColumnDesc projectedSchemaDescription( + SchemaDescSource schemaDescSource, Set selectedFields) { + return switch (schemaDescSource) { + case DDL_USAGE -> // just a reference to the UDT + new UdtRefColumnDesc(schemaDescSource, udtName(), ApiSupportDesc.from(this)); + case DML_USAGE -> // full inline schema desc + { + var allFieldsDesc = allFields.getSchemaDescription(schemaDescSource); + // exclude mapEntry that is not in selectedFields + var selectedFieldsDesc = new ColumnsDescContainer(); + for (var entry : allFieldsDesc.entrySet()) { + if (selectedFields.contains(CqlIdentifier.fromInternal(entry.getKey()))) { + selectedFieldsDesc.put(entry.getKey(), entry.getValue()); + } + } + yield new UdtColumnDesc( + schemaDescSource, udtName(), selectedFieldsDesc, ApiSupportDesc.from(this)); + } + default -> throw schemaDescSource.unsupportedException("ApiUdtType.getSchemaDescription()"); + }; + } + @Override public DataType cqlType() { // NOTE: override the shallow type to return the full UDT type diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionTableIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionTableIntegrationTest.java new file mode 100644 index 0000000000..d5139446a5 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionTableIntegrationTest.java @@ -0,0 +1,282 @@ +package io.stargate.sgv2.jsonapi.api.v1.tables; + +import static io.stargate.sgv2.jsonapi.api.v1.util.DataApiCommandSenders.assertNamespaceCommand; +import static io.stargate.sgv2.jsonapi.api.v1.util.DataApiCommandSenders.assertTableCommand; + +import io.quarkus.test.common.WithTestResource; +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.stargate.sgv2.jsonapi.service.schema.tables.ApiDataTypeDefs; +import io.stargate.sgv2.jsonapi.testresource.DseTestResource; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.*; + +@QuarkusIntegrationTest +@WithTestResource(value = DseTestResource.class) +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +public class ProjectionTableIntegrationTest extends AbstractTableIntegrationTestBase { + + @Nested + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class Scalars { + + private static final String TABLE = "proj_basic"; + + @BeforeAll + public static void createTable() { + assertNamespaceCommand(keyspaceName) + .templated() + .createTable( + TABLE, + Map.ofEntries( + Map.entry("id", "text"), + Map.entry("name", "text"), + Map.entry("age", "int"), + Map.entry("active", "boolean")), + "id") + .wasSuccessful(); + + // seed a couple of rows + assertTableCommand(keyspaceName, TABLE) + .templated() + .insertOne( + """ + { + "id": "u1", + "name": "Ada", + "age": 42, + "active": true + } + """) + .wasSuccessful() + .hasInsertedIds(List.of("u1")); + + assertTableCommand(keyspaceName, TABLE) + .templated() + .insertOne( + """ + { + "id": "u2", + "name": "Bob", + "age": 25, + "active": false + } + """) + .wasSuccessful() + .hasInsertedIds(List.of("u2")); + } + + @Test + public void inclusionProjectsOnlySelectedColumns() { + // Projects only name and age + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection( + Map.of("id", "u1"), "{\"name\":1,\"age\":1}", Map.of(), Map.of()) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) + .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) + .doesNotHaveProjectionSchemaWith("active") + .doesNotHaveProjectionSchemaWith("id") + .hasSingleDocument() + .hasJSONField( + "data.document", + """ + { + "name": "Ada", + "age": 42 + } + """); + } + + @Test + public void noProjectionSelectsAllColumns() { + // No projection clause should return all columns + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection(Map.of("id", "u1"), null, null, null) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) + .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) + .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) + .hasProjectionSchemaWith("active", ApiDataTypeDefs.BOOLEAN) + .hasSingleDocument() + .hasJSONField( + "data.document", + """ + { + "id": "u1", + "name": "Ada", + "age": 42, + "active": true + } + """); + } + + @Test + public void selectPrimaryKeyOnly() { + // Selecting only id should return only id + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection(Map.of("id", "u2"), "{\"id\":1}", Map.of(), Map.of()) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) + .doesNotHaveProjectionSchemaWith("name") + .doesNotHaveProjectionSchemaWith("age") + .doesNotHaveProjectionSchemaWith("active") + .hasSingleDocument() + .hasJSONField( + "data.document", + """ + { + "id": "u2" + } + """); + } + + @Test + public void exclusionProjectsAllButExcludedColumns() { + // Exclude a single column via raw projection JSON + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection(Map.of("id", "u1"), "{\"active\":0}", Map.of(), Map.of()) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) + .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) + .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) + .doesNotHaveProjectionSchemaWith("active") + .hasSingleDocument() + .hasJSONField( + "data.document", + """ + { + "id": "u1", + "name": "Ada", + "age": 42 + } + """); + } + } + + @Nested + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class UdtProjectionTest { + + private static final String TYPE_NAME = "Address"; + private static final String TABLE = "proj_udt"; + + @BeforeAll + public static void createTypeAndTable() { + // Create UDT type: Address(city text, country text) + assertNamespaceCommand(keyspaceName) + .templated() + .createType(TYPE_NAME, Map.of("city", "text", "country", "text")) + .wasSuccessful(); + + // Create table with udt column + assertNamespaceCommand(keyspaceName) + .templated() + .createTable( + TABLE, + Map.ofEntries( + Map.entry("id", "text"), + Map.entry("address", Map.of("type", "userDefined", "udtName", TYPE_NAME))), + "id") + .wasSuccessful(); + + // Insert one row + String row1 = + """ + { + "id": "r1", + "address": {"city": "New York", "country": "USA"} + } + """; + + assertTableCommand(keyspaceName, TABLE).templated().insertOne(row1).wasSuccessful(); + } + + @Test + public void projectUdtTopLevel() { + // Project the entire UDT column + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection(Map.of("id", "r1"), "{\"address\":1}", Map.of(), Map.of()) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaUdt("address", TYPE_NAME) + .hasProjectionSchemaUdtField("address", "city", "text") + .hasProjectionSchemaUdtField("address", "country", "text") + .hasSingleDocument() + .hasJSONField( + "data.document.address", + """ + {"city": "New York", "country": "USA"} + """); + } + + @Test + public void projectUdtSubField() { + // Project a sub-field of the UDT + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection( + Map.of("id", "r1"), "{\"address.city\":1}", Map.of(), Map.of()) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaUdt("address", TYPE_NAME) + .hasProjectionSchemaUdtField("address", "city", "text") + .doesNotHaveProjectionSchemaUdtField("address", "country") + .hasSingleDocument() + .hasJSONField( + "data.document.address", + """ + {"city": "New York"} + """); + } + + @Test + public void projectUdtTopLevelOverridesSubfield() { + // Selecting the top-level UDT should override any sub-field narrowing + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection( + Map.of("id", "r1"), "{\"address\":1,\"address.city\":1}", Map.of(), Map.of()) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaUdt("address", TYPE_NAME) + .hasProjectionSchemaUdtField("address", "city", "text") + .hasProjectionSchemaUdtField("address", "country", "text") + .hasSingleDocument() + .hasJSONField( + "data.document.address", + """ + {"city": "New York", "country": "USA"} + """); + } + + @Test + public void excludeUdtSubfieldProjectsRemainingFields() { + // Exclude a UDT sub-field; remaining UDT fields should be returned + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection( + Map.of("id", "r1"), "{\"address.city\":0}", Map.of(), Map.of()) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaUdt("address", TYPE_NAME) + .hasProjectionSchemaUdtField("address", "country", "text") + .doesNotHaveProjectionSchemaUdtField("address", "city") + .hasSingleDocument() + .hasJSONField( + "data.document.address", + """ + {"country": "USA"} + """); + } + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/DataApiResponseValidator.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/DataApiResponseValidator.java index 8229f70374..b563e67415 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/DataApiResponseValidator.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/DataApiResponseValidator.java @@ -375,6 +375,27 @@ public DataApiResponseValidator doesNotHaveProjectionSchemaWith(String columnNam return body("$", not(hasKey("status.projectionSchema." + columnName))); } + // UDT-specific projection schema assertions + public DataApiResponseValidator hasProjectionSchemaUdt(String columnName, String udtName) { + return body("status.projectionSchema." + columnName + ".type", equalTo("userDefined")) + .body("status.projectionSchema." + columnName + ".udtName", equalTo(udtName)); + } + + public DataApiResponseValidator hasProjectionSchemaUdtField( + String columnName, String fieldName, String expectedType) { + // Example path: status.projectionSchema.address.definition.fields.city.type == text + return body( + "status.projectionSchema." + columnName + ".definition.fields." + fieldName + ".type", + equalTo(expectedType)); + } + + public DataApiResponseValidator doesNotHaveProjectionSchemaUdtField( + String columnName, String fieldName) { + return body( + "$", + not(hasKey("status.projectionSchema." + columnName + ".definition.fields." + fieldName))); + } + public DataApiResponseValidator hasDocumentInPosition(int position, String documentJSON) { return body( "data.documents[%s]".formatted(position), diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/TableTemplates.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/TableTemplates.java index 39083a4a7e..8345beb044 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/TableTemplates.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/TableTemplates.java @@ -41,6 +41,28 @@ private String findClause( return asJSON(clause); } + private String findClause( + Map filter, + String projectionJSON, + Map sort, + Map options) { + + var clause = new LinkedHashMap<>(); + if (filter != null) { + clause.put("filter", filter); + } + if (projectionJSON != null) { + clause.put("projection", projectionJSON); + } + if (sort != null) { + clause.put("sort", sort); + } + if (options != null) { + clause.put("options", options); + } + return asJSON(clause); + } + public DataApiResponseValidator find( CommandName commandName, Map filter, List columns) { return find(commandName, filter, columns, null); @@ -105,6 +127,18 @@ public DataApiResponseValidator find( return sender.postFind(findClause(filter, projection, sort, options)); } + /** + * Passing raw projectionJSON, this is useful when we want to express inclusion and exclusion mode + * in the projection. + */ + public DataApiResponseValidator findWithExplicitProjection( + Map filter, + String projectionJSON, + Map sort, + Map options) { + return sender.postFind(findClause(filter, projectionJSON, sort, options)); + } + public DataApiResponseValidator find(String filter) { var json = """ diff --git a/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TableMetadataTestData.java b/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TableMetadataTestData.java index e07c106323..9e02e21d17 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TableMetadataTestData.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TableMetadataTestData.java @@ -7,6 +7,7 @@ import com.datastax.oss.driver.internal.core.metadata.schema.DefaultColumnMetadata; import com.datastax.oss.driver.internal.core.metadata.schema.DefaultIndexMetadata; import com.datastax.oss.driver.internal.core.metadata.schema.DefaultTableMetadata; +import com.datastax.oss.driver.internal.core.type.UserDefinedTypeBuilder; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import io.stargate.sgv2.jsonapi.service.schema.tables.ApiIndexFunction; @@ -223,22 +224,8 @@ public TableMetadata tableAllDatatypesIndexed() { } public TableMetadata tableAllDatatypesNotIndexed() { - return new DefaultTableMetadata( - names.KEYSPACE_NAME, - names.TABLE_NAME, - UUID.randomUUID(), - false, - false, - ImmutableList.of( - columnMetadata(names.COL_PARTITION_KEY_1, DataTypes.TEXT), - columnMetadata(names.COL_PARTITION_KEY_2, DataTypes.TEXT)), - ImmutableMap.of( - columnMetadata(names.COL_CLUSTERING_KEY_1, DataTypes.TEXT), - ClusteringOrder.ASC, - columnMetadata(names.COL_CLUSTERING_KEY_2, DataTypes.TEXT), - ClusteringOrder.ASC, - columnMetadata(names.COL_CLUSTERING_KEY_3, DataTypes.TEXT), - ClusteringOrder.ASC), + // Build base columns as before + var baseColumns = columnMap( Map.entry(names.COL_PARTITION_KEY_1, DataTypes.TEXT), Map.entry(names.COL_PARTITION_KEY_2, DataTypes.TEXT), @@ -269,7 +256,37 @@ public TableMetadata tableAllDatatypesNotIndexed() { Map.entry(names.CQL_SET_COLUMN, DataTypes.setOf(DataTypes.TEXT)), Map.entry(names.CQL_MAP_COLUMN, DataTypes.mapOf(DataTypes.TEXT, DataTypes.TEXT)), Map.entry(names.CQL_LIST_COLUMN, DataTypes.listOf(DataTypes.TEXT)), - Map.entry(names.CQL_VECTOR_COLUMN, DataTypes.vectorOf(DataTypes.FLOAT, 3))), + Map.entry(names.CQL_VECTOR_COLUMN, DataTypes.vectorOf(DataTypes.FLOAT, 3))); + + // Add a non-frozen UDT column: address_udt(city text, country text) + var udt = + new UserDefinedTypeBuilder(names.KEYSPACE_NAME, names.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS) + .withField(names.CQL_ADDRESS_CITY_FIELD, DataTypes.TEXT) + .withField(names.CQL_ADDRESS_COUNTRY_FIELD, DataTypes.TEXT) + .build(); + + var extendedColumns = new java.util.LinkedHashMap(baseColumns); + extendedColumns.put( + names.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS, + columnMetadata(names.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS, udt)); + + return new DefaultTableMetadata( + names.KEYSPACE_NAME, + names.TABLE_NAME, + UUID.randomUUID(), + false, + false, + ImmutableList.of( + columnMetadata(names.COL_PARTITION_KEY_1, DataTypes.TEXT), + columnMetadata(names.COL_PARTITION_KEY_2, DataTypes.TEXT)), + ImmutableMap.of( + columnMetadata(names.COL_CLUSTERING_KEY_1, DataTypes.TEXT), + ClusteringOrder.ASC, + columnMetadata(names.COL_CLUSTERING_KEY_2, DataTypes.TEXT), + ClusteringOrder.ASC, + columnMetadata(names.COL_CLUSTERING_KEY_3, DataTypes.TEXT), + ClusteringOrder.ASC), + extendedColumns, ImmutableMap.of(), ImmutableMap.of()); } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TableProjectionTestData.java b/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TableProjectionTestData.java new file mode 100644 index 0000000000..dd45f900d6 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TableProjectionTestData.java @@ -0,0 +1,253 @@ +package io.stargate.sgv2.jsonapi.fixtures.testdata; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.metadata.schema.ColumnMetadata; +import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata; +import com.datastax.oss.driver.api.core.type.DataTypes; +import com.datastax.oss.driver.api.core.type.UserDefinedType; +import com.datastax.oss.driver.internal.querybuilder.select.DefaultSelect; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; +import io.stargate.sgv2.jsonapi.api.model.command.Projectable; +import io.stargate.sgv2.jsonapi.service.cqldriver.executor.TableSchemaObject; +import io.stargate.sgv2.jsonapi.service.operation.tables.TableProjection; +import io.stargate.sgv2.jsonapi.service.processor.CommandContextTestData; +import io.stargate.sgv2.jsonapi.util.recordable.Recordable; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Map; +import java.util.UUID; +import org.mockito.Mockito; + +/** Test fixture for TableProjection unit tests, following fluent style used elsewhere. */ +public class TableProjectionTestData extends TestDataSuplier { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public TableProjectionTestData(TestData testData) { + super(testData); + } + + public Fixture tableWithAllDataTypes(String message) { + var tm = testData.tableMetadata().tableAllDatatypesNotIndexed(); + return new Fixture(message, tm, TableSchemaObject.from(tm, new ObjectMapper())); + } + + public Fixture tableWithAllDataTypesPlusUdt(String message) { + var tm = testData.tableMetadata().tableAllDatatypesNotIndexed(); + return new Fixture(message, tm, TableSchemaObject.from(tm, new ObjectMapper())); + } + + public static class Fixture implements Recordable { + public final String message; + public final TableMetadata tableMetadata; + public final TableSchemaObject tableSchemaObject; + + private TableProjection projection; + private String appliedCql; + + public Fixture( + String message, TableMetadata tableMetadata, TableSchemaObject tableSchemaObject) { + this.message = message; + this.tableMetadata = tableMetadata; + this.tableSchemaObject = tableSchemaObject; + } + + public Fixture withProjectionJson(String json) { + try { + Projectable cmd = + new Projectable() { + private final JsonNode def = OBJECT_MAPPER.readTree(json); + + @Override + public JsonNode projectionDefinition() { + return def; + } + }; + + CommandContext ctx = + new CommandContextTestData(new TestData()) + .tableSchemaObjectCommandContext(tableSchemaObject); + this.projection = TableProjection.fromDefinition(ctx, OBJECT_MAPPER, cmd); + return this; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public Fixture applySelect() { + var select = new DefaultSelect(tableMetadata.getKeyspace(), tableMetadata.getName()).all(); + this.appliedCql = projection.apply(select).asCql(); + return this; + } + + public Fixture assertSelectContains(String... fragments) { + for (String f : fragments) { + assertThat(appliedCql).as("%s contains %s", message, f).contains(f); + } + return this; + } + + public Fixture assertSelectNotContains(String... fragments) { + for (String f : fragments) { + assertThat(appliedCql).as("%s not contains %s", message, f).doesNotContain(f); + } + return this; + } + + /** + * Project a row with all necessary selected columns populated with default values. This method + * internally creates a complete row with all selected columns and then projects it. + */ + public JsonNode projectRow() { + var selectedColumns = projection.getSelectedColumns(); + Object[] allValues = new Object[selectedColumns.size()]; + + // Fill with default values based on column types + for (int i = 0; i < selectedColumns.size(); i++) { + var column = selectedColumns.get(i); + allValues[i] = getDefaultValueForColumn(column); + } + + // Create a mock row that returns the values in order + Row row = Mockito.mock(Row.class); + for (int i = 0; i < allValues.length; i++) { + final int index = i; + Mockito.when(row.getObject(index)).thenReturn(allValues[index]); + } + + return projection.projectRow(row); + } + + /** + * Project a row with custom values for specific columns. This method allows overriding default + * values for testing specific scenarios. + * + * @param overrides Map of column names to custom values + * @return projected JSON result + */ + public JsonNode projectRowWithOverrides(Map overrides) { + var selectedColumns = projection.getSelectedColumns(); + Object[] allValues = new Object[selectedColumns.size()]; + + // Fill with default values, then apply overrides + for (int i = 0; i < selectedColumns.size(); i++) { + var column = selectedColumns.get(i); + var columnName = column.getName().asInternal(); + + // Use override value if provided, otherwise use default + allValues[i] = overrides.getOrDefault(columnName, getDefaultValueForColumn(column)); + } + + // Create a mock row that returns the values in order + Row row = Mockito.mock(Row.class); + for (int i = 0; i < allValues.length; i++) { + final int index = i; + Mockito.when(row.getObject(index)).thenReturn(allValues[index]); + } + + return projection.projectRow(row); + } + + /** Get a default value for a column based on its type. */ + private Object getDefaultValueForColumn(ColumnMetadata column) { + var type = column.getType(); + if (type == DataTypes.TEXT) { + return "default_text"; + } else if (type == DataTypes.INT) { + return 42; + } else if (type == DataTypes.BOOLEAN) { + return true; + } else if (type == DataTypes.DOUBLE) { + return 3.14; + } else if (type == DataTypes.FLOAT) { + return 2.5f; + } else if (type == DataTypes.BIGINT) { + return 123456789L; + } else if (type == DataTypes.UUID) { + return UUID.randomUUID(); + } else if (type == DataTypes.DATE) { + return LocalDate.now(); + } else if (type == DataTypes.TIMESTAMP) { + return Instant.now(); + } else if (type == DataTypes.TIME) { + return LocalTime.now(); + } else if (type == DataTypes.DECIMAL) { + return BigDecimal.valueOf(123.45); + } else if (type instanceof UserDefinedType) { + // For UDT, create a default UdtValue + var udtType = (UserDefinedType) type; + var udtValue = udtType.newValue(); + // Set default values for UDT fields + for (var fieldName : udtType.getFieldNames()) { + if (udtType.getFieldTypes().get(udtType.getFieldNames().indexOf(fieldName)) + == DataTypes.TEXT) { + // Use the actual field name as generated by TestDataNames (includes timestamp) + udtValue = udtValue.set(fieldName, "default_" + fieldName.asInternal(), String.class); + } + } + return udtValue; + } + // For other types, return null + return null; + } + + public Fixture assertJsonHasInt(JsonNode node, CqlIdentifier key, int value) { + assertThat(node.get(key.asInternal()).asInt()).isEqualTo(value); + return this; + } + + public Fixture assertJsonNodeSize(JsonNode node, int expectedSize) { + assertThat(node.size()).isEqualTo(expectedSize); + return this; + } + + public Fixture assertJsonMissing(JsonNode node, CqlIdentifier... keys) { + for (var k : keys) { + assertThat(node.get(k.asInternal())).isNull(); + } + return this; + } + + public Fixture assertJsonHasObject(JsonNode node, CqlIdentifier key) { + assertThat(node.get(key.asInternal())).isNotNull(); + return this; + } + + public Fixture assertJsonHasString(JsonNode node, CqlIdentifier key, String value) { + assertThat(node.get(key.asInternal()).asText()).isEqualTo(value); + return this; + } + + public Fixture assertJsonHasBoolean(JsonNode node, CqlIdentifier key, boolean value) { + assertThat(node.get(key.asInternal()).asBoolean()).isEqualTo(value); + return this; + } + + public Fixture assertJsonHasDouble(JsonNode node, CqlIdentifier key, double value) { + assertThat(node.get(key.asInternal()).asDouble()).isEqualTo(value); + return this; + } + + public Fixture assertJsonHasLong(JsonNode node, CqlIdentifier key, long value) { + assertThat(node.get(key.asInternal()).asLong()).isEqualTo(value); + return this; + } + + public Fixture assertJsonHasBigDecimal(JsonNode node, CqlIdentifier key, BigDecimal value) { + assertThat(node.get(key.asInternal()).decimalValue()).isEqualTo(value); + return this; + } + + @Override + public DataRecorder recordTo(DataRecorder dataRecorder) { + return dataRecorder.append("message", message).append("table", tableMetadata.describe(true)); + } + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TestData.java b/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TestData.java index 538da34f21..e210a71b88 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TestData.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TestData.java @@ -92,4 +92,8 @@ public TableUpdateAnalyzerTestData tableUpdateAnalyzer() { public TableUpdateOperatorTestData tableUpdateOperator() { return getOrCache(TableUpdateOperatorTestData.class); } + + public TableProjectionTestData tableProjection() { + return getOrCache(TableProjectionTestData.class); + } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TestDataNames.java b/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TestDataNames.java index b2e64bb27b..f8a5454618 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TestDataNames.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/fixtures/testdata/TestDataNames.java @@ -154,6 +154,14 @@ public class TestDataNames { public final CqlIdentifier CQL_VECTOR_COLUMN = CqlIdentifier.fromInternal("vector_column_" + System.currentTimeMillis()); + // UDT columns + public final CqlIdentifier CQL_NON_FROZEN_UDT_COLUMN_ADDRESS = + CqlIdentifier.fromInternal("non_frozen_address_" + System.currentTimeMillis()); + public final CqlIdentifier CQL_ADDRESS_CITY_FIELD = + CqlIdentifier.fromInternal("address_city_" + System.currentTimeMillis()); + public final CqlIdentifier CQL_ADDRESS_COUNTRY_FIELD = + CqlIdentifier.fromInternal("address_country_" + System.currentTimeMillis()); + public final CqlIdentifier CQL_MAP_COLUMN_INDEX = CqlIdentifier.fromInternal("map_column_index_" + System.currentTimeMillis()); public final CqlIdentifier CQL_LIST_COLUMN_INDEX = diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjectionTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjectionTest.java new file mode 100644 index 0000000000..4091cfcaa0 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjectionTest.java @@ -0,0 +1,528 @@ +package io.stargate.sgv2.jsonapi.service.operation.tables; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.datastax.oss.driver.api.core.data.UdtValue; +import com.datastax.oss.driver.api.core.type.UserDefinedType; +import io.stargate.sgv2.jsonapi.fixtures.testdata.TableProjectionTestData; +import io.stargate.sgv2.jsonapi.fixtures.testdata.TestData; +import io.stargate.sgv2.jsonapi.fixtures.testdata.TestDataNames; +import java.math.BigDecimal; +import java.util.HashMap; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class TableProjectionTest { + + private static final TestData TEST_DATA = new TestData(); + private static final TestDataNames NAMES = TEST_DATA.names; + + private static TableProjectionTestData.Fixture tableWithAllTypes(String message) { + return TEST_DATA.tableProjection().tableWithAllDataTypes(message); + } + + private static TableProjectionTestData.Fixture tableWithUdt(String message) { + return TEST_DATA.tableProjection().tableWithAllDataTypesPlusUdt(message); + } + + private static UdtValue createUdtValueWithNullCity(String country) { + // Get the UDT type from the table metadata + var tableMetadata = TEST_DATA.tableMetadata().tableAllDatatypesNotIndexed(); + var udtColumn = tableMetadata.getColumn(NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS).orElseThrow(); + var udtType = (UserDefinedType) udtColumn.getType(); + + // Create a real UdtValue using the UDT type + UdtValue udtValue = udtType.newValue(); + udtValue = udtValue.setToNull(NAMES.CQL_ADDRESS_CITY_FIELD); + udtValue = udtValue.set(NAMES.CQL_ADDRESS_COUNTRY_FIELD, country, String.class); + return udtValue; + } + + @Nested + class HighLevelProjection { + + @Test + public void scalar_columns_inclusion_projects_all_types() { + // Tests that inclusion projection works with various scalar data types + var json = + "{" + + "\"" + + NAMES.CQL_TEXT_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_INT_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_BOOLEAN_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_DOUBLE_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_UUID_COLUMN.asInternal() + + "\":1" + + "}"; + + var fixture = + tableWithAllTypes("scalar columns inclusion projects all types") + .withProjectionJson(json) + .applySelect() + .assertSelectContains( + NAMES.CQL_TEXT_COLUMN.asInternal(), + NAMES.CQL_INT_COLUMN.asInternal(), + NAMES.CQL_BOOLEAN_COLUMN.asInternal(), + NAMES.CQL_DOUBLE_COLUMN.asInternal(), + NAMES.CQL_UUID_COLUMN.asInternal()); + + var out = fixture.projectRow(); + + fixture + .assertJsonHasString(out, NAMES.CQL_TEXT_COLUMN, "default_text") + .assertJsonHasInt(out, NAMES.CQL_INT_COLUMN, 42) + .assertJsonHasBoolean(out, NAMES.CQL_BOOLEAN_COLUMN, true) + .assertJsonHasDouble(out, NAMES.CQL_DOUBLE_COLUMN, 3.14); + } + + @Test + public void scalar_columns_exclusion_removes_specified_types() { + // Tests that exclusion projection removes specified scalar columns + var json = + "{" + + "\"" + + NAMES.CQL_TEXT_COLUMN.asInternal() + + "\":0," + + "\"" + + NAMES.CQL_BOOLEAN_COLUMN.asInternal() + + "\":0" + + "}"; + + tableWithAllTypes("scalar columns exclusion removes specified types") + .withProjectionJson(json) + .applySelect() + .assertSelectNotContains( + NAMES.CQL_TEXT_COLUMN.asInternal(), NAMES.CQL_BOOLEAN_COLUMN.asInternal()) + .assertSelectContains( + NAMES.CQL_INT_COLUMN.asInternal(), NAMES.CQL_DOUBLE_COLUMN.asInternal()); + } + + @Test + public void null_scalar_values_are_not_returned() { + // Tests that null scalar values are omitted from projection results + var json = + "{" + + "\"" + + NAMES.CQL_TEXT_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_INT_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_BOOLEAN_COLUMN.asInternal() + + "\":1" + + "}"; + + var fixture = + tableWithAllTypes("null scalar values are not returned") + .withProjectionJson(json) + .applySelect(); + + // Override specific values to null to test null handling + var overrides = new HashMap(); + overrides.put(NAMES.CQL_TEXT_COLUMN.asInternal(), null); + overrides.put(NAMES.CQL_BOOLEAN_COLUMN.asInternal(), null); + var out = fixture.projectRowWithOverrides(overrides); + + fixture + .assertJsonNodeSize( + out, 1) // Only int column should be present (text and boolean are null) + .assertJsonHasInt(out, NAMES.CQL_INT_COLUMN, 42) + .assertJsonMissing(out, NAMES.CQL_TEXT_COLUMN, NAMES.CQL_BOOLEAN_COLUMN); + } + + @Test + public void numeric_types_projection_works_correctly() { + // Tests that various numeric types project correctly + var json = + "{" + + "\"" + + NAMES.CQL_INT_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_BIGINT_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_DOUBLE_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_FLOAT_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_DECIMAL_COLUMN.asInternal() + + "\":1" + + "}"; + + var fixture = + tableWithAllTypes("numeric types projection works correctly") + .withProjectionJson(json) + .applySelect() + .assertSelectContains( + NAMES.CQL_INT_COLUMN.asInternal(), + NAMES.CQL_BIGINT_COLUMN.asInternal(), + NAMES.CQL_DOUBLE_COLUMN.asInternal(), + NAMES.CQL_FLOAT_COLUMN.asInternal(), + NAMES.CQL_DECIMAL_COLUMN.asInternal()); + + var out = fixture.projectRow(); + + fixture + .assertJsonNodeSize(out, 5) // Should have exactly 5 numeric fields + .assertJsonHasInt(out, NAMES.CQL_INT_COLUMN, 42) + .assertJsonHasLong(out, NAMES.CQL_BIGINT_COLUMN, 123456789L) + .assertJsonHasDouble(out, NAMES.CQL_DOUBLE_COLUMN, 3.14) + .assertJsonHasDouble(out, NAMES.CQL_FLOAT_COLUMN, 2.5) + .assertJsonHasBigDecimal(out, NAMES.CQL_DECIMAL_COLUMN, BigDecimal.valueOf(123.45)); + } + + @Test + public void date_time_types_projection_works_correctly() { + // Tests that date/time types project correctly + var json = + "{" + + "\"" + + NAMES.CQL_DATE_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_TIMESTAMP_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_TIME_COLUMN.asInternal() + + "\":1" + + "}"; + + var fixture = + tableWithAllTypes("date/time types projection works correctly") + .withProjectionJson(json) + .applySelect() + .assertSelectContains( + NAMES.CQL_DATE_COLUMN.asInternal(), + NAMES.CQL_TIMESTAMP_COLUMN.asInternal(), + NAMES.CQL_TIME_COLUMN.asInternal()); + + var out = fixture.projectRow(); + + // Verify that date/time fields are present and check size + fixture + .assertJsonNodeSize(out, 3) // Should have exactly 3 fields + .assertJsonHasObject(out, NAMES.CQL_DATE_COLUMN) + .assertJsonHasObject(out, NAMES.CQL_TIMESTAMP_COLUMN) + .assertJsonHasObject(out, NAMES.CQL_TIME_COLUMN); + } + + @Test + public void mixed_inclusion_exclusion_throws_error() { + // Tests that mixing inclusion and exclusion throws validation error + var json = + "{" + + "\"" + + NAMES.CQL_INT_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_TEXT_COLUMN.asInternal() + + "\":0" + + "}"; + + assertThat( + assertThrows( + RuntimeException.class, + () -> + tableWithAllTypes("mixed inclusion/exclusion should throw error") + .withProjectionJson(json) + .applySelect())) + .hasMessageContaining("cannot exclude"); + } + + @Test + public void inclusion_selects_only_listed_columns_and_projects_non_nulls() { + // Tests that inclusion projection selects only specified columns and excludes null values + var json = + "{" + + "\"" + + NAMES.CQL_TEXT_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_INT_COLUMN.asInternal() + + "\":1" + + "}"; + + var fixture = + tableWithAllTypes("inclusion projects only selected non-null fields") + .withProjectionJson(json) + .applySelect() + .assertSelectContains( + NAMES.CQL_TEXT_COLUMN.asInternal(), NAMES.CQL_INT_COLUMN.asInternal()) + .assertSelectNotContains(NAMES.CQL_BOOLEAN_COLUMN.asInternal()); + + // Override text column to null to test null exclusion + var overrides = new HashMap(); + overrides.put(NAMES.CQL_TEXT_COLUMN.asInternal(), null); + var out = fixture.projectRowWithOverrides(overrides); + fixture + .assertJsonHasInt(out, NAMES.CQL_INT_COLUMN, 42) + .assertJsonMissing(out, NAMES.CQL_TEXT_COLUMN); + } + + @Test + public void exclusion_prunes_listed_columns_from_selection() { + // Tests that exclusion projection removes specified columns from CQL SELECT + var json = "{" + "\"" + NAMES.CQL_TEXT_COLUMN.asInternal() + "\":0" + "}"; + + tableWithAllTypes("exclusion removes selected columns from SELECT") + .withProjectionJson(json) + .applySelect() + .assertSelectNotContains(NAMES.CQL_TEXT_COLUMN.asInternal()) + .assertSelectContains(NAMES.CQL_INT_COLUMN.asInternal()); + } + + @Test + public void null_and_empty_collections_are_not_returned() { + // Tests that null and empty collection values are omitted from projection results + var json = + "{" + + "\"" + + NAMES.CQL_INT_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_SET_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_MAP_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_LIST_COLUMN.asInternal() + + "\":1" + + "}"; + + var fixture = + tableWithAllTypes("null and empty collection values omitted") + .withProjectionJson(json) + .applySelect(); + + // Override collection values to null to test null handling + var overrides = new HashMap(); + overrides.put(NAMES.CQL_SET_COLUMN.asInternal(), null); + overrides.put(NAMES.CQL_MAP_COLUMN.asInternal(), null); + overrides.put(NAMES.CQL_LIST_COLUMN.asInternal(), null); + var out = fixture.projectRowWithOverrides(overrides); + + fixture + .assertJsonHasInt(out, NAMES.CQL_INT_COLUMN, 42) + .assertJsonMissing( + out, NAMES.CQL_SET_COLUMN, NAMES.CQL_MAP_COLUMN, NAMES.CQL_LIST_COLUMN); + } + } + + @Nested + class UdtProjection { + + private String udtCol() { + return NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS.asInternal(); + } + + private String cityField() { + return NAMES.CQL_ADDRESS_CITY_FIELD.asInternal(); + } + + private String countryField() { + return NAMES.CQL_ADDRESS_COUNTRY_FIELD.asInternal(); + } + + @Test + public void inclusion_udt_top_level_includes_all_fields() { + // Tests that UDT top-level inclusion includes all UDT fields + var json = "{\"" + udtCol() + "\":1}"; + + var fixture = + tableWithUdt("inclusion UDT top level includes all fields") + .withProjectionJson(json) + .applySelect() + .assertSelectContains(udtCol()); + + var out = fixture.projectRow(); + + fixture.assertJsonHasObject(out, NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS); + var projectedUdt = out.get(NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS.asInternal()); + assertThat(projectedUdt.get(cityField()).asText()).isEqualTo("default_" + cityField()); + assertThat(projectedUdt.get(countryField()).asText()).isEqualTo("default_" + countryField()); + } + + @Test + public void inclusion_udt_sub_level_includes_only_specified_fields() { + // Tests that UDT subfield inclusion includes only specified fields + var json = "{\"" + udtCol() + "." + cityField() + "\":1}"; + + var fixture = + tableWithUdt("inclusion UDT sub level includes only specified fields") + .withProjectionJson(json) + .applySelect() + .assertSelectContains(udtCol()); + + var out = fixture.projectRow(); + fixture.assertJsonHasObject(out, NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS); + var projectedUdt = out.get(NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS.asInternal()); + assertThat(projectedUdt.get(cityField()).asText()).isEqualTo("default_" + cityField()); + assertThat(projectedUdt.has(countryField())).isFalse(); + } + + @Test + public void inclusion_udt_top_level_overrides_sub_level() { + // Tests that UDT top-level inclusion overrides subfield specifications + var json = "{\"" + udtCol() + "\":1, \"" + udtCol() + "." + cityField() + "\":1}"; + + var fixture = + tableWithUdt("inclusion UDT top level overrides sub level") + .withProjectionJson(json) + .applySelect() + .assertSelectContains(udtCol()); + + var out = fixture.projectRow(); + + fixture.assertJsonHasObject(out, NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS); + var projectedUdt = out.get(NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS.asInternal()); + + assertThat(projectedUdt.get(cityField()).asText()).isEqualTo("default_" + cityField()); + assertThat(projectedUdt.get(countryField()).asText()).isEqualTo("default_" + countryField()); + } + + @Test + public void exclusion_udt_top_level_removes_udt_from_selection() { + // Tests that UDT top-level exclusion removes UDT from CQL SELECT + var json = "{\"" + udtCol() + "\":0}"; + + tableWithUdt("exclusion UDT top level removes UDT from selection") + .withProjectionJson(json) + .applySelect() + .assertSelectNotContains(udtCol()) + .assertSelectContains(NAMES.CQL_INT_COLUMN.asInternal()); + } + + @Test + public void exclusion_udt_sub_level_removes_specified_fields() { + // Tests that UDT subfield exclusion removes only specified fields + var json = "{\"" + udtCol() + "." + cityField() + "\":0}"; + + var fixture = + tableWithUdt("exclusion UDT sub level removes specified fields") + .withProjectionJson(json) + .applySelect() + .assertSelectContains(udtCol()); + + var out = fixture.projectRow(); + + fixture.assertJsonHasObject(out, NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS); + var projectedUdt = out.get(NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS.asInternal()); + assertThat(projectedUdt.has(cityField())).isFalse(); + assertThat(projectedUdt.get(countryField()).asText()).isEqualTo("default_" + countryField()); + } + + @Test + public void exclusion_udt_all_subfields_removes_udt_from_selection() { + // Tests that excluding all UDT subfields removes UDT from CQL SELECT + var json = + "{\"" + + udtCol() + + "." + + cityField() + + "\":0, \"" + + udtCol() + + "." + + countryField() + + "\":0}"; + + tableWithUdt("exclusion UDT all subfields removes UDT from selection") + .withProjectionJson(json) + .applySelect() + .assertSelectNotContains(udtCol()) + .assertSelectContains(NAMES.CQL_INT_COLUMN.asInternal()); + } + + /** + * Sparse UDT fields (null) are not included in projection results even if explicitly selected. + */ + @Test + public void udt_null_fields_are_not_included_even_if_selected() { + // Tests that null UDT fields are excluded from projection results even if selected + var json = + "{\"" + + udtCol() + + "." + + cityField() + + "\":1, \"" + + udtCol() + + "." + + countryField() + + "\":1}"; + + var fixture = + tableWithUdt("UDT null fields are not included even if selected") + .withProjectionJson(json) + .applySelect(); + + // Create a UDT with null city field to test null handling + var udtWithNullCity = createUdtValueWithNullCity("USA"); + var overrides = new HashMap(); + overrides.put(udtCol(), udtWithNullCity); + var out = fixture.projectRowWithOverrides(overrides); + + fixture.assertJsonHasObject(out, NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS); + var projectedUdt = out.get(NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS.asInternal()); + assertThat(projectedUdt.has(cityField())).isFalse(); + assertThat(projectedUdt.get(countryField()).asText()).isEqualTo("USA"); + } + + @Test + public void udt_exclusion_projection_removes_specified_fields() { + // Tests that UDT exclusion projection removes all specified subfields + var json = + "{\"" + + udtCol() + + "." + + cityField() + + "\":0, \"" + + udtCol() + + "." + + countryField() + + "\":0}"; + + tableWithUdt("UDT exclusion projection removes specified fields") + .withProjectionJson(json) + .applySelect() + .assertSelectNotContains("\"" + udtCol() + "\"") + .assertSelectContains(NAMES.CQL_INT_COLUMN.asInternal()); + } + + @Test + public void mixed_inclusion_exclusion_throws_error() { + // Tests that mixing UDT inclusion and exclusion throws validation error + var json = + "{\"" + + udtCol() + + "." + + cityField() + + "\":1, \"" + + udtCol() + + "." + + countryField() + + "\":0}"; + assertThat( + assertThrows( + RuntimeException.class, + () -> { + tableWithUdt("mixed inclusion/exclusion should throw error") + .withProjectionJson(json) + .applySelect(); + })) + .hasMessageContaining("cannot exclude"); + } + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectorTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectorTest.java new file mode 100644 index 0000000000..27cd936947 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectorTest.java @@ -0,0 +1,243 @@ +package io.stargate.sgv2.jsonapi.service.projection; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.stargate.sgv2.jsonapi.fixtures.testdata.TableMetadataTestData; +import io.stargate.sgv2.jsonapi.fixtures.testdata.TestData; +import io.stargate.sgv2.jsonapi.fixtures.testdata.TestDataNames; +import io.stargate.sgv2.jsonapi.service.cqldriver.executor.TableSchemaObject; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class TableProjectionSelectorTest { + + private static final TestData TEST_DATA = new TestData(); + private static final TestDataNames NAMES = TEST_DATA.names; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static TableSchemaObject realTableAllTypes() { + var tm = new TableMetadataTestData(TEST_DATA).tableAllDatatypesNotIndexed(); + return TableSchemaObject.from(tm, new ObjectMapper()); + } + + private static TableProjectionDefinition include(String json) { + try { + return TableProjectionDefinition.createFromDefinition(OBJECT_MAPPER.readTree(json)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Nested + class NonUdtSelectors { + @Test + public void inclusion_keeps_only_listed_columns() { + // Tests that inclusion projection selects only specified columns + var table = realTableAllTypes(); + var def = + include( + "{" + + "\"" + + NAMES.CQL_INT_COLUMN.asInternal() + + "\":1," + + "\"" + + NAMES.CQL_TEXT_COLUMN.asInternal() + + "\":1" + + "}"); + + var selectors = TableProjectionSelectors.from(def, table); + var selected = selectors.toCqlColumns(); + assertThat(selected) + .extracting(c -> c.getName()) + .contains(NAMES.CQL_INT_COLUMN, NAMES.CQL_TEXT_COLUMN); + assertThat(selected).extracting(c -> c.getName()).doesNotContain(NAMES.CQL_BOOLEAN_COLUMN); + } + + @Test + public void exclusion_removes_listed_columns() { + // Tests that exclusion projection removes specified columns + var table = realTableAllTypes(); + var def = include("{" + "\"" + NAMES.CQL_TEXT_COLUMN.asInternal() + "\":0" + "}"); + + var selectors = TableProjectionSelectors.from(def, table); + var selected = selectors.toCqlColumns(); + assertThat(selected).extracting(c -> c.getName()).doesNotContain(NAMES.CQL_TEXT_COLUMN); + } + } + + @Nested + class UdtSelectors { + private String udtCol() { + return NAMES.CQL_NON_FROZEN_UDT_COLUMN_ADDRESS.asInternal(); + } + + private String cityField() { + return NAMES.CQL_ADDRESS_CITY_FIELD.asInternal(); + } + + private String countryField() { + return NAMES.CQL_ADDRESS_COUNTRY_FIELD.asInternal(); + } + + @Test + public void inclusion_subfield_creates_udt_selector_with_field() { + // Tests that UDT subfield inclusion creates selector with only that field + var table = realTableAllTypes(); + var def = include("{\"" + udtCol() + "." + cityField() + "\":1}"); + + var selectors = TableProjectionSelectors.from(def, table); + var selector = + (TableUDTProjectionSelector) + selectors.getSelectorForColumn(CqlIdentifier.fromInternal(udtCol())); + assertThat(selector).isNotNull(); + assertThat(selector.getSubFields()).contains(cityField()); + assertThat(selectors.toCqlColumns()) + .extracting(c -> c.getName().asInternal()) + .contains(udtCol()); + } + + @Test + public void inclusion_multiple_subfields_creates_udt_selector_with_both() { + // Tests that multiple UDT subfield inclusions create selector with all fields + var table = realTableAllTypes(); + var def = + include( + "{\"" + + udtCol() + + "." + + cityField() + + "\":1, \"" + + udtCol() + + "." + + countryField() + + "\":1}"); + + var selectors = TableProjectionSelectors.from(def, table); + var selector = + (TableUDTProjectionSelector) + selectors.getSelectorForColumn(CqlIdentifier.fromInternal(udtCol())); + assertThat(selector).isNotNull(); + assertThat(selector.getSubFields()).contains(cityField(), countryField()); + } + + @Test + public void inclusion_whole_udt_overrides_subfields() { + // Tests that whole UDT inclusion overrides subfield specifications + var table = realTableAllTypes(); + var def = include("{\"" + udtCol() + "\":1, \"" + udtCol() + "." + cityField() + "\":1}"); + + var selectors = TableProjectionSelectors.from(def, table); + var selector = selectors.getSelectorForColumn(CqlIdentifier.fromInternal(udtCol())); + assertThat(selector).isInstanceOf(TableUDTProjectionSelector.class); + var full = + new ObjectMapper() + .createObjectNode() + .put(cityField(), "New York") + .put(countryField(), "USA"); + var projected = selector.projectToJsonNode(full); + assertThat(projected.get(cityField()).asText()).isEqualTo("New York"); + assertThat(projected.get(countryField()).asText()).isEqualTo("USA"); + } + + @Test + public void exclusion_subfield_removes_field_and_keeps_others() { + // Tests that UDT subfield exclusion removes only that field, keeps others + var table = realTableAllTypes(); + var def = include("{\"" + udtCol() + "." + cityField() + "\":0}"); + + var selectors = TableProjectionSelectors.from(def, table); + var selector = + (TableUDTProjectionSelector) + selectors.getSelectorForColumn(CqlIdentifier.fromInternal(udtCol())); + assertThat(selector).isNotNull(); + assertThat(selector.getSubFields()).doesNotContain(cityField()); + assertThat(selector.getSubFields()).contains(countryField()); + } + + @Test + public void exclusion_all_subfields_removes_udt_selector() { + // Tests that excluding all UDT subfields removes the UDT selector entirely + var table = realTableAllTypes(); + var def = + include( + "{\"" + + udtCol() + + "." + + cityField() + + "\":0, \"" + + udtCol() + + "." + + countryField() + + "\":0}"); + + var selectors = TableProjectionSelectors.from(def, table); + assertThat(selectors.getSelectorForColumn(CqlIdentifier.fromInternal(udtCol()))).isNull(); + } + + @Test + public void exclusion_whole_udt_removes_udt_selector() { + // Tests that whole UDT exclusion removes the UDT selector entirely + var table = realTableAllTypes(); + var def = include("{\"" + udtCol() + "\":0}"); + + var selectors = TableProjectionSelectors.from(def, table); + assertThat(selectors.getSelectorForColumn(CqlIdentifier.fromInternal(udtCol()))).isNull(); + } + + @Test + public void exclusion_projection_removes_specified_fields() { + // Tests that exclusion projection removes all specified UDT subfields + var table = realTableAllTypes(); + var def = + include( + "{\"" + + udtCol() + + "." + + cityField() + + "\":0, \"" + + udtCol() + + "." + + countryField() + + "\":0}"); + + var selectors = TableProjectionSelectors.from(def, table); + var selector = + (TableUDTProjectionSelector) + selectors.getSelectorForColumn(CqlIdentifier.fromInternal(udtCol())); + // since all fields are excluded, the udt selector should be null + assertThat(selector).isNull(); + } + + @Test + public void udt_projection_with_null_values_excludes_nulls() { + // Tests that null UDT field values are excluded from projection results + var table = realTableAllTypes(); + var def = + include( + "{\"" + + udtCol() + + "." + + cityField() + + "\":1, \"" + + udtCol() + + "." + + countryField() + + "\":1}"); + + var selectors = TableProjectionSelectors.from(def, table); + var selector = + (TableUDTProjectionSelector) + selectors.getSelectorForColumn(CqlIdentifier.fromInternal(udtCol())); + assertThat(selector).isNotNull(); + + // Test with null city field + var partial = + new ObjectMapper().createObjectNode().putNull(cityField()).put(countryField(), "USA"); + var projected = selector.projectToJsonNode(partial); + assertThat(projected.has(cityField())).isFalse(); + assertThat(projected.get(countryField()).asText()).isEqualTo("USA"); + } + } +} From 14f9d51985db95172638d64e59847b8e90a508d4 Mon Sep 17 00:00:00 2001 From: Yuqi Du Date: Fri, 17 Oct 2025 11:42:24 -0700 Subject: [PATCH 2/7] fix unsupportedColumns and unknown columns --- .../operation/tables/TableProjection.java | 27 -------- .../projection/TableProjectionSelectors.java | 36 ++++++++-- ...st.java => ProjectionIntegrationTest.java} | 66 +++++++++++-------- .../ProjectionSchemaIntegrationTest.java | 5 +- 4 files changed, 72 insertions(+), 62 deletions(-) rename src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/{ProjectionTableIntegrationTest.java => ProjectionIntegrationTest.java} (88%) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjection.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjection.java index a54e50db03..ecc8af88a5 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjection.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/tables/TableProjection.java @@ -25,10 +25,8 @@ import io.stargate.sgv2.jsonapi.service.projection.TableProjectionSelector; import io.stargate.sgv2.jsonapi.service.projection.TableProjectionSelectors; import io.stargate.sgv2.jsonapi.service.projection.TableUDTProjectionSelector; -import io.stargate.sgv2.jsonapi.service.schema.tables.ApiSupportDef; import io.stargate.sgv2.jsonapi.service.schema.tables.ApiUdtType; import java.util.*; -import java.util.function.Predicate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,13 +42,6 @@ public class TableProjection implements SelectCQLClause, OperationProjection { private static final Logger LOGGER = LoggerFactory.getLogger(TableProjection.class); - /** - * Match if a column does not support reads so we can find unsupported columns from the - * projection. - */ - private static final Predicate MATCH_READ_UNSUPPORTED = - ApiSupportDef.Matcher.NO_MATCHES.withRead(false); - private ObjectMapper objectMapper; private TableSchemaObject table; @@ -120,24 +111,6 @@ public static TableProjection fromDefinition( })); } - // result set has ColumnDefinitions not ColumnMetadata kind of weird - var readApiColumns = - table - .apiTableDef() - .allColumns() - .filterByIdentifiers(selectedColumns.stream().map(ColumnMetadata::getName).toList()); - - var unsupportedColumns = readApiColumns.filterBySupportToList(MATCH_READ_UNSUPPORTED); - if (!unsupportedColumns.isEmpty()) { - throw ProjectionException.Code.UNSUPPORTED_COLUMN_TYPES.get( - errVars( - table, - map -> { - map.put("allColumns", errFmtApiColumnDef(table.apiTableDef().allColumns())); - map.put("unsupportedColumns", errFmtApiColumnDef(unsupportedColumns)); - })); - } - TableProjection projection = new TableProjection( objectMapper, diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectors.java b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectors.java index 134134445f..c18fe2ba9b 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectors.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/TableProjectionSelectors.java @@ -8,21 +8,30 @@ import io.stargate.sgv2.jsonapi.exception.ProjectionException; import io.stargate.sgv2.jsonapi.service.cqldriver.executor.TableSchemaObject; import io.stargate.sgv2.jsonapi.service.schema.collections.DocumentPath; -import io.stargate.sgv2.jsonapi.service.schema.tables.ApiColumnDef; -import io.stargate.sgv2.jsonapi.service.schema.tables.ApiColumnDefContainer; -import io.stargate.sgv2.jsonapi.service.schema.tables.ApiTypeName; -import io.stargate.sgv2.jsonapi.service.schema.tables.ApiUdtType; +import io.stargate.sgv2.jsonapi.service.schema.tables.*; import io.stargate.sgv2.jsonapi.util.CqlIdentifierUtil; import java.util.*; +import java.util.function.Predicate; /** * Encapsulates a map of table projection selectors for inclusion-only projections. Only tracks what * we want to include - exclusion logic is handled by not including items. */ public final class TableProjectionSelectors { + /** + * The map of column identifiers to their projection selectors. The presence of a selector in this + * map means the column is included in the projection. + */ private final Map selectors; + private final TableSchemaObject table; + /** + * Match if a column does not support reads, we can find unsupported columns from the projection. + */ + private static final Predicate MATCH_READ_UNSUPPORTED = + ApiSupportDef.Matcher.NO_MATCHES.withRead(false); + private TableProjectionSelectors( Map selectors, TableSchemaObject table) { this.selectors = selectors; @@ -35,10 +44,27 @@ private TableProjectionSelectors( */ public static TableProjectionSelectors from( TableProjectionDefinition definition, TableSchemaObject table) { + + // check if the table columns is not readable first + // this is to avoid trigger the UnsupportedApiDataType typeName() exception + List unsupportedReadColumns = + table.apiTableDef().allColumns().filterBySupportToList(MATCH_READ_UNSUPPORTED); + if (!unsupportedReadColumns.isEmpty()) { + throw ProjectionException.Code.UNSUPPORTED_COLUMN_TYPES.get( + errVars( + table, + map -> { + map.put("allColumns", errFmtApiColumnDef(table.apiTableDef().allColumns())); + map.put("unsupportedColumns", errFmtApiColumnDef(unsupportedReadColumns)); + })); + } + + // create selectors based on inclusion or exclusion mode var selectors = definition.isInclusion() ? buildInclusionSelectors(definition, table) : buildExclusionSelectors(definition, table); + return selectors; } @@ -80,6 +106,7 @@ private static TableProjectionSelectors buildInclusionSelectors( var rootIdentifier = CqlIdentifier.fromInternal(root); var rootApiColumnDef = allColumnsInTable.get(rootIdentifier); + // if root column not found, this is an invalid projection path if (rootApiColumnDef == null) { unknownProjectionPaths.add(path); continue; @@ -194,7 +221,6 @@ private static TableProjectionSelectors buildExclusionSelectors( // Whole column excluded - remove it entirely selectorMap.remove(rootIdentifier); } else if (docPath.getSegmentsSize() > 1) { - if (docPath.getSegmentsSize() != 2 || rootApiColumnDef.type().typeName() != ApiTypeName.UDT) { // Invalid path: only UDT fields can be sub-selected, and only one level deep diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionTableIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java similarity index 88% rename from src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionTableIntegrationTest.java rename to src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java index d5139446a5..d68d421424 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionTableIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java @@ -14,7 +14,7 @@ @QuarkusIntegrationTest @WithTestResource(value = DseTestResource.class) @TestClassOrder(ClassOrderer.OrderAnnotation.class) -public class ProjectionTableIntegrationTest extends AbstractTableIntegrationTestBase { +public class ProjectionIntegrationTest extends AbstractTableIntegrationTestBase { @Nested @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @@ -79,9 +79,9 @@ public void inclusionProjectsOnlySelectedColumns() { .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) .doesNotHaveProjectionSchemaWith("active") .doesNotHaveProjectionSchemaWith("id") - .hasSingleDocument() - .hasJSONField( - "data.document", + .hasDocuments(1) + .hasDocumentInPosition( + 0, """ { "name": "Ada", @@ -102,9 +102,9 @@ public void noProjectionSelectsAllColumns() { .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) .hasProjectionSchemaWith("active", ApiDataTypeDefs.BOOLEAN) - .hasSingleDocument() - .hasJSONField( - "data.document", + .hasDocuments(1) + .hasDocumentInPosition( + 0, """ { "id": "u1", @@ -127,9 +127,9 @@ public void selectPrimaryKeyOnly() { .doesNotHaveProjectionSchemaWith("name") .doesNotHaveProjectionSchemaWith("age") .doesNotHaveProjectionSchemaWith("active") - .hasSingleDocument() - .hasJSONField( - "data.document", + .hasDocuments(1) + .hasDocumentInPosition( + 0, """ { "id": "u2" @@ -149,9 +149,9 @@ public void exclusionProjectsAllButExcludedColumns() { .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) .doesNotHaveProjectionSchemaWith("active") - .hasSingleDocument() - .hasJSONField( - "data.document", + .hasDocuments(1) + .hasDocumentInPosition( + 0, """ { "id": "u1", @@ -211,11 +211,13 @@ public void projectUdtTopLevel() { .hasProjectionSchemaUdt("address", TYPE_NAME) .hasProjectionSchemaUdtField("address", "city", "text") .hasProjectionSchemaUdtField("address", "country", "text") - .hasSingleDocument() - .hasJSONField( - "data.document.address", + .hasDocuments(1) + .hasDocumentInPosition( + 0, """ - {"city": "New York", "country": "USA"} + { + "address": {"city": "New York", "country": "USA"} + } """); } @@ -231,11 +233,13 @@ public void projectUdtSubField() { .hasProjectionSchemaUdt("address", TYPE_NAME) .hasProjectionSchemaUdtField("address", "city", "text") .doesNotHaveProjectionSchemaUdtField("address", "country") - .hasSingleDocument() - .hasJSONField( - "data.document.address", + .hasDocuments(1) + .hasDocumentInPosition( + 0, """ - {"city": "New York"} + { + "address": {"city": "New York"} + } """); } @@ -251,11 +255,13 @@ public void projectUdtTopLevelOverridesSubfield() { .hasProjectionSchemaUdt("address", TYPE_NAME) .hasProjectionSchemaUdtField("address", "city", "text") .hasProjectionSchemaUdtField("address", "country", "text") - .hasSingleDocument() - .hasJSONField( - "data.document.address", + .hasDocuments(1) + .hasDocumentInPosition( + 0, """ - {"city": "New York", "country": "USA"} + { + "address": {"city": "New York", "country": "USA"} + } """); } @@ -271,11 +277,13 @@ public void excludeUdtSubfieldProjectsRemainingFields() { .hasProjectionSchemaUdt("address", TYPE_NAME) .hasProjectionSchemaUdtField("address", "country", "text") .doesNotHaveProjectionSchemaUdtField("address", "city") - .hasSingleDocument() - .hasJSONField( - "data.document.address", + .hasDocuments(1) + .hasDocumentInPosition( + 0, """ - {"country": "USA"} + { + "address": {"country": "USA"} + } """); } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionSchemaIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionSchemaIntegrationTest.java index 21d25646ac..c8bc05dd21 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionSchemaIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionSchemaIntegrationTest.java @@ -141,7 +141,10 @@ public void findManyProjectionMissingColumns() { .hasProjectionSchema() .hasDocuments(1) .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) - .doesNotHaveProjectionSchemaWith("MISSING_COLUMN"); + .hasSingleApiError( + ProjectionException.Code.UNKNOWN_TABLE_COLUMNS, + ProjectionException.class, + "The projection included the following unknown columns: [MISSING_COLUMN]"); } @Test From 4a034c37228b3e8b64d4accef150adf3794a092a Mon Sep 17 00:00:00 2001 From: Yuqi Du Date: Fri, 17 Oct 2025 12:27:45 -0700 Subject: [PATCH 3/7] fix integration tests --- .../api/v1/tables/ProjectionIntegrationTest.java | 14 +++++++------- .../v1/tables/ProjectionSchemaIntegrationTest.java | 4 ---- .../sgv2/jsonapi/api/v1/util/TableTemplates.java | 10 +++++----- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java index d68d421424..5264c633d8 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java @@ -72,7 +72,7 @@ public void inclusionProjectsOnlySelectedColumns() { assertTableCommand(keyspaceName, TABLE) .templated() .findWithExplicitProjection( - Map.of("id", "u1"), "{\"name\":1,\"age\":1}", Map.of(), Map.of()) + Map.of("id", "u1"), Map.of("name", 1, "age", 1), Map.of(), Map.of()) .wasSuccessful() .hasProjectionSchema() .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) @@ -120,7 +120,7 @@ public void selectPrimaryKeyOnly() { // Selecting only id should return only id assertTableCommand(keyspaceName, TABLE) .templated() - .findWithExplicitProjection(Map.of("id", "u2"), "{\"id\":1}", Map.of(), Map.of()) + .findWithExplicitProjection(Map.of("id", "u2"), Map.of("id", 1), Map.of(), Map.of()) .wasSuccessful() .hasProjectionSchema() .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) @@ -142,7 +142,7 @@ public void exclusionProjectsAllButExcludedColumns() { // Exclude a single column via raw projection JSON assertTableCommand(keyspaceName, TABLE) .templated() - .findWithExplicitProjection(Map.of("id", "u1"), "{\"active\":0}", Map.of(), Map.of()) + .findWithExplicitProjection(Map.of("id", "u1"), Map.of("active", 0), Map.of(), Map.of()) .wasSuccessful() .hasProjectionSchema() .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) @@ -205,7 +205,7 @@ public void projectUdtTopLevel() { // Project the entire UDT column assertTableCommand(keyspaceName, TABLE) .templated() - .findWithExplicitProjection(Map.of("id", "r1"), "{\"address\":1}", Map.of(), Map.of()) + .findWithExplicitProjection(Map.of("id", "r1"), Map.of("address", 1), Map.of(), Map.of()) .wasSuccessful() .hasProjectionSchema() .hasProjectionSchemaUdt("address", TYPE_NAME) @@ -227,7 +227,7 @@ public void projectUdtSubField() { assertTableCommand(keyspaceName, TABLE) .templated() .findWithExplicitProjection( - Map.of("id", "r1"), "{\"address.city\":1}", Map.of(), Map.of()) + Map.of("id", "r1"), Map.of("address.city", 1), Map.of(), Map.of()) .wasSuccessful() .hasProjectionSchema() .hasProjectionSchemaUdt("address", TYPE_NAME) @@ -249,7 +249,7 @@ public void projectUdtTopLevelOverridesSubfield() { assertTableCommand(keyspaceName, TABLE) .templated() .findWithExplicitProjection( - Map.of("id", "r1"), "{\"address\":1,\"address.city\":1}", Map.of(), Map.of()) + Map.of("id", "r1"), Map.of("address", 1, "address.city", 1), Map.of(), Map.of()) .wasSuccessful() .hasProjectionSchema() .hasProjectionSchemaUdt("address", TYPE_NAME) @@ -271,7 +271,7 @@ public void excludeUdtSubfieldProjectsRemainingFields() { assertTableCommand(keyspaceName, TABLE) .templated() .findWithExplicitProjection( - Map.of("id", "r1"), "{\"address.city\":0}", Map.of(), Map.of()) + Map.of("id", "r1"), Map.of("address.city", 0), Map.of(), Map.of()) .wasSuccessful() .hasProjectionSchema() .hasProjectionSchemaUdt("address", TYPE_NAME) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionSchemaIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionSchemaIntegrationTest.java index c8bc05dd21..a0d0171e67 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionSchemaIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionSchemaIntegrationTest.java @@ -137,10 +137,6 @@ public void findManyProjectionMissingColumns() { assertTableCommand(keyspaceName, TABLE_NAME) .templated() .find(Map.of("id", "row-1"), List.of("id", "MISSING_COLUMN"), Map.of(), Map.of()) - .wasSuccessful() - .hasProjectionSchema() - .hasDocuments(1) - .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) .hasSingleApiError( ProjectionException.Code.UNKNOWN_TABLE_COLUMNS, ProjectionException.class, diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/TableTemplates.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/TableTemplates.java index 8345beb044..045303bdf6 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/TableTemplates.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/TableTemplates.java @@ -43,7 +43,7 @@ private String findClause( private String findClause( Map filter, - String projectionJSON, + Map projection, Map sort, Map options) { @@ -51,8 +51,8 @@ private String findClause( if (filter != null) { clause.put("filter", filter); } - if (projectionJSON != null) { - clause.put("projection", projectionJSON); + if (projection != null) { + clause.put("projection", projection); } if (sort != null) { clause.put("sort", sort); @@ -133,10 +133,10 @@ public DataApiResponseValidator find( */ public DataApiResponseValidator findWithExplicitProjection( Map filter, - String projectionJSON, + Map projection, Map sort, Map options) { - return sender.postFind(findClause(filter, projectionJSON, sort, options)); + return sender.postFind(findClause(filter, projection, sort, options)); } public DataApiResponseValidator find(String filter) { From 697f7cb38b3a57273780133a1f9f0b67468babb6 Mon Sep 17 00:00:00 2001 From: Yuqi Du Date: Fri, 17 Oct 2025 12:55:17 -0700 Subject: [PATCH 4/7] [test1] IT --- .../v1/tables/ProjectionIntegrationTest.java | 352 +++++++++--------- 1 file changed, 175 insertions(+), 177 deletions(-) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java index 5264c633d8..d76f44be42 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java @@ -5,9 +5,7 @@ import io.quarkus.test.common.WithTestResource; import io.quarkus.test.junit.QuarkusIntegrationTest; -import io.stargate.sgv2.jsonapi.service.schema.tables.ApiDataTypeDefs; import io.stargate.sgv2.jsonapi.testresource.DseTestResource; -import java.util.List; import java.util.Map; import org.junit.jupiter.api.*; @@ -16,190 +14,190 @@ @TestClassOrder(ClassOrderer.OrderAnnotation.class) public class ProjectionIntegrationTest extends AbstractTableIntegrationTestBase { - @Nested - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) - class Scalars { + private static final String TYPE_NAME = "Address"; + private static final String TABLE = "proj_udt"; - private static final String TABLE = "proj_basic"; + @BeforeAll + public static void createTypeAndTable() { + // Create UDT type: Address(city text, country text) + assertNamespaceCommand(keyspaceName) + .templated() + .createType(TYPE_NAME, Map.of("city", "text", "country", "text")) + .wasSuccessful(); - @BeforeAll - public static void createTable() { - assertNamespaceCommand(keyspaceName) - .templated() - .createTable( - TABLE, - Map.ofEntries( - Map.entry("id", "text"), - Map.entry("name", "text"), - Map.entry("age", "int"), - Map.entry("active", "boolean")), - "id") - .wasSuccessful(); + // Create table with udt column + assertNamespaceCommand(keyspaceName) + .templated() + .createTable( + TABLE, + Map.ofEntries( + Map.entry("id", "text"), + Map.entry("address", Map.of("type", "userDefined", "udtName", TYPE_NAME))), + "id") + .wasSuccessful(); - // seed a couple of rows - assertTableCommand(keyspaceName, TABLE) - .templated() - .insertOne( - """ - { - "id": "u1", - "name": "Ada", - "age": 42, - "active": true - } - """) - .wasSuccessful() - .hasInsertedIds(List.of("u1")); + // Insert one row + String row1 = + """ + { + "id": "r1", + "address": {"city": "New York", "country": "USA"} + } + """; - assertTableCommand(keyspaceName, TABLE) - .templated() - .insertOne( - """ - { - "id": "u2", - "name": "Bob", - "age": 25, - "active": false - } - """) - .wasSuccessful() - .hasInsertedIds(List.of("u2")); - } - - @Test - public void inclusionProjectsOnlySelectedColumns() { - // Projects only name and age - assertTableCommand(keyspaceName, TABLE) - .templated() - .findWithExplicitProjection( - Map.of("id", "u1"), Map.of("name", 1, "age", 1), Map.of(), Map.of()) - .wasSuccessful() - .hasProjectionSchema() - .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) - .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) - .doesNotHaveProjectionSchemaWith("active") - .doesNotHaveProjectionSchemaWith("id") - .hasDocuments(1) - .hasDocumentInPosition( - 0, - """ - { - "name": "Ada", - "age": 42 - } - """); - } - - @Test - public void noProjectionSelectsAllColumns() { - // No projection clause should return all columns - assertTableCommand(keyspaceName, TABLE) - .templated() - .findWithExplicitProjection(Map.of("id", "u1"), null, null, null) - .wasSuccessful() - .hasProjectionSchema() - .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) - .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) - .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) - .hasProjectionSchemaWith("active", ApiDataTypeDefs.BOOLEAN) - .hasDocuments(1) - .hasDocumentInPosition( - 0, - """ - { - "id": "u1", - "name": "Ada", - "age": 42, - "active": true - } - """); - } - - @Test - public void selectPrimaryKeyOnly() { - // Selecting only id should return only id - assertTableCommand(keyspaceName, TABLE) - .templated() - .findWithExplicitProjection(Map.of("id", "u2"), Map.of("id", 1), Map.of(), Map.of()) - .wasSuccessful() - .hasProjectionSchema() - .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) - .doesNotHaveProjectionSchemaWith("name") - .doesNotHaveProjectionSchemaWith("age") - .doesNotHaveProjectionSchemaWith("active") - .hasDocuments(1) - .hasDocumentInPosition( - 0, - """ - { - "id": "u2" - } - """); - } - - @Test - public void exclusionProjectsAllButExcludedColumns() { - // Exclude a single column via raw projection JSON - assertTableCommand(keyspaceName, TABLE) - .templated() - .findWithExplicitProjection(Map.of("id", "u1"), Map.of("active", 0), Map.of(), Map.of()) - .wasSuccessful() - .hasProjectionSchema() - .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) - .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) - .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) - .doesNotHaveProjectionSchemaWith("active") - .hasDocuments(1) - .hasDocumentInPosition( - 0, - """ - { - "id": "u1", - "name": "Ada", - "age": 42 - } - """); - } + assertTableCommand(keyspaceName, TABLE).templated().insertOne(row1).wasSuccessful(); } + // + // @Nested + // class Scalars { + // + // private static final String TABLE = "proj_basic"; + // + // @BeforeAll + // public static void createTable() { + // assertNamespaceCommand(keyspaceName) + // .templated() + // .createTable( + // TABLE, + // Map.ofEntries( + // Map.entry("id", "text"), + // Map.entry("name", "text"), + // Map.entry("age", "int"), + // Map.entry("active", "boolean")), + // "id") + // .wasSuccessful(); + // + // // seed a couple of rows + // assertTableCommand(keyspaceName, TABLE) + // .templated() + // .insertOne( + // """ + // { + // "id": "u1", + // "name": "Ada", + // "age": 42, + // "active": true + // } + // """) + // .wasSuccessful() + // .hasInsertedIds(List.of("u1")); + // + // assertTableCommand(keyspaceName, TABLE) + // .templated() + // .insertOne( + // """ + // { + // "id": "u2", + // "name": "Bob", + // "age": 25, + // "active": false + // } + // """) + // .wasSuccessful() + // .hasInsertedIds(List.of("u2")); + // } + // + // @Test + // public void inclusionProjectsOnlySelectedColumns() { + // // Projects only name and age + // assertTableCommand(keyspaceName, TABLE) + // .templated() + // .findWithExplicitProjection( + // Map.of("id", "u1"), Map.of("name", 1, "age", 1), Map.of(), Map.of()) + // .wasSuccessful() + // .hasProjectionSchema() + // .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) + // .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) + // .doesNotHaveProjectionSchemaWith("active") + // .doesNotHaveProjectionSchemaWith("id") + // .hasDocuments(1) + // .hasDocumentInPosition( + // 0, + // """ + // { + // "name": "Ada", + // "age": 42 + // } + // """); + // } + // + // @Test + // public void noProjectionSelectsAllColumns() { + // // No projection clause should return all columns + // assertTableCommand(keyspaceName, TABLE) + // .templated() + // .findWithExplicitProjection(Map.of("id", "u1"), null, null, null) + // .wasSuccessful() + // .hasProjectionSchema() + // .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) + // .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) + // .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) + // .hasProjectionSchemaWith("active", ApiDataTypeDefs.BOOLEAN) + // .hasDocuments(1) + // .hasDocumentInPosition( + // 0, + // """ + // { + // "id": "u1", + // "name": "Ada", + // "age": 42, + // "active": true + // } + // """); + // } + // + // @Test + // public void selectPrimaryKeyOnly() { + // // Selecting only id should return only id + // assertTableCommand(keyspaceName, TABLE) + // .templated() + // .findWithExplicitProjection(Map.of("id", "u2"), Map.of("id", 1), Map.of(), Map.of()) + // .wasSuccessful() + // .hasProjectionSchema() + // .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) + // .doesNotHaveProjectionSchemaWith("name") + // .doesNotHaveProjectionSchemaWith("age") + // .doesNotHaveProjectionSchemaWith("active") + // .hasDocuments(1) + // .hasDocumentInPosition( + // 0, + // """ + // { + // "id": "u2" + // } + // """); + // } + // + // @Test + // public void exclusionProjectsAllButExcludedColumns() { + // // Exclude a single column via raw projection JSON + // assertTableCommand(keyspaceName, TABLE) + // .templated() + // .findWithExplicitProjection(Map.of("id", "u1"), Map.of("active", 0), Map.of(), + // Map.of()) + // .wasSuccessful() + // .hasProjectionSchema() + // .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) + // .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) + // .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) + // .doesNotHaveProjectionSchemaWith("active") + // .hasDocuments(1) + // .hasDocumentInPosition( + // 0, + // """ + // { + // "id": "u1", + // "name": "Ada", + // "age": 42 + // } + // """); + // } + // } + @Nested - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class UdtProjectionTest { - private static final String TYPE_NAME = "Address"; - private static final String TABLE = "proj_udt"; - - @BeforeAll - public static void createTypeAndTable() { - // Create UDT type: Address(city text, country text) - assertNamespaceCommand(keyspaceName) - .templated() - .createType(TYPE_NAME, Map.of("city", "text", "country", "text")) - .wasSuccessful(); - - // Create table with udt column - assertNamespaceCommand(keyspaceName) - .templated() - .createTable( - TABLE, - Map.ofEntries( - Map.entry("id", "text"), - Map.entry("address", Map.of("type", "userDefined", "udtName", TYPE_NAME))), - "id") - .wasSuccessful(); - - // Insert one row - String row1 = - """ - { - "id": "r1", - "address": {"city": "New York", "country": "USA"} - } - """; - - assertTableCommand(keyspaceName, TABLE).templated().insertOne(row1).wasSuccessful(); - } - @Test public void projectUdtTopLevel() { // Project the entire UDT column From 9a7d5a8ee4dbd9f2acf98b5ea61e7f2dc3b6398b Mon Sep 17 00:00:00 2001 From: Yuqi Du Date: Fri, 17 Oct 2025 13:03:35 -0700 Subject: [PATCH 5/7] [test2] IT --- ...onIntegrationTest.java => ProjectionABCIntegrationTest.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/{ProjectionIntegrationTest.java => ProjectionABCIntegrationTest.java} (99%) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java similarity index 99% rename from src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java rename to src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java index d76f44be42..da4f43ea9b 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java @@ -12,7 +12,7 @@ @QuarkusIntegrationTest @WithTestResource(value = DseTestResource.class) @TestClassOrder(ClassOrderer.OrderAnnotation.class) -public class ProjectionIntegrationTest extends AbstractTableIntegrationTestBase { +public class ProjectionABCIntegrationTest extends AbstractTableIntegrationTestBase { private static final String TYPE_NAME = "Address"; private static final String TABLE = "proj_udt"; From 0baa14926025ef6f7c4ed04a30fa2e4b61247b62 Mon Sep 17 00:00:00 2001 From: Yuqi Du Date: Fri, 17 Oct 2025 13:16:48 -0700 Subject: [PATCH 6/7] Integration Test class/inner class name can not have test keyword --- .../tables/ProjectionABCIntegrationTest.java | 348 +++++++++--------- 1 file changed, 174 insertions(+), 174 deletions(-) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java index da4f43ea9b..1321a6088e 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java @@ -5,7 +5,9 @@ import io.quarkus.test.common.WithTestResource; import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.stargate.sgv2.jsonapi.service.schema.tables.ApiDataTypeDefs; import io.stargate.sgv2.jsonapi.testresource.DseTestResource; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.*; @@ -17,186 +19,184 @@ public class ProjectionABCIntegrationTest extends AbstractTableIntegrationTestBa private static final String TYPE_NAME = "Address"; private static final String TABLE = "proj_udt"; - @BeforeAll - public static void createTypeAndTable() { - // Create UDT type: Address(city text, country text) - assertNamespaceCommand(keyspaceName) - .templated() - .createType(TYPE_NAME, Map.of("city", "text", "country", "text")) - .wasSuccessful(); + @Nested + class Scalars { - // Create table with udt column - assertNamespaceCommand(keyspaceName) - .templated() - .createTable( - TABLE, - Map.ofEntries( - Map.entry("id", "text"), - Map.entry("address", Map.of("type", "userDefined", "udtName", TYPE_NAME))), - "id") - .wasSuccessful(); + private static final String TABLE = "proj_basic"; - // Insert one row - String row1 = - """ - { - "id": "r1", - "address": {"city": "New York", "country": "USA"} - } - """; + @BeforeAll + public static void createTable() { + assertNamespaceCommand(keyspaceName) + .templated() + .createTable( + TABLE, + Map.ofEntries( + Map.entry("id", "text"), + Map.entry("name", "text"), + Map.entry("age", "int"), + Map.entry("active", "boolean")), + "id") + .wasSuccessful(); - assertTableCommand(keyspaceName, TABLE).templated().insertOne(row1).wasSuccessful(); - } + // seed a couple of rows + assertTableCommand(keyspaceName, TABLE) + .templated() + .insertOne( + """ + { + "id": "u1", + "name": "Ada", + "age": 42, + "active": true + } + """) + .wasSuccessful() + .hasInsertedIds(List.of("u1")); - // - // @Nested - // class Scalars { - // - // private static final String TABLE = "proj_basic"; - // - // @BeforeAll - // public static void createTable() { - // assertNamespaceCommand(keyspaceName) - // .templated() - // .createTable( - // TABLE, - // Map.ofEntries( - // Map.entry("id", "text"), - // Map.entry("name", "text"), - // Map.entry("age", "int"), - // Map.entry("active", "boolean")), - // "id") - // .wasSuccessful(); - // - // // seed a couple of rows - // assertTableCommand(keyspaceName, TABLE) - // .templated() - // .insertOne( - // """ - // { - // "id": "u1", - // "name": "Ada", - // "age": 42, - // "active": true - // } - // """) - // .wasSuccessful() - // .hasInsertedIds(List.of("u1")); - // - // assertTableCommand(keyspaceName, TABLE) - // .templated() - // .insertOne( - // """ - // { - // "id": "u2", - // "name": "Bob", - // "age": 25, - // "active": false - // } - // """) - // .wasSuccessful() - // .hasInsertedIds(List.of("u2")); - // } - // - // @Test - // public void inclusionProjectsOnlySelectedColumns() { - // // Projects only name and age - // assertTableCommand(keyspaceName, TABLE) - // .templated() - // .findWithExplicitProjection( - // Map.of("id", "u1"), Map.of("name", 1, "age", 1), Map.of(), Map.of()) - // .wasSuccessful() - // .hasProjectionSchema() - // .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) - // .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) - // .doesNotHaveProjectionSchemaWith("active") - // .doesNotHaveProjectionSchemaWith("id") - // .hasDocuments(1) - // .hasDocumentInPosition( - // 0, - // """ - // { - // "name": "Ada", - // "age": 42 - // } - // """); - // } - // - // @Test - // public void noProjectionSelectsAllColumns() { - // // No projection clause should return all columns - // assertTableCommand(keyspaceName, TABLE) - // .templated() - // .findWithExplicitProjection(Map.of("id", "u1"), null, null, null) - // .wasSuccessful() - // .hasProjectionSchema() - // .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) - // .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) - // .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) - // .hasProjectionSchemaWith("active", ApiDataTypeDefs.BOOLEAN) - // .hasDocuments(1) - // .hasDocumentInPosition( - // 0, - // """ - // { - // "id": "u1", - // "name": "Ada", - // "age": 42, - // "active": true - // } - // """); - // } - // - // @Test - // public void selectPrimaryKeyOnly() { - // // Selecting only id should return only id - // assertTableCommand(keyspaceName, TABLE) - // .templated() - // .findWithExplicitProjection(Map.of("id", "u2"), Map.of("id", 1), Map.of(), Map.of()) - // .wasSuccessful() - // .hasProjectionSchema() - // .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) - // .doesNotHaveProjectionSchemaWith("name") - // .doesNotHaveProjectionSchemaWith("age") - // .doesNotHaveProjectionSchemaWith("active") - // .hasDocuments(1) - // .hasDocumentInPosition( - // 0, - // """ - // { - // "id": "u2" - // } - // """); - // } - // - // @Test - // public void exclusionProjectsAllButExcludedColumns() { - // // Exclude a single column via raw projection JSON - // assertTableCommand(keyspaceName, TABLE) - // .templated() - // .findWithExplicitProjection(Map.of("id", "u1"), Map.of("active", 0), Map.of(), - // Map.of()) - // .wasSuccessful() - // .hasProjectionSchema() - // .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) - // .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) - // .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) - // .doesNotHaveProjectionSchemaWith("active") - // .hasDocuments(1) - // .hasDocumentInPosition( - // 0, - // """ - // { - // "id": "u1", - // "name": "Ada", - // "age": 42 - // } - // """); - // } - // } + assertTableCommand(keyspaceName, TABLE) + .templated() + .insertOne( + """ + { + "id": "u2", + "name": "Bob", + "age": 25, + "active": false + } + """) + .wasSuccessful() + .hasInsertedIds(List.of("u2")); + } + + @Test + public void inclusionProjectsOnlySelectedColumns() { + // Projects only name and age + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection( + Map.of("id", "u1"), Map.of("name", 1, "age", 1), Map.of(), Map.of()) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) + .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) + .doesNotHaveProjectionSchemaWith("active") + .doesNotHaveProjectionSchemaWith("id") + .hasDocuments(1) + .hasDocumentInPosition( + 0, + """ + { + "name": "Ada", + "age": 42 + } + """); + } + + @Test + public void noProjectionSelectsAllColumns() { + // No projection clause should return all columns + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection(Map.of("id", "u1"), null, null, null) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) + .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) + .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) + .hasProjectionSchemaWith("active", ApiDataTypeDefs.BOOLEAN) + .hasDocuments(1) + .hasDocumentInPosition( + 0, + """ + { + "id": "u1", + "name": "Ada", + "age": 42, + "active": true + } + """); + } + + @Test + public void selectPrimaryKeyOnly() { + // Selecting only id should return only id + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection(Map.of("id", "u2"), Map.of("id", 1), Map.of(), Map.of()) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) + .doesNotHaveProjectionSchemaWith("name") + .doesNotHaveProjectionSchemaWith("age") + .doesNotHaveProjectionSchemaWith("active") + .hasDocuments(1) + .hasDocumentInPosition( + 0, + """ + { + "id": "u2" + } + """); + } + + @Test + public void exclusionProjectsAllButExcludedColumns() { + // Exclude a single column via raw projection JSON + assertTableCommand(keyspaceName, TABLE) + .templated() + .findWithExplicitProjection(Map.of("id", "u1"), Map.of("active", 0), Map.of(), Map.of()) + .wasSuccessful() + .hasProjectionSchema() + .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) + .hasProjectionSchemaWith("name", ApiDataTypeDefs.TEXT) + .hasProjectionSchemaWith("age", ApiDataTypeDefs.INT) + .doesNotHaveProjectionSchemaWith("active") + .hasDocuments(1) + .hasDocumentInPosition( + 0, + """ + { + "id": "u1", + "name": "Ada", + "age": 42 + } + """); + } + } @Nested - class UdtProjectionTest { + class UdtProjection { + + @BeforeAll + public static void createTypeAndTable() { + // Create UDT type: Address(city text, country text) + assertNamespaceCommand(keyspaceName) + .templated() + .createType(TYPE_NAME, Map.of("city", "text", "country", "text")) + .wasSuccessful(); + + // Create table with udt column + assertNamespaceCommand(keyspaceName) + .templated() + .createTable( + TABLE, + Map.ofEntries( + Map.entry("id", "text"), + Map.entry("address", Map.of("type", "userDefined", "udtName", TYPE_NAME))), + "id") + .wasSuccessful(); + + // Insert one row + String row1 = + """ + { + "id": "r1", + "address": {"city": "New York", "country": "USA"} + } + """; + + assertTableCommand(keyspaceName, TABLE).templated().insertOne(row1).wasSuccessful(); + } @Test public void projectUdtTopLevel() { From 6d5557eab7c819f853b6b2646184430ee6abeb14 Mon Sep 17 00:00:00 2001 From: Yuqi Du Date: Tue, 21 Oct 2025 10:34:32 -0700 Subject: [PATCH 7/7] fix IT, Exclude a UDT sub-field; remaining UDT fields should be returned --- .../jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java index 1321a6088e..1f0a70b8cd 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/ProjectionABCIntegrationTest.java @@ -272,6 +272,7 @@ public void excludeUdtSubfieldProjectsRemainingFields() { Map.of("id", "r1"), Map.of("address.city", 0), Map.of(), Map.of()) .wasSuccessful() .hasProjectionSchema() + .hasProjectionSchemaWith("id", ApiDataTypeDefs.TEXT) .hasProjectionSchemaUdt("address", TYPE_NAME) .hasProjectionSchemaUdtField("address", "country", "text") .doesNotHaveProjectionSchemaUdtField("address", "city") @@ -280,6 +281,7 @@ public void excludeUdtSubfieldProjectsRemainingFields() { 0, """ { + "id": "r1", "address": {"country": "USA"} } """);