From eccf7866e294fe1f405e2ce9206187b20c91969e Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Tue, 12 May 2026 16:33:18 -0700 Subject: [PATCH 01/22] Initial "alterCollection" implementation --- .../api/model/command/CollectionCommand.java | 1 + .../api/model/command/CommandName.java | 2 + .../command/impl/AlterCollectionCommand.java | 37 +++ .../jsonapi/api/v1/CollectionResource.java | 2 + .../jsonapi/exception/SchemaException.java | 1 + .../AlterCollectionLexicalOperation.java | 143 ++++++++ .../AlterCollectionCommandResolver.java | 118 +++++++ src/main/resources/errors.yaml | 10 +- ...rCollectionWithLexicalIntegrationTest.java | 311 ++++++++++++++++++ 9 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CollectionCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CollectionCommand.java index 0c456b4acc..138a9e94db 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CollectionCommand.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CollectionCommand.java @@ -21,6 +21,7 @@ @JsonSubTypes.Type(value = InsertOneCommand.class), @JsonSubTypes.Type(value = UpdateManyCommand.class), @JsonSubTypes.Type(value = UpdateOneCommand.class), + @JsonSubTypes.Type(value = AlterCollectionCommand.class), // We have only collection resource that is used for API Tables @JsonSubTypes.Type(value = AlterTableCommand.class), @JsonSubTypes.Type(value = CreateIndexCommand.class), diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandName.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandName.java index 5c522d5fd2..68998b2346 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandName.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandName.java @@ -14,6 +14,7 @@ public enum CommandName { // they should not be DDL, they are not changing schema, we should add an CommandType.ADMIN for // them ? + ALTER_COLLECTION(Names.ALTER_COLLECTION, CommandType.DDL, CommandTarget.COLLECTION), ALTER_TABLE(Names.ALTER_TABLE, CommandType.DDL, CommandTarget.TABLE), ALTER_TYPE(Names.ALTER_TYPE, CommandType.DDL, CommandTarget.TABLE), COUNT_DOCUMENTS(Names.COUNT_DOCUMENTS, CommandType.DML, CommandTarget.COLLECTION), @@ -107,6 +108,7 @@ public static List filterByTarget(CommandTarget target) { } public interface Names { + String ALTER_COLLECTION = "alterCollection"; String ALTER_TABLE = "alterTable"; String ALTER_TYPE = "alterType"; String COUNT_DOCUMENTS = "countDocuments"; diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java new file mode 100644 index 0000000000..09cc91a443 --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java @@ -0,0 +1,37 @@ +package io.stargate.sgv2.jsonapi.api.model.command.impl; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.stargate.sgv2.jsonapi.api.model.command.CollectionCommand; +import io.stargate.sgv2.jsonapi.api.model.command.CommandName; +import jakarta.validation.Valid; +import javax.annotation.Nullable; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema( + description = + "Command that alters mutable settings of an existing collection. Initial phase supports enabling the 'lexical' feature only.") +@JsonTypeName(CommandName.Names.ALTER_COLLECTION) +public record AlterCollectionCommand( + @Valid + @JsonInclude(JsonInclude.Include.NON_NULL) + @Nullable + @Schema( + description = + "Lexical configuration to apply. Currently only enabling lexical is supported ('enabled' must be true).", + type = SchemaType.OBJECT, + implementation = CreateCollectionCommand.Options.LexicalConfigDefinition.class) + CreateCollectionCommand.Options.LexicalConfigDefinition lexical) + implements CollectionCommand { + + @Override + public CommandName commandName() { + return CommandName.ALTER_COLLECTION; + } + + @Override + public boolean isForceSchemaRefresh() { + return true; + } +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResource.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResource.java index 7fbbe62563..34b622fd1e 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResource.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResource.java @@ -7,6 +7,7 @@ import io.smallrye.mutiny.Uni; import io.stargate.sgv2.jsonapi.ConfigPreLoader; import io.stargate.sgv2.jsonapi.api.model.command.*; +import io.stargate.sgv2.jsonapi.api.model.command.impl.AlterCollectionCommand; import io.stargate.sgv2.jsonapi.api.model.command.impl.AlterTableCommand; import io.stargate.sgv2.jsonapi.api.model.command.impl.CountDocumentsCommand; import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateIndexCommand; @@ -138,6 +139,7 @@ public CollectionResource( InsertManyCommand.class, UpdateManyCommand.class, UpdateOneCommand.class, + AlterCollectionCommand.class, // Table Only commands AlterTableCommand.class, CreateIndexCommand.class, diff --git a/src/main/java/io/stargate/sgv2/jsonapi/exception/SchemaException.java b/src/main/java/io/stargate/sgv2/jsonapi/exception/SchemaException.java index ce0af3608e..dd7d045ebd 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/exception/SchemaException.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/exception/SchemaException.java @@ -42,6 +42,7 @@ public enum Code implements ErrorCode { EXISTING_COLLECTION_DIFFERENT_SETTINGS, EXISTING_TABLE_NOT_DATA_API_COLLECTION, // converted from ErrorCodeV1 + INVALID_ALTER_COLLECTION_OPTIONS, INVALID_CREATE_COLLECTION_OPTIONS, INVALID_FORMAT_FOR_INDEX_CREATION_COLUMN, INVALID_INDEXING_DEFINITION, diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java new file mode 100644 index 0000000000..c9fa936d35 --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -0,0 +1,143 @@ +package io.stargate.sgv2.jsonapi.service.operation.collections; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.smallrye.mutiny.Uni; +import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; +import io.stargate.sgv2.jsonapi.api.model.command.CommandResult; +import io.stargate.sgv2.jsonapi.api.request.RequestContext; +import io.stargate.sgv2.jsonapi.config.constants.TableCommentConstants; +import io.stargate.sgv2.jsonapi.service.cqldriver.executor.QueryExecutor; +import io.stargate.sgv2.jsonapi.service.operation.Operation; +import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionLexicalConfig; +import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionSchemaObject; +import java.time.Duration; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Operation that enables the lexical feature on an existing collection by adding the {@code + * query_lexical_value} column, creating an analyzed SAI index on it, and updating the table + * "comment" JSON to record the new lexical config. + * + *

When {@link #noOp} is true the operation returns success without executing any DDL: this is + * the "already enabled with same settings" case. + * + *

On partial failure (e.g. column added but index creation failed) earlier steps are not rolled + * back; the failure is propagated to the caller, matching {@link CreateCollectionOperation}'s + * behavior. The command is safe to retry once the underlying issue is resolved. + */ +public record AlterCollectionLexicalOperation( + CommandContext commandContext, + ObjectMapper objectMapper, + int ddlDelayMillis, + CollectionLexicalConfig newLexicalConfig, + boolean noOp) + implements Operation { + + private static final Logger LOGGER = + LoggerFactory.getLogger(AlterCollectionLexicalOperation.class); + + private static final CqlIdentifier COMMENT_OPTION = CqlIdentifier.fromInternal("comment"); + + @Override + public Uni> execute( + RequestContext requestContext, QueryExecutor queryExecutor) { + + if (noOp) { + return Uni.createFrom().>item(new SchemaChangeResult(true)); + } + + final CollectionSchemaObject schemaObject = commandContext.schemaObject(); + final String keyspace = schemaObject.tableMetadata().getKeyspace().asInternal(); + final String table = schemaObject.tableMetadata().getName().asInternal(); + + final String newComment; + try { + newComment = buildUpdatedComment(schemaObject); + } catch (Exception e) { + return Uni.createFrom().failure(e); + } + + final JsonNode analyzerDef = newLexicalConfig.analyzerDefinition(); + final String analyzerString = + analyzerDef.isTextual() ? analyzerDef.asText() : analyzerDef.toString(); + + SimpleStatement addColumnStmt = + SimpleStatement.newInstance( + "ALTER TABLE \"%s\".\"%s\" ADD query_lexical_value text".formatted(keyspace, table)); + + SimpleStatement createIndexStmt = + SimpleStatement.newInstance( + ("CREATE CUSTOM INDEX IF NOT EXISTS \"%s_query_lexical_value\"" + + " ON \"%s\".\"%s\" (query_lexical_value)" + + " USING 'StorageAttachedIndex'" + + " WITH OPTIONS = { 'index_analyzer': '%s' }") + .formatted(table, keyspace, table, analyzerString)); + + SimpleStatement alterCommentStmt = + SimpleStatement.builder( + "ALTER TABLE \"%s\".\"%s\" WITH comment = ?".formatted(keyspace, table)) + .addPositionalValue(newComment) + .build(); + + final Duration delay = Duration.ofMillis(ddlDelayMillis > 0 ? ddlDelayMillis : 100); + + return queryExecutor + .executeCreateSchemaChange(requestContext, addColumnStmt) + .onItem() + .delayIt() + .by(delay) + .onItem() + .transformToUni( + r1 -> queryExecutor.executeCreateSchemaChange(requestContext, createIndexStmt)) + .onItem() + .delayIt() + .by(delay) + .onItem() + .transformToUni( + r2 -> queryExecutor.executeCreateSchemaChange(requestContext, alterCommentStmt)) + .map(r3 -> new SchemaChangeResult(true)); + } + + /** + * Reads the current table comment JSON and surgically replaces the {@code + * collection.options.lexical} sub-node, leaving all other options (vector / indexing / id / + * rerank / unknown fields) untouched. + * + *

The resolver guarantees we are operating on a V1-shaped comment (legacy/V0 collections are + * rejected before reaching the operation). + */ + private String buildUpdatedComment(CollectionSchemaObject schemaObject) throws Exception { + final Object commentObj = schemaObject.tableMetadata().getOptions().get(COMMENT_OPTION); + final String comment = commentObj == null ? null : commentObj.toString(); + if (comment == null || comment.isBlank()) { + // Defensive: resolver should have rejected this case. + throw new IllegalStateException( + "Cannot alter collection: table comment is empty; expected V1 schema"); + } + + final ObjectNode rootNode = (ObjectNode) objectMapper.readTree(comment); + final ObjectNode collectionNode = + (ObjectNode) rootNode.get(TableCommentConstants.TOP_LEVEL_KEY); + if (collectionNode == null) { + throw new IllegalStateException( + "Cannot alter collection: comment does not have '" + + TableCommentConstants.TOP_LEVEL_KEY + + "' node"); + } + ObjectNode optionsNode = (ObjectNode) collectionNode.get(TableCommentConstants.OPTIONS_KEY); + if (optionsNode == null) { + optionsNode = objectMapper.createObjectNode(); + collectionNode.set(TableCommentConstants.OPTIONS_KEY, optionsNode); + } + optionsNode.set( + TableCommentConstants.COLLECTION_LEXICAL_CONFIG_KEY, + objectMapper.valueToTree(newLexicalConfig)); + return objectMapper.writeValueAsString(rootNode); + } +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java new file mode 100644 index 0000000000..5013ec969e --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java @@ -0,0 +1,118 @@ +package io.stargate.sgv2.jsonapi.service.resolver; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +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.impl.AlterCollectionCommand; +import io.stargate.sgv2.jsonapi.config.OperationsConfig; +import io.stargate.sgv2.jsonapi.config.constants.TableCommentConstants; +import io.stargate.sgv2.jsonapi.config.feature.ApiFeature; +import io.stargate.sgv2.jsonapi.exception.SchemaException; +import io.stargate.sgv2.jsonapi.service.operation.Operation; +import io.stargate.sgv2.jsonapi.service.operation.collections.AlterCollectionLexicalOperation; +import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionLexicalConfig; +import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionSchemaObject; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Map; +import java.util.Objects; + +@ApplicationScoped +public class AlterCollectionCommandResolver implements CommandResolver { + + private static final CqlIdentifier COMMENT_OPTION = CqlIdentifier.fromInternal("comment"); + + private final ObjectMapper objectMapper; + + @Inject + public AlterCollectionCommandResolver(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public Class getCommandClass() { + return AlterCollectionCommand.class; + } + + @Override + public Operation resolveCollectionCommand( + CommandContext ctx, AlterCollectionCommand command) { + + if (command.lexical() == null) { + throw SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.get( + Map.of("message", "must specify 'lexical' field")); + } + + final boolean lexicalAvailableForDB = ctx.apiFeatures().isFeatureEnabled(ApiFeature.LEXICAL); + + // validateAndConstruct throws: + // - LEXICAL_NOT_AVAILABLE_FOR_DATABASE if requested.enabled && !lexicalAvailableForDB + // - INVALID_CREATE_COLLECTION_OPTIONS for malformed analyzer / missing 'enabled' / etc. + final CollectionLexicalConfig requested = + CollectionLexicalConfig.validateAndConstruct( + objectMapper, lexicalAvailableForDB, command.lexical()); + + // Phase 1: disabling lexical is not supported. + if (!requested.enabled()) { + throw SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.get( + Map.of( + "message", + "'lexical.enabled' must be true; alterCollection cannot disable lexical search")); + } + + // Reject legacy / pre-lexical collections: must have a V1 comment with collection.options. + final String rawComment = readTableComment(ctx.schemaObject()); + if (isLegacyComment(rawComment)) { + throw SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.get( + Map.of( + "message", + "collection has legacy metadata (pre-lexical schema); recreate the collection to enable lexical")); + } + + final CollectionLexicalConfig current = ctx.schemaObject().lexicalConfig(); + final int ddlDelayMillis = + ctx.config().get(OperationsConfig.class).databaseConfig().ddlDelayMillis(); + + if (current.enabled()) { + if (analyzersEqual(current.analyzerDefinition(), requested.analyzerDefinition())) { + // Same settings already in effect: no-op success. + return new AlterCollectionLexicalOperation( + ctx, objectMapper, ddlDelayMillis, requested, /* noOp */ true); + } + throw SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.get( + Map.of( + "message", + "lexical is already enabled for this collection with a different analyzer configuration")); + } + + return new AlterCollectionLexicalOperation( + ctx, objectMapper, ddlDelayMillis, requested, /* noOp */ false); + } + + private static String readTableComment(CollectionSchemaObject schemaObject) { + final Object commentObj = schemaObject.tableMetadata().getOptions().get(COMMENT_OPTION); + return commentObj == null ? null : commentObj.toString(); + } + + private boolean isLegacyComment(String rawComment) { + if (rawComment == null || rawComment.isBlank()) { + return true; + } + try { + JsonNode root = objectMapper.readTree(rawComment); + JsonNode optionsNode = + root.path(TableCommentConstants.TOP_LEVEL_KEY).path(TableCommentConstants.OPTIONS_KEY); + return optionsNode.isMissingNode() || !optionsNode.isObject(); + } catch (Exception e) { + return true; + } + } + + private static boolean analyzersEqual(JsonNode a, JsonNode b) { + if (a == null || b == null) { + return a == b; + } + return Objects.equals(a, b); + } +} diff --git a/src/main/resources/errors.yaml b/src/main/resources/errors.yaml index f3408c38e1..7da67acd1e 100644 --- a/src/main/resources/errors.yaml +++ b/src/main/resources/errors.yaml @@ -1368,12 +1368,20 @@ request-errors: Resend the command using only columns that use the `vector` type. + - scope: SCHEMA + code: INVALID_ALTER_COLLECTION_OPTIONS + title: Invalid options for alterCollection + body: |- + 'alterCollection' command option(s) invalid: ${message} + + Resend 'alterCollection' with valid options. + - scope: SCHEMA code: INVALID_CREATE_COLLECTION_OPTIONS title: Invalid options for createCollection body: |- 'createCollection' command option(s) invalid: ${message} - + Resend 'createCollection' with valid options. - scope: SCHEMA diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java new file mode 100644 index 0000000000..d3c2f850ab --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java @@ -0,0 +1,311 @@ +package io.stargate.sgv2.jsonapi.api.v1; + +import static io.restassured.RestAssured.given; +import static io.stargate.sgv2.jsonapi.api.v1.ResponseAssertions.responseIsDDLSuccess; +import static io.stargate.sgv2.jsonapi.api.v1.ResponseAssertions.responseIsError; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +import io.quarkus.test.common.WithTestResource; +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.restassured.http.ContentType; +import io.restassured.response.ValidatableResponse; +import io.stargate.sgv2.jsonapi.exception.SchemaException; +import io.stargate.sgv2.jsonapi.testresource.DseTestResource; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; + +@QuarkusIntegrationTest +@WithTestResource(value = DseTestResource.class) +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +class AlterCollectionWithLexicalIntegrationTest extends AbstractKeyspaceIntegrationTestBase { + + @Nested + @Order(1) + class AlterCollectionEnableLexicalHappyPath { + + @Test + void enableLexicalDefaultAnalyzerOnDisabledCollection() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexicalDisabled(name); + + String json = + """ + { + "alterCollection": { + "lexical": { "enabled": true } + } + } + """; + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsDDLSuccess()) + .body("status.ok", is(1)); + + // Sanity check: lexical insert/find should now work via $lexical sort. + String insertOk = + """ + { + "insertOne": { + "document": { "_id": "doc1", "$lexical": "hello world" } + } + } + """; + postToCollection(name, insertOk) + .statusCode(200) + .body("errors", is(org.hamcrest.Matchers.nullValue())); + + String find = + """ + { + "findOne": { + "sort": { "$lexical": "hello" } + } + } + """; + postToCollection(name, find) + .statusCode(200) + .body("errors", is(org.hamcrest.Matchers.nullValue())) + .body("data.document._id", is("doc1")); + + deleteCollection(name); + } + + @Test + void enableLexicalCustomAnalyzerOnDisabledCollection() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexicalDisabled(name); + + String json = + """ + { + "alterCollection": { + "lexical": { + "enabled": true, + "analyzer": { "tokenizer": { "name": "whitespace" } } + } + } + } + """; + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsDDLSuccess()) + .body("status.ok", is(1)); + + deleteCollection(name); + } + + @Test + void enableLexicalAlreadyEnabledSameSettingsIsNoOp() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + // Create with lexical enabled, default analyzer. + createCollectionWithLexical(name, "{ \"enabled\": true, \"analyzer\": \"standard\" }"); + + String json = + """ + { + "alterCollection": { + "lexical": { "enabled": true, "analyzer": "standard" } + } + } + """; + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsDDLSuccess()) + .body("status.ok", is(1)); + + deleteCollection(name); + } + } + + @Nested + @Order(2) + class AlterCollectionLexicalFail { + + @Test + void failEnableLexicalDifferentAnalyzer() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexical(name, "{ \"enabled\": true, \"analyzer\": \"standard\" }"); + + String json = + """ + { + "alterCollection": { + "lexical": { + "enabled": true, + "analyzer": { "tokenizer": { "name": "whitespace" } } + } + } + } + """; + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsError()) + .body( + "errors[0].errorCode", + is(SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.name())) + .body("errors[0].message", containsString("different analyzer configuration")); + + deleteCollection(name); + } + + @Test + void failDisableLexical() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexical(name, "{ \"enabled\": true }"); + + String json = + """ + { + "alterCollection": { + "lexical": { "enabled": false } + } + } + """; + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsError()) + .body( + "errors[0].errorCode", + is(SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.name())) + .body("errors[0].message", containsString("cannot disable lexical")); + + deleteCollection(name); + } + + @Test + void failUnknownRootField() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexicalDisabled(name); + + String json = + """ + { + "alterCollection": { + "lexical": { "enabled": true }, + "unknownField": 1 + } + } + """; + // Jackson rejects the unknown root property; we just assert an error is returned. + postToCollection(name, json).statusCode(200).body("$", responseIsError()); + + deleteCollection(name); + } + + @Test + void failMissingLexical() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexicalDisabled(name); + + String json = + """ + { + "alterCollection": { } + } + """; + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsError()) + .body( + "errors[0].errorCode", + is(SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.name())) + .body("errors[0].message", containsString("must specify 'lexical' field")); + + deleteCollection(name); + } + + @Test + void failEnableWhenLexicalNotAvailableForDB() { + Assumptions.assumeFalse(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexicalDisabled(name); + + String json = + """ + { + "alterCollection": { + "lexical": { "enabled": true } + } + } + """; + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsError()) + .body( + "errors[0].errorCode", + is(SchemaException.Code.LEXICAL_NOT_AVAILABLE_FOR_DATABASE.name())); + + deleteCollection(name); + } + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + private static String freshCollectionName() { + return "alter_lex_" + RandomStringUtils.insecure().nextAlphanumeric(12); + } + + private void createCollectionWithLexicalDisabled(String collectionName) { + createCollectionWithLexical(collectionName, "{ \"enabled\": false }"); + } + + private void createCollectionWithLexical(String collectionName, String lexicalDef) { + String body = + """ + { + "createCollection": { + "name": "%s", + "options": { + "lexical": %s + } + } + } + """ + .formatted(collectionName, lexicalDef); + given() + .port(getTestPort()) + .headers(getHeaders()) + .contentType(ContentType.JSON) + .body(body) + .when() + .post(KeyspaceResource.BASE_PATH, keyspaceName) + .then() + .statusCode(200) + .body("$", responseIsDDLSuccess()) + .body("status.ok", is(1)); + } + + private ValidatableResponse postToCollection(String collectionName, String json) { + return given() + .port(getTestPort()) + .headers(getHeaders()) + .contentType(ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, keyspaceName, collectionName) + .then(); + } +} From fcd47f061dadb7630eadff4e17bc9c8f02e13c84 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Tue, 12 May 2026 16:41:46 -0700 Subject: [PATCH 02/22] Fix DDL wrt comment --- .../collections/AlterCollectionLexicalOperation.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index c9fa936d35..4fec611be1 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -79,11 +79,13 @@ public Uni> execute( + " WITH OPTIONS = { 'index_analyzer': '%s' }") .formatted(table, keyspace, table, analyzerString)); + // Cassandra does not accept bind parameters for table options like `comment`; embed the + // JSON directly with CQL single-quote escaping (matches + // CreateCollectionOperation.getCreateTable). SimpleStatement alterCommentStmt = - SimpleStatement.builder( - "ALTER TABLE \"%s\".\"%s\" WITH comment = ?".formatted(keyspace, table)) - .addPositionalValue(newComment) - .build(); + SimpleStatement.newInstance( + "ALTER TABLE \"%s\".\"%s\" WITH comment = '%s'" + .formatted(keyspace, table, newComment.replace("'", "''"))); final Duration delay = Duration.ofMillis(ddlDelayMillis > 0 ? ddlDelayMillis : 100); From 5cdd3a8050536e2a3550d4240a91c00fd62b9bb2 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Tue, 12 May 2026 17:15:53 -0700 Subject: [PATCH 03/22] IT fix --- .../sgv2/jsonapi/api/v1/CollectionResourceIntegrationTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResourceIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResourceIntegrationTest.java index c2f9c2e49e..4a00a37cf3 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResourceIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResourceIntegrationTest.java @@ -77,7 +77,8 @@ public void unknownCommand() { "Command 'unknownCommand' is not a Collection Command recognized by Data API.")) .body( "errors[0].message", - containsString("Data API supports following Collection Commands: [alterTable,")); + containsString( + "Data API supports following Collection Commands: [alterCollection, alterTable,")); } @Test From bc96db1c702f8a677aaadac94fdd282284206cc7 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Wed, 13 May 2026 09:30:15 -0700 Subject: [PATCH 04/22] Add teets --- .../command/impl/AlterCollectionCommand.java | 4 +- .../AlterCollectionCommandResolver.java | 7 +- .../collections/CollectionLexicalConfig.java | 34 +++++-- ...rCollectionWithLexicalIntegrationTest.java | 97 +++++++++++++++++++ 4 files changed, 132 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java index 09cc91a443..49f4c552e0 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java @@ -11,7 +11,7 @@ @Schema( description = - "Command that alters mutable settings of an existing collection. Initial phase supports enabling the 'lexical' feature only.") + "Command that alters mutable settings of an existing collection. Currently supports enabling the 'lexical' feature.") @JsonTypeName(CommandName.Names.ALTER_COLLECTION) public record AlterCollectionCommand( @Valid @@ -19,7 +19,7 @@ public record AlterCollectionCommand( @Nullable @Schema( description = - "Lexical configuration to apply. Currently only enabling lexical is supported ('enabled' must be true).", + "Lexical configuration to apply. Currently only enabling is supported ('enabled' must be true).", type = SchemaType.OBJECT, implementation = CreateCollectionCommand.Options.LexicalConfigDefinition.class) CreateCollectionCommand.Options.LexicalConfigDefinition lexical) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java index 5013ec969e..8481bb14d4 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java @@ -48,10 +48,13 @@ public Operation resolveCollectionCommand( // validateAndConstruct throws: // - LEXICAL_NOT_AVAILABLE_FOR_DATABASE if requested.enabled && !lexicalAvailableForDB - // - INVALID_CREATE_COLLECTION_OPTIONS for malformed analyzer / missing 'enabled' / etc. + // - INVALID_ALTER_COLLECTION_OPTIONS for malformed analyzer / missing 'enabled' / etc. final CollectionLexicalConfig requested = CollectionLexicalConfig.validateAndConstruct( - objectMapper, lexicalAvailableForDB, command.lexical()); + objectMapper, + lexicalAvailableForDB, + command.lexical(), + SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS); // Phase 1: disabling lexical is not supported. if (!requested.enabled()) { diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionLexicalConfig.java b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionLexicalConfig.java index 87299985da..5888f983de 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionLexicalConfig.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionLexicalConfig.java @@ -72,7 +72,11 @@ public CollectionLexicalConfig(boolean enabled, JsonNode analyzerDefinition) { /** * Method for validating the lexical config passed and constructing actual configuration object to - * use. + * use. Invalid-option errors are reported as {@link + * SchemaException.Code#INVALID_CREATE_COLLECTION_OPTIONS}; use {@link + * #validateAndConstruct(ObjectMapper, boolean, + * CreateCollectionCommand.Options.LexicalConfigDefinition, SchemaException.Code)} from commands + * other than {@code createCollection}. * * @return Valid CollectionLexicalConfig object */ @@ -80,6 +84,24 @@ public static CollectionLexicalConfig validateAndConstruct( ObjectMapper mapper, boolean lexicalAvailableForDB, CreateCollectionCommand.Options.LexicalConfigDefinition lexicalConfig) { + return validateAndConstruct( + mapper, + lexicalAvailableForDB, + lexicalConfig, + SchemaException.Code.INVALID_CREATE_COLLECTION_OPTIONS); + } + + /** + * Same as {@link #validateAndConstruct(ObjectMapper, boolean, + * CreateCollectionCommand.Options.LexicalConfigDefinition)} but lets the caller specify which + * {@link SchemaException.Code} to use for invalid-option errors so the reported error is + * attributed to the invoking command (e.g. {@code alterCollection}). + */ + public static CollectionLexicalConfig validateAndConstruct( + ObjectMapper mapper, + boolean lexicalAvailableForDB, + CreateCollectionCommand.Options.LexicalConfigDefinition lexicalConfig, + SchemaException.Code optionsErrorCode) { // Case 1: No lexical body provided - use defaults if available, otherwise disable if (lexicalConfig == null) { return lexicalAvailableForDB ? configForDefault() : configForDisabled(); @@ -88,7 +110,7 @@ public static CollectionLexicalConfig validateAndConstruct( // Case 2: Validate 'enabled' flag is present Boolean enabled = lexicalConfig.enabled(); if (enabled == null) { - throw SchemaException.Code.INVALID_CREATE_COLLECTION_OPTIONS.get( + throw optionsErrorCode.get( "message", "'enabled' is required property for 'lexical' Object value"); } @@ -106,7 +128,7 @@ public static CollectionLexicalConfig validateAndConstruct( if (!enabled) { if (!analyzerNotDefined) { String nodeType = JsonUtil.nodeTypeAsString(analyzerDef); - throw SchemaException.Code.INVALID_CREATE_COLLECTION_OPTIONS.get( + throw optionsErrorCode.get( "message", ("'lexical' is disabled, but 'lexical.analyzer' property was provided with an unexpected type: %s. " + "When 'lexical' is disabled, 'lexical.analyzer' must either be omitted or be JSON null, or an empty Object '{ }'.") @@ -135,7 +157,7 @@ public static CollectionLexicalConfig validateAndConstruct( // First: check for any invalid (misspelled etc) fields foundNames.removeAll(VALID_ANALYZER_FIELDS); if (!foundNames.isEmpty()) { - throw SchemaException.Code.INVALID_CREATE_COLLECTION_OPTIONS.get( + throw optionsErrorCode.get( "message", "Invalid field%s for 'lexical.analyzer'. Valid fields are: %s, found: %s" .formatted( @@ -163,7 +185,7 @@ public static CollectionLexicalConfig validateAndConstruct( } }; if (!valueOk) { - throw SchemaException.Code.INVALID_CREATE_COLLECTION_OPTIONS.get( + throw optionsErrorCode.get( "message", "'%s' property of 'lexical.analyzer' must be JSON %s, is: %s" .formatted(entry.getKey(), expectedType, JsonUtil.nodeTypeAsString(fieldValue))); @@ -171,7 +193,7 @@ public static CollectionLexicalConfig validateAndConstruct( } } else { // Otherwise, invalid definition - throw SchemaException.Code.INVALID_CREATE_COLLECTION_OPTIONS.get( + throw optionsErrorCode.get( "message", "'analyzer' property of 'lexical' must be either JSON Object or String, is: %s" .formatted(JsonUtil.nodeTypeAsString(analyzerDef))); diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java index d3c2f850ab..f4a8c72f22 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java @@ -234,6 +234,103 @@ void failMissingLexical() { deleteCollection(name); } + // Malformed `lexical` body must yield INVALID_ALTER_COLLECTION_OPTIONS (not the + // createCollection variant). Mirrors the equivalent CreateCollectionWithLexicalIntegrationTest + // failure cases, but with the alterCollection-specific error code. + @Test + void failMissingEnabledFlag() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexicalDisabled(name); + + String json = + """ + { + "alterCollection": { + "lexical": { } + } + } + """; + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsError()) + .body( + "errors[0].errorCode", + is(SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.name())) + .body( + "errors[0].message", + containsString("'enabled' is required property for 'lexical' Object value")); + + deleteCollection(name); + } + + @Test + void failAnalyzerWrongJsonType() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexicalDisabled(name); + + String json = + """ + { + "alterCollection": { + "lexical": { + "enabled": true, + "analyzer": [1, 2, 3] + } + } + } + """; + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsError()) + .body( + "errors[0].errorCode", + is(SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.name())) + .body( + "errors[0].message", + containsString( + "'analyzer' property of 'lexical' must be either JSON Object or String, is: Array")); + + deleteCollection(name); + } + + @Test + void failAnalyzerMisspelledField() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexicalDisabled(name); + + String json = + """ + { + "alterCollection": { + "lexical": { + "enabled": true, + "analyzer": { + "tokeniser": { "name": "standard" } + } + } + } + } + """; + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsError()) + .body( + "errors[0].errorCode", + is(SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.name())) + .body( + "errors[0].message", + containsString( + "Invalid field for 'lexical.analyzer'. Valid fields are: [charFilters, filters, tokenizer], found: [tokeniser]")); + + deleteCollection(name); + } + @Test void failEnableWhenLexicalNotAvailableForDB() { Assumptions.assumeFalse(isLexicalAvailableForDB()); From 4bd750bcf7945d51aabb4c7249e7485088414bfe Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Wed, 13 May 2026 09:32:21 -0700 Subject: [PATCH 05/22] Simplify --- .../CreateCollectionCommandResolver.java | 5 +++- .../collections/CollectionLexicalConfig.java | 27 +++---------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateCollectionCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateCollectionCommandResolver.java index e6e7230bd2..743a0bd2e4 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateCollectionCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateCollectionCommandResolver.java @@ -102,7 +102,10 @@ public Operation resolveKeyspaceCommand( CreateCollectionCommand.Options.VectorSearchConfig vector = options.vector(); final CollectionLexicalConfig lexicalConfig = CollectionLexicalConfig.validateAndConstruct( - objectMapper, lexicalAvailableForDB, options.lexical()); + objectMapper, + lexicalAvailableForDB, + options.lexical(), + SchemaException.Code.INVALID_CREATE_COLLECTION_OPTIONS); final CollectionRerankDef rerankDef = CollectionRerankDef.fromApiDesc( diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionLexicalConfig.java b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionLexicalConfig.java index 5888f983de..eb1414000c 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionLexicalConfig.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionLexicalConfig.java @@ -71,32 +71,13 @@ public CollectionLexicalConfig(boolean enabled, JsonNode analyzerDefinition) { } /** - * Method for validating the lexical config passed and constructing actual configuration object to - * use. Invalid-option errors are reported as {@link - * SchemaException.Code#INVALID_CREATE_COLLECTION_OPTIONS}; use {@link - * #validateAndConstruct(ObjectMapper, boolean, - * CreateCollectionCommand.Options.LexicalConfigDefinition, SchemaException.Code)} from commands - * other than {@code createCollection}. + * Validates the lexical config passed and constructs the runtime configuration object to use. + * Invalid-option errors are reported with {@code optionsErrorCode} so they get attributed to the + * invoking command (e.g. {@code INVALID_CREATE_COLLECTION_OPTIONS} from {@code createCollection}, + * {@code INVALID_ALTER_COLLECTION_OPTIONS} from {@code alterCollection}). * * @return Valid CollectionLexicalConfig object */ - public static CollectionLexicalConfig validateAndConstruct( - ObjectMapper mapper, - boolean lexicalAvailableForDB, - CreateCollectionCommand.Options.LexicalConfigDefinition lexicalConfig) { - return validateAndConstruct( - mapper, - lexicalAvailableForDB, - lexicalConfig, - SchemaException.Code.INVALID_CREATE_COLLECTION_OPTIONS); - } - - /** - * Same as {@link #validateAndConstruct(ObjectMapper, boolean, - * CreateCollectionCommand.Options.LexicalConfigDefinition)} but lets the caller specify which - * {@link SchemaException.Code} to use for invalid-option errors so the reported error is - * attributed to the invoking command (e.g. {@code alterCollection}). - */ public static CollectionLexicalConfig validateAndConstruct( ObjectMapper mapper, boolean lexicalAvailableForDB, From 91bde100bed6357153aa2c78f825b7b5846d436b Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Wed, 13 May 2026 09:41:18 -0700 Subject: [PATCH 06/22] More testing --- .../AlterCollectionLexicalOperation.java | 6 +- ...rCollectionWithLexicalIntegrationTest.java | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index 4fec611be1..66613d85cd 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -16,8 +16,6 @@ import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionSchemaObject; import java.time.Duration; import java.util.function.Supplier; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Operation that enables the lexical feature on an existing collection by adding the {@code @@ -39,9 +37,6 @@ public record AlterCollectionLexicalOperation( boolean noOp) implements Operation { - private static final Logger LOGGER = - LoggerFactory.getLogger(AlterCollectionLexicalOperation.class); - private static final CqlIdentifier COMMENT_OPTION = CqlIdentifier.fromInternal("comment"); @Override @@ -127,6 +122,7 @@ private String buildUpdatedComment(CollectionSchemaObject schemaObject) throws E final ObjectNode collectionNode = (ObjectNode) rootNode.get(TableCommentConstants.TOP_LEVEL_KEY); if (collectionNode == null) { + // Defensive: resolver should have rejected this case. throw new IllegalStateException( "Cannot alter collection: comment does not have '" + TableCommentConstants.TOP_LEVEL_KEY diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java index f4a8c72f22..d31e64775f 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java @@ -3,6 +3,7 @@ import static io.restassured.RestAssured.given; import static io.stargate.sgv2.jsonapi.api.v1.ResponseAssertions.responseIsDDLSuccess; import static io.stargate.sgv2.jsonapi.api.v1.ResponseAssertions.responseIsError; +import static net.javacrumbs.jsonunit.JsonMatchers.jsonEquals; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; @@ -104,6 +105,95 @@ void enableLexicalCustomAnalyzerOnDisabledCollection() { deleteCollection(name); } + // Locks in the surgical-replace contract of buildUpdatedComment: when alterCollection enables + // lexical, all other previously-configured collection options (vector, indexing, defaultId, + // rerank) must remain unchanged in the stored comment. + @Test + void preservesOtherOptionsAcrossAlter() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + String createBody = + """ + { + "createCollection": { + "name": "%s", + "options": { + "defaultId": { "type": "objectId" }, + "vector": { "dimension": 5, "metric": "cosine" }, + "indexing": { "deny": ["comment"] }, + "lexical": { "enabled": false }, + "rerank": { "enabled": false } + } + } + } + """ + .formatted(name); + given() + .port(getTestPort()) + .headers(getHeaders()) + .contentType(ContentType.JSON) + .body(createBody) + .when() + .post(KeyspaceResource.BASE_PATH, keyspaceName) + .then() + .statusCode(200) + .body("$", responseIsDDLSuccess()) + .body("status.ok", is(1)); + + // Enable lexical via alterCollection. + String alterBody = + """ + { + "alterCollection": { + "lexical": { "enabled": true } + } + } + """; + postToCollection(name, alterBody) + .statusCode(200) + .body("$", responseIsDDLSuccess()) + .body("status.ok", is(1)); + + // Verify via findCollections + explain that everything except lexical is unchanged, + // and that lexical has flipped to enabled with the default analyzer. + String expected = + """ + { + "name": "%s", + "options": { + "defaultId": { "type": "objectId" }, + "vector": { "dimension": 5, "metric": "cosine", "sourceModel": "other" }, + "indexing": { "deny": ["comment"] }, + "lexical": { "enabled": true, "analyzer": "standard" }, + "rerank": { "enabled": false } + } + } + """ + .formatted(name); + given() + .port(getTestPort()) + .headers(getHeaders()) + .contentType(ContentType.JSON) + .body( + """ + { + "findCollections": { + "options": { "explain": true } + } + } + """) + .when() + .post(KeyspaceResource.BASE_PATH, keyspaceName) + .then() + .statusCode(200) + .body("$", responseIsDDLSuccess()) + .body( + "status.collections.find { it.name == '%s' }".formatted(name), jsonEquals(expected)); + + deleteCollection(name); + } + @Test void enableLexicalAlreadyEnabledSameSettingsIsNoOp() { Assumptions.assumeTrue(isLexicalAvailableForDB()); From 2ad227ee2906dbab6d4d9150e084d8b090433630 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Wed, 13 May 2026 09:56:13 -0700 Subject: [PATCH 07/22] Use shared constants --- .../AlterCollectionLexicalOperation.java | 46 +++++++++++++------ .../AlterCollectionCommandResolver.java | 26 +++++++---- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index 66613d85cd..ea6f3795dc 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -1,7 +1,9 @@ package io.stargate.sgv2.jsonapi.service.operation.collections; import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.AsyncResultSet; import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.fasterxml.jackson.core.JacksonException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -9,6 +11,7 @@ import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.CommandResult; import io.stargate.sgv2.jsonapi.api.request.RequestContext; +import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.config.constants.TableCommentConstants; import io.stargate.sgv2.jsonapi.service.cqldriver.executor.QueryExecutor; import io.stargate.sgv2.jsonapi.service.operation.Operation; @@ -39,11 +42,17 @@ public record AlterCollectionLexicalOperation( private static final CqlIdentifier COMMENT_OPTION = CqlIdentifier.fromInternal("comment"); + private static final CqlIdentifier LEXICAL_COLUMN = + CqlIdentifier.fromInternal(DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME); + @Override public Uni> execute( RequestContext requestContext, QueryExecutor queryExecutor) { if (noOp) { + // Type witness needed: Mutiny's item(T) and item(Supplier) overloads otherwise + // both match SchemaChangeResult (which is a Supplier), and inference picks + // the wrong T. return Uni.createFrom().>item(new SchemaChangeResult(true)); } @@ -54,7 +63,7 @@ public Uni> execute( final String newComment; try { newComment = buildUpdatedComment(schemaObject); - } catch (Exception e) { + } catch (JacksonException e) { return Uni.createFrom().failure(e); } @@ -62,9 +71,9 @@ public Uni> execute( final String analyzerString = analyzerDef.isTextual() ? analyzerDef.asText() : analyzerDef.toString(); - SimpleStatement addColumnStmt = - SimpleStatement.newInstance( - "ALTER TABLE \"%s\".\"%s\" ADD query_lexical_value text".formatted(keyspace, table)); + // Idempotent for retry after partial failure: skip ADD COLUMN if the column already exists. + final boolean columnAlreadyExists = + schemaObject.tableMetadata().getColumn(LEXICAL_COLUMN).isPresent(); SimpleStatement createIndexStmt = SimpleStatement.newInstance( @@ -84,14 +93,25 @@ public Uni> execute( final Duration delay = Duration.ofMillis(ddlDelayMillis > 0 ? ddlDelayMillis : 100); - return queryExecutor - .executeCreateSchemaChange(requestContext, addColumnStmt) - .onItem() - .delayIt() - .by(delay) - .onItem() - .transformToUni( - r1 -> queryExecutor.executeCreateSchemaChange(requestContext, createIndexStmt)) + Uni pipeline; + if (columnAlreadyExists) { + pipeline = queryExecutor.executeCreateSchemaChange(requestContext, createIndexStmt); + } else { + SimpleStatement addColumnStmt = + SimpleStatement.newInstance( + "ALTER TABLE \"%s\".\"%s\" ADD query_lexical_value text".formatted(keyspace, table)); + pipeline = + queryExecutor + .executeCreateSchemaChange(requestContext, addColumnStmt) + .onItem() + .delayIt() + .by(delay) + .onItem() + .transformToUni( + r1 -> queryExecutor.executeCreateSchemaChange(requestContext, createIndexStmt)); + } + + return pipeline .onItem() .delayIt() .by(delay) @@ -109,7 +129,7 @@ public Uni> execute( *

The resolver guarantees we are operating on a V1-shaped comment (legacy/V0 collections are * rejected before reaching the operation). */ - private String buildUpdatedComment(CollectionSchemaObject schemaObject) throws Exception { + private String buildUpdatedComment(CollectionSchemaObject schemaObject) throws JacksonException { final Object commentObj = schemaObject.tableMetadata().getOptions().get(COMMENT_OPTION); final String comment = commentObj == null ? null : commentObj.toString(); if (comment == null || comment.isBlank()) { diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java index 8481bb14d4..e3f77dc51b 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java @@ -6,6 +6,7 @@ import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.impl.AlterCollectionCommand; import io.stargate.sgv2.jsonapi.config.OperationsConfig; +import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.config.constants.TableCommentConstants; import io.stargate.sgv2.jsonapi.config.feature.ApiFeature; import io.stargate.sgv2.jsonapi.exception.SchemaException; @@ -23,6 +24,9 @@ public class AlterCollectionCommandResolver implements CommandResolver resolveCollectionCommand( final int ddlDelayMillis = ctx.config().get(OperationsConfig.class).databaseConfig().ddlDelayMillis(); - if (current.enabled()) { - if (analyzersEqual(current.analyzerDefinition(), requested.analyzerDefinition())) { + // "Truly enabled" means both the stored comment claims lexical is on AND the underlying + // column actually exists. If the comment says enabled but the column is missing (an + // inconsistent state from manual surgery or an interrupted prior alter), treat it as + // not-enabled and run the full DDL pipeline so the table catches up to the comment. + final boolean trulyEnabled = + current.enabled() + && ctx.schemaObject().tableMetadata().getColumn(LEXICAL_COLUMN).isPresent(); + + if (trulyEnabled) { + // Both analyzer definitions are guaranteed non-null here (CollectionLexicalConfig's + // constructor requires non-null analyzer when enabled=true). JsonNode.equals is value-based, + // so this gives strict structural comparison for both string and object analyzers. + if (Objects.equals(current.analyzerDefinition(), requested.analyzerDefinition())) { // Same settings already in effect: no-op success. return new AlterCollectionLexicalOperation( ctx, objectMapper, ddlDelayMillis, requested, /* noOp */ true); @@ -111,11 +126,4 @@ private boolean isLegacyComment(String rawComment) { return true; } } - - private static boolean analyzersEqual(JsonNode a, JsonNode b) { - if (a == null || b == null) { - return a == b; - } - return Objects.equals(a, b); - } } From bfd37a10aeb9ebea31b942d25a89cc15199b06dd Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Wed, 13 May 2026 10:01:06 -0700 Subject: [PATCH 08/22] More constants use --- .../collections/AlterCollectionLexicalOperation.java | 9 +++++---- .../collections/CollectionDriverExceptionHandler.java | 5 ++++- .../collections/CreateCollectionOperation.java | 11 ++++++++--- .../collections/InsertCollectionOperation.java | 3 ++- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index ea6f3795dc..03756199f4 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -75,13 +75,14 @@ public Uni> execute( final boolean columnAlreadyExists = schemaObject.tableMetadata().getColumn(LEXICAL_COLUMN).isPresent(); + final String lexicalCol = DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME; SimpleStatement createIndexStmt = SimpleStatement.newInstance( - ("CREATE CUSTOM INDEX IF NOT EXISTS \"%s_query_lexical_value\"" - + " ON \"%s\".\"%s\" (query_lexical_value)" + ("CREATE CUSTOM INDEX IF NOT EXISTS \"%s_%s\"" + + " ON \"%s\".\"%s\" (%s)" + " USING 'StorageAttachedIndex'" + " WITH OPTIONS = { 'index_analyzer': '%s' }") - .formatted(table, keyspace, table, analyzerString)); + .formatted(table, lexicalCol, keyspace, table, lexicalCol, analyzerString)); // Cassandra does not accept bind parameters for table options like `comment`; embed the // JSON directly with CQL single-quote escaping (matches @@ -99,7 +100,7 @@ public Uni> execute( } else { SimpleStatement addColumnStmt = SimpleStatement.newInstance( - "ALTER TABLE \"%s\".\"%s\" ADD query_lexical_value text".formatted(keyspace, table)); + "ALTER TABLE \"%s\".\"%s\" ADD %s text".formatted(keyspace, table, lexicalCol)); pipeline = queryExecutor .executeCreateSchemaChange(requestContext, addColumnStmt) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CollectionDriverExceptionHandler.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CollectionDriverExceptionHandler.java index 8a02ad61cc..9738aa19b4 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CollectionDriverExceptionHandler.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CollectionDriverExceptionHandler.java @@ -5,6 +5,7 @@ import com.datastax.oss.driver.api.core.cql.SimpleStatement; import com.datastax.oss.driver.api.core.servererrors.InvalidQueryException; +import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.exception.*; import io.stargate.sgv2.jsonapi.service.cqldriver.executor.DefaultDriverExceptionHandler; import io.stargate.sgv2.jsonapi.service.operation.tables.CreateIndexExceptionHandler; @@ -52,7 +53,9 @@ public RuntimeException handle(InvalidQueryException exception) { if (exception .getMessage() .contains( - "analyzed size for column query_lexical_value exceeds the cumulative limit for index")) { + "analyzed size for column " + + DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME + + " exceeds the cumulative limit for index")) { return DocumentException.Code.LEXICAL_CONTENT_TOO_LONG.get(errVars(schemaObject, exception)); } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java index 219e285b0c..7eef7a6488 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java @@ -17,6 +17,7 @@ import io.stargate.sgv2.jsonapi.api.model.command.tracing.RequestTracing; import io.stargate.sgv2.jsonapi.api.request.RequestContext; import io.stargate.sgv2.jsonapi.config.DatabaseLimitsConfig; +import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.exception.DatabaseException; import io.stargate.sgv2.jsonapi.exception.SchemaException; import io.stargate.sgv2.jsonapi.service.cqldriver.CQLSessionCache; @@ -526,7 +527,10 @@ public static SimpleStatement getCreateTable( String comment, CollectionLexicalConfig lexicalConfig) { // The keyspace and table name are quoted to make it case-sensitive - final String lexicalField = lexicalConfig.enabled() ? " query_lexical_value text, " : ""; + final String lexicalField = + lexicalConfig.enabled() + ? " %s text, ".formatted(DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME) + : ""; if (vectorSearch) { String createTableWithVector = "CREATE TABLE IF NOT EXISTS \"%s\".\"%s\" (" @@ -647,12 +651,13 @@ public List getIndexStatements( // Note: needs to be either plain (unquoted) String (NOT quoted JSON String) OR JSON Object final String analyzerString = analyzerDef.isTextual() ? analyzerDef.asText() : analyzerDef.toString(); + final String lexicalCol = DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME; final String lexicalCreateStmt = """ - %s "%s_query_lexical_value" ON "%s"."%s" (query_lexical_value) + %s "%s_%s" ON "%s"."%s" (%s) USING 'StorageAttachedIndex' WITH OPTIONS = { 'index_analyzer': '%s' } """ - .formatted(appender, table, keyspace, table, analyzerString); + .formatted(appender, table, lexicalCol, keyspace, table, lexicalCol, analyzerString); statements.add(SimpleStatement.newInstance(lexicalCreateStmt)); } return statements; diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/InsertCollectionOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/InsertCollectionOperation.java index 491b4945cf..5f73d321d4 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/InsertCollectionOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/InsertCollectionOperation.java @@ -8,6 +8,7 @@ import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.CommandResult; import io.stargate.sgv2.jsonapi.api.request.RequestContext; +import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.exception.DocumentException; import io.stargate.sgv2.jsonapi.exception.SchemaException; import io.stargate.sgv2.jsonapi.service.cqldriver.executor.QueryExecutor; @@ -215,7 +216,7 @@ public String buildInsertQuery(boolean vectorEnabled) { insertQuery.append(", query_vector_value"); } if (lexicalEnabled) { - insertQuery.append(", query_lexical_value"); + insertQuery.append(", ").append(DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME); } insertQuery.append(") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?"); From 13402a427cc9304e645b6f5504dbcc9bab92f9ce Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Wed, 13 May 2026 10:16:08 -0700 Subject: [PATCH 09/22] Simplify AlterCollectionCommandResolver implementation --- .../AlterCollectionCommandResolver.java | 66 ++++++++----------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java index e3f77dc51b..0d3c387ddc 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java @@ -44,37 +44,29 @@ public Operation resolveCollectionCommand( CommandContext ctx, AlterCollectionCommand command) { if (command.lexical() == null) { - throw SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.get( - Map.of("message", "must specify 'lexical' field")); + throw badOptions("must specify 'lexical' field"); } - final boolean lexicalAvailableForDB = ctx.apiFeatures().isFeatureEnabled(ApiFeature.LEXICAL); - // validateAndConstruct throws: // - LEXICAL_NOT_AVAILABLE_FOR_DATABASE if requested.enabled && !lexicalAvailableForDB // - INVALID_ALTER_COLLECTION_OPTIONS for malformed analyzer / missing 'enabled' / etc. final CollectionLexicalConfig requested = CollectionLexicalConfig.validateAndConstruct( objectMapper, - lexicalAvailableForDB, + ctx.apiFeatures().isFeatureEnabled(ApiFeature.LEXICAL), command.lexical(), SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS); // Phase 1: disabling lexical is not supported. if (!requested.enabled()) { - throw SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.get( - Map.of( - "message", - "'lexical.enabled' must be true; alterCollection cannot disable lexical search")); + throw badOptions( + "'lexical.enabled' must be true; alterCollection cannot disable lexical search"); } // Reject legacy / pre-lexical collections: must have a V1 comment with collection.options. - final String rawComment = readTableComment(ctx.schemaObject()); - if (isLegacyComment(rawComment)) { - throw SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.get( - Map.of( - "message", - "collection has legacy metadata (pre-lexical schema); recreate the collection to enable lexical")); + if (isLegacyComment(ctx.schemaObject())) { + throw badOptions( + "collection has legacy metadata (pre-lexical schema); recreate the collection to enable lexical"); } final CollectionLexicalConfig current = ctx.schemaObject().lexicalConfig(); @@ -89,39 +81,39 @@ public Operation resolveCollectionCommand( current.enabled() && ctx.schemaObject().tableMetadata().getColumn(LEXICAL_COLUMN).isPresent(); - if (trulyEnabled) { - // Both analyzer definitions are guaranteed non-null here (CollectionLexicalConfig's - // constructor requires non-null analyzer when enabled=true). JsonNode.equals is value-based, - // so this gives strict structural comparison for both string and object analyzers. - if (Objects.equals(current.analyzerDefinition(), requested.analyzerDefinition())) { - // Same settings already in effect: no-op success. - return new AlterCollectionLexicalOperation( - ctx, objectMapper, ddlDelayMillis, requested, /* noOp */ true); - } - throw SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.get( - Map.of( - "message", - "lexical is already enabled for this collection with a different analyzer configuration")); + if (!trulyEnabled) { + return new AlterCollectionLexicalOperation( + ctx, objectMapper, ddlDelayMillis, requested, /* noOp */ false); } + // Both analyzer definitions are guaranteed non-null here (CollectionLexicalConfig's + // constructor requires non-null analyzer when enabled=true). JsonNode.equals is value-based, + // so this gives strict structural comparison for both string and object analyzers. + if (!Objects.equals(current.analyzerDefinition(), requested.analyzerDefinition())) { + throw badOptions( + "lexical is already enabled for this collection with a different analyzer configuration"); + } + // Same settings already in effect: no-op success. return new AlterCollectionLexicalOperation( - ctx, objectMapper, ddlDelayMillis, requested, /* noOp */ false); + ctx, objectMapper, ddlDelayMillis, requested, /* noOp */ true); } - private static String readTableComment(CollectionSchemaObject schemaObject) { - final Object commentObj = schemaObject.tableMetadata().getOptions().get(COMMENT_OPTION); - return commentObj == null ? null : commentObj.toString(); + private static SchemaException badOptions(String message) { + return SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.get(Map.of("message", message)); } - private boolean isLegacyComment(String rawComment) { - if (rawComment == null || rawComment.isBlank()) { + private boolean isLegacyComment(CollectionSchemaObject schemaObject) { + final Object commentObj = schemaObject.tableMetadata().getOptions().get(COMMENT_OPTION); + if (commentObj == null) { return true; } try { - JsonNode root = objectMapper.readTree(rawComment); JsonNode optionsNode = - root.path(TableCommentConstants.TOP_LEVEL_KEY).path(TableCommentConstants.OPTIONS_KEY); - return optionsNode.isMissingNode() || !optionsNode.isObject(); + objectMapper + .readTree(commentObj.toString()) + .path(TableCommentConstants.TOP_LEVEL_KEY) + .path(TableCommentConstants.OPTIONS_KEY); + return !optionsNode.isObject(); } catch (Exception e) { return true; } From 31dc2059566d60d5d75d589f7c6666281fba97ef Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Wed, 13 May 2026 10:22:23 -0700 Subject: [PATCH 10/22] Code de-duping --- .../AlterCollectionLexicalOperation.java | 13 ++----- .../CreateCollectionOperation.java | 36 ++++++++++++------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index 03756199f4..8aebb3fa80 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -4,7 +4,6 @@ import com.datastax.oss.driver.api.core.cql.AsyncResultSet; import com.datastax.oss.driver.api.core.cql.SimpleStatement; import com.fasterxml.jackson.core.JacksonException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import io.smallrye.mutiny.Uni; @@ -67,22 +66,14 @@ public Uni> execute( return Uni.createFrom().failure(e); } - final JsonNode analyzerDef = newLexicalConfig.analyzerDefinition(); - final String analyzerString = - analyzerDef.isTextual() ? analyzerDef.asText() : analyzerDef.toString(); - // Idempotent for retry after partial failure: skip ADD COLUMN if the column already exists. final boolean columnAlreadyExists = schemaObject.tableMetadata().getColumn(LEXICAL_COLUMN).isPresent(); final String lexicalCol = DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME; SimpleStatement createIndexStmt = - SimpleStatement.newInstance( - ("CREATE CUSTOM INDEX IF NOT EXISTS \"%s_%s\"" - + " ON \"%s\".\"%s\" (%s)" - + " USING 'StorageAttachedIndex'" - + " WITH OPTIONS = { 'index_analyzer': '%s' }") - .formatted(table, lexicalCol, keyspace, table, lexicalCol, analyzerString)); + CreateCollectionOperation.buildLexicalIndexStatement( + keyspace, table, newLexicalConfig, /* ifNotExists */ true); // Cassandra does not accept bind parameters for table options like `comment`; embed the // JSON directly with CQL single-quote escaping (matches diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java index 7eef7a6488..aaa66b6e67 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java @@ -647,19 +647,31 @@ public List getIndexStatements( } if (lexicalConfig.enabled()) { - var analyzerDef = lexicalConfig.analyzerDefinition(); - // Note: needs to be either plain (unquoted) String (NOT quoted JSON String) OR JSON Object - final String analyzerString = - analyzerDef.isTextual() ? analyzerDef.asText() : analyzerDef.toString(); - final String lexicalCol = DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME; - final String lexicalCreateStmt = - """ - %s "%s_%s" ON "%s"."%s" (%s) - USING 'StorageAttachedIndex' WITH OPTIONS = { 'index_analyzer': '%s' } - """ - .formatted(appender, table, lexicalCol, keyspace, table, lexicalCol, analyzerString); - statements.add(SimpleStatement.newInstance(lexicalCreateStmt)); + statements.add(buildLexicalIndexStatement(keyspace, table, lexicalConfig, collectionExisted)); } return statements; } + + /** + * Builds the {@code CREATE CUSTOM INDEX} statement for the lexical column, used both by + * createCollection (when the table is fresh or being recreated) and by alterCollection (when + * enabling lexical on an existing collection). + * + * @param ifNotExists when true, emits {@code IF NOT EXISTS} for idempotent retries + */ + public static SimpleStatement buildLexicalIndexStatement( + String keyspace, String table, CollectionLexicalConfig lexicalConfig, boolean ifNotExists) { + var analyzerDef = lexicalConfig.analyzerDefinition(); + // Note: needs to be either plain (unquoted) String (NOT quoted JSON String) OR JSON Object + final String analyzerString = + analyzerDef.isTextual() ? analyzerDef.asText() : analyzerDef.toString(); + final String lexicalCol = DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME; + final String prefix = ifNotExists ? "CREATE CUSTOM INDEX IF NOT EXISTS" : "CREATE CUSTOM INDEX"; + return SimpleStatement.newInstance( + """ + %s "%s_%s" ON "%s"."%s" (%s) + USING 'StorageAttachedIndex' WITH OPTIONS = { 'index_analyzer': '%s' } + """ + .formatted(prefix, table, lexicalCol, keyspace, table, lexicalCol, analyzerString)); + } } From eaf2ea690eff0f0758b82ae22cf0bcedacd6e607 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Wed, 13 May 2026 10:27:11 -0700 Subject: [PATCH 11/22] Minor reuse add --- .../collections/AlterCollectionLexicalOperation.java | 5 ++--- .../resolver/CreateCollectionCommandResolver.java | 12 +++++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index 8aebb3fa80..748be54ac7 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -14,6 +14,7 @@ import io.stargate.sgv2.jsonapi.config.constants.TableCommentConstants; import io.stargate.sgv2.jsonapi.service.cqldriver.executor.QueryExecutor; import io.stargate.sgv2.jsonapi.service.operation.Operation; +import io.stargate.sgv2.jsonapi.service.resolver.CreateCollectionCommandResolver; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionLexicalConfig; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionSchemaObject; import java.time.Duration; @@ -145,9 +146,7 @@ private String buildUpdatedComment(CollectionSchemaObject schemaObject) throws J optionsNode = objectMapper.createObjectNode(); collectionNode.set(TableCommentConstants.OPTIONS_KEY, optionsNode); } - optionsNode.set( - TableCommentConstants.COLLECTION_LEXICAL_CONFIG_KEY, - objectMapper.valueToTree(newLexicalConfig)); + CreateCollectionCommandResolver.addLexicalToOptionsNode(optionsNode, newLexicalConfig); return objectMapper.writeValueAsString(rootNode); } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateCollectionCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateCollectionCommandResolver.java index 743a0bd2e4..65142b727f 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateCollectionCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateCollectionCommandResolver.java @@ -209,7 +209,7 @@ public static String generateComment( } // Store Lexical Config as-is: - optionsNode.putPOJO(TableCommentConstants.COLLECTION_LEXICAL_CONFIG_KEY, lexicalConfig); + addLexicalToOptionsNode(optionsNode, lexicalConfig); // Store Reranking Config as-is: optionsNode.putPOJO(TableCommentConstants.COLLECTION_RERANKING_CONFIG_KEY, rerankDef); @@ -223,6 +223,16 @@ public static String generateComment( return tableCommentNode.toString(); } + /** + * Writes the lexical config entry into a collection's options node. Shared between {@link + * #generateComment} (when building a fresh comment for createCollection) and {@code + * AlterCollectionLexicalOperation} (when updating the lexical sub-node of an existing comment). + */ + public static void addLexicalToOptionsNode( + ObjectNode optionsNode, CollectionLexicalConfig lexicalConfig) { + optionsNode.putPOJO(TableCommentConstants.COLLECTION_LEXICAL_CONFIG_KEY, lexicalConfig); + } + /** * Validates the vector search options provided in a create collection command. It checks if * vector search is enabled globally, and validates the specific vectorization service From aa3cf0a754ada7f7712ad2b21d899eba9a745818 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Wed, 13 May 2026 10:36:33 -0700 Subject: [PATCH 12/22] Comment, error message improvements --- .../AlterCollectionLexicalOperation.java | 17 ++++++++++++++--- .../AlterCollectionCommandResolver.java | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index 748be54ac7..ea0c9c1c9a 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -28,9 +28,20 @@ *

When {@link #noOp} is true the operation returns success without executing any DDL: this is * the "already enabled with same settings" case. * - *

On partial failure (e.g. column added but index creation failed) earlier steps are not rolled - * back; the failure is propagated to the caller, matching {@link CreateCollectionOperation}'s - * behavior. The command is safe to retry once the underlying issue is resolved. + *

No rollback on partial failure. If e.g. ADD COLUMN succeeds but CREATE INDEX fails, the + * column is left in place and the failure is propagated to the caller. This matches {@link + * CreateCollectionOperation}'s behavior and is intentional: + * + *

    + *
  • Reverse DDL (DROP COLUMN, DROP INDEX) is itself fallible — a rollback that fails leaves the + * schema in a worse state than the original partial failure and obscures the root cause. + *
  • The operation is designed to be retry-safe: ADD COLUMN is skipped when the column already + * exists, CREATE INDEX uses {@code IF NOT EXISTS}, and ALTER TABLE WITH comment is naturally + * idempotent. Re-issuing the same {@code alterCollection} command after the underlying issue + * is resolved completes the unfinished steps without re-running the finished ones. + *
  • Users get a consistent mental model with {@code createCollection}, which has the same + * partial-failure semantics. + *
*/ public record AlterCollectionLexicalOperation( CommandContext commandContext, diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java index 0d3c387ddc..51913b312f 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java @@ -66,7 +66,7 @@ public Operation resolveCollectionCommand( // Reject legacy / pre-lexical collections: must have a V1 comment with collection.options. if (isLegacyComment(ctx.schemaObject())) { throw badOptions( - "collection has legacy metadata (pre-lexical schema); recreate the collection to enable lexical"); + "collection has legacy metadata (pre-lexical schema); recreate the collection with lexical enabled"); } final CollectionLexicalConfig current = ctx.schemaObject().lexicalConfig(); From bed999529c72438a572249236800b6096b6e4593 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Wed, 13 May 2026 10:41:31 -0700 Subject: [PATCH 13/22] One last minor simplification --- .../collections/AlterCollectionLexicalOperation.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index ea0c9c1c9a..07e9edcff9 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -82,7 +82,6 @@ public Uni> execute( final boolean columnAlreadyExists = schemaObject.tableMetadata().getColumn(LEXICAL_COLUMN).isPresent(); - final String lexicalCol = DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME; SimpleStatement createIndexStmt = CreateCollectionOperation.buildLexicalIndexStatement( keyspace, table, newLexicalConfig, /* ifNotExists */ true); @@ -103,7 +102,8 @@ public Uni> execute( } else { SimpleStatement addColumnStmt = SimpleStatement.newInstance( - "ALTER TABLE \"%s\".\"%s\" ADD %s text".formatted(keyspace, table, lexicalCol)); + "ALTER TABLE \"%s\".\"%s\" ADD %s text" + .formatted(keyspace, table, DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME)); pipeline = queryExecutor .executeCreateSchemaChange(requestContext, addColumnStmt) From 28a2b6f37990736755fa9573c71251ac6eb324b5 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 14 May 2026 13:46:53 -0700 Subject: [PATCH 14/22] Refactoring to "alterTable" style (approach #2) --- .../command/impl/AlterCollectionCommand.java | 18 +-- .../impl/AlterCollectionOperation.java | 13 ++ .../impl/AlterCollectionOperationImpl.java | 27 ++++ .../AlterCollectionCommandResolver.java | 36 +++-- ...rCollectionWithLexicalIntegrationTest.java | 127 ++++++------------ 5 files changed, 110 insertions(+), 111 deletions(-) create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperation.java create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperationImpl.java diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java index 49f4c552e0..d9b9b11207 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java @@ -1,29 +1,17 @@ package io.stargate.sgv2.jsonapi.api.model.command.impl; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonTypeName; import io.stargate.sgv2.jsonapi.api.model.command.CollectionCommand; import io.stargate.sgv2.jsonapi.api.model.command.CommandName; -import jakarta.validation.Valid; -import javax.annotation.Nullable; -import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import io.stargate.sgv2.jsonapi.api.model.command.NoOptionsCommand; import org.eclipse.microprofile.openapi.annotations.media.Schema; @Schema( description = "Command that alters mutable settings of an existing collection. Currently supports enabling the 'lexical' feature.") @JsonTypeName(CommandName.Names.ALTER_COLLECTION) -public record AlterCollectionCommand( - @Valid - @JsonInclude(JsonInclude.Include.NON_NULL) - @Nullable - @Schema( - description = - "Lexical configuration to apply. Currently only enabling is supported ('enabled' must be true).", - type = SchemaType.OBJECT, - implementation = CreateCollectionCommand.Options.LexicalConfigDefinition.class) - CreateCollectionCommand.Options.LexicalConfigDefinition lexical) - implements CollectionCommand { +public record AlterCollectionCommand(AlterCollectionOperation operation) + implements CollectionCommand, NoOptionsCommand { @Override public CommandName commandName() { diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperation.java new file mode 100644 index 0000000000..4a812923bd --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperation.java @@ -0,0 +1,13 @@ +package io.stargate.sgv2.jsonapi.api.model.command.impl; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Polymorphic operation payload for {@link AlterCollectionCommand}. Each operation is represented + * by a record implementing this interface; Jackson selects the concrete subtype by the wrapper key + * (e.g. {@code "enableLexical"}). Mirrors {@link AlterTableOperation}. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT) +@JsonSubTypes({@JsonSubTypes.Type(value = AlterCollectionOperationImpl.EnableLexical.class)}) +public interface AlterCollectionOperation {} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperationImpl.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperationImpl.java new file mode 100644 index 0000000000..87a40a7bbe --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperationImpl.java @@ -0,0 +1,27 @@ +package io.stargate.sgv2.jsonapi.api.model.command.impl; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Map; +import javax.annotation.Nullable; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +/** Each operation that {@link AlterCollectionCommand} understands is represented by a record. */ +public class AlterCollectionOperationImpl { + + @Schema(description = "Operation to enable the lexical search feature on a collection.") + @JsonTypeName("enableLexical") + public record EnableLexical( + @Schema( + description = + "Analyzer to use for '$lexical' field: either String (name of a pre-defined analyzer), or JSON Object to specify custom one. Default: 'standard'.", + defaultValue = "standard", + oneOf = {String.class, Map.class}) + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonProperty("analyzer") + @Nullable + JsonNode analyzerDef) + implements AlterCollectionOperation {} +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java index 51913b312f..8955182e1a 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java @@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.impl.AlterCollectionCommand; +import io.stargate.sgv2.jsonapi.api.model.command.impl.AlterCollectionOperationImpl; +import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateCollectionCommand; import io.stargate.sgv2.jsonapi.config.OperationsConfig; import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.config.constants.TableCommentConstants; @@ -43,26 +45,38 @@ public Class getCommandClass() { public Operation resolveCollectionCommand( CommandContext ctx, AlterCollectionCommand command) { - if (command.lexical() == null) { - throw badOptions("must specify 'lexical' field"); + if (command.operation() == null) { + throw badOptions("must specify 'operation' field"); } + return switch (command.operation()) { + case AlterCollectionOperationImpl.EnableLexical op -> handleEnableLexical(ctx, op); + default -> + throw new IllegalStateException( + "Unexpected AlterCollectionOperation class: " + + command.operation().getClass().getSimpleName()); + }; + } + + private Operation handleEnableLexical( + CommandContext ctx, AlterCollectionOperationImpl.EnableLexical op) { + + // Synthesize a LexicalConfigDefinition with enabled=true so we can reuse the existing + // validation pipeline that createCollection uses. + final var lexicalConfigDef = + new CreateCollectionCommand.Options.LexicalConfigDefinition( + /* enabled */ Boolean.TRUE, op.analyzerDef()); + // validateAndConstruct throws: - // - LEXICAL_NOT_AVAILABLE_FOR_DATABASE if requested.enabled && !lexicalAvailableForDB - // - INVALID_ALTER_COLLECTION_OPTIONS for malformed analyzer / missing 'enabled' / etc. + // - LEXICAL_NOT_AVAILABLE_FOR_DATABASE if !lexicalAvailableForDB + // - INVALID_ALTER_COLLECTION_OPTIONS for malformed analyzer final CollectionLexicalConfig requested = CollectionLexicalConfig.validateAndConstruct( objectMapper, ctx.apiFeatures().isFeatureEnabled(ApiFeature.LEXICAL), - command.lexical(), + lexicalConfigDef, SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS); - // Phase 1: disabling lexical is not supported. - if (!requested.enabled()) { - throw badOptions( - "'lexical.enabled' must be true; alterCollection cannot disable lexical search"); - } - // Reject legacy / pre-lexical collections: must have a V1 comment with collection.options. if (isLegacyComment(ctx.schemaObject())) { throw badOptions( diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java index d31e64775f..86cfcac626 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java @@ -6,6 +6,7 @@ import static net.javacrumbs.jsonunit.JsonMatchers.jsonEquals; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; import io.quarkus.test.common.WithTestResource; import io.quarkus.test.junit.QuarkusIntegrationTest; @@ -41,7 +42,9 @@ void enableLexicalDefaultAnalyzerOnDisabledCollection() { """ { "alterCollection": { - "lexical": { "enabled": true } + "operation": { + "enableLexical": { } + } } } """; @@ -59,9 +62,7 @@ void enableLexicalDefaultAnalyzerOnDisabledCollection() { } } """; - postToCollection(name, insertOk) - .statusCode(200) - .body("errors", is(org.hamcrest.Matchers.nullValue())); + postToCollection(name, insertOk).statusCode(200).body("errors", is(nullValue())); String find = """ @@ -73,7 +74,7 @@ void enableLexicalDefaultAnalyzerOnDisabledCollection() { """; postToCollection(name, find) .statusCode(200) - .body("errors", is(org.hamcrest.Matchers.nullValue())) + .body("errors", is(nullValue())) .body("data.document._id", is("doc1")); deleteCollection(name); @@ -90,9 +91,10 @@ void enableLexicalCustomAnalyzerOnDisabledCollection() { """ { "alterCollection": { - "lexical": { - "enabled": true, - "analyzer": { "tokenizer": { "name": "whitespace" } } + "operation": { + "enableLexical": { + "analyzer": { "tokenizer": { "name": "whitespace" } } + } } } } @@ -146,7 +148,9 @@ void preservesOtherOptionsAcrossAlter() { """ { "alterCollection": { - "lexical": { "enabled": true } + "operation": { + "enableLexical": { } + } } } """; @@ -206,7 +210,9 @@ void enableLexicalAlreadyEnabledSameSettingsIsNoOp() { """ { "alterCollection": { - "lexical": { "enabled": true, "analyzer": "standard" } + "operation": { + "enableLexical": { "analyzer": "standard" } + } } } """; @@ -234,9 +240,10 @@ void failEnableLexicalDifferentAnalyzer() { """ { "alterCollection": { - "lexical": { - "enabled": true, - "analyzer": { "tokenizer": { "name": "whitespace" } } + "operation": { + "enableLexical": { + "analyzer": { "tokenizer": { "name": "whitespace" } } + } } } } @@ -253,55 +260,7 @@ void failEnableLexicalDifferentAnalyzer() { } @Test - void failDisableLexical() { - Assumptions.assumeTrue(isLexicalAvailableForDB()); - - final String name = freshCollectionName(); - createCollectionWithLexical(name, "{ \"enabled\": true }"); - - String json = - """ - { - "alterCollection": { - "lexical": { "enabled": false } - } - } - """; - postToCollection(name, json) - .statusCode(200) - .body("$", responseIsError()) - .body( - "errors[0].errorCode", - is(SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.name())) - .body("errors[0].message", containsString("cannot disable lexical")); - - deleteCollection(name); - } - - @Test - void failUnknownRootField() { - Assumptions.assumeTrue(isLexicalAvailableForDB()); - - final String name = freshCollectionName(); - createCollectionWithLexicalDisabled(name); - - String json = - """ - { - "alterCollection": { - "lexical": { "enabled": true }, - "unknownField": 1 - } - } - """; - // Jackson rejects the unknown root property; we just assert an error is returned. - postToCollection(name, json).statusCode(200).body("$", responseIsError()); - - deleteCollection(name); - } - - @Test - void failMissingLexical() { + void failMissingOperation() { Assumptions.assumeTrue(isLexicalAvailableForDB()); final String name = freshCollectionName(); @@ -319,16 +278,16 @@ void failMissingLexical() { .body( "errors[0].errorCode", is(SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.name())) - .body("errors[0].message", containsString("must specify 'lexical' field")); + .body("errors[0].message", containsString("must specify 'operation' field")); deleteCollection(name); } - // Malformed `lexical` body must yield INVALID_ALTER_COLLECTION_OPTIONS (not the - // createCollection variant). Mirrors the equivalent CreateCollectionWithLexicalIntegrationTest - // failure cases, but with the alterCollection-specific error code. + // Unknown operation key under "operation" — Jackson surfaces this via the global + // CommandObjectMapperHandler.handleUnknownTypeId path; we just assert that an error is + // returned with no DDL effect. @Test - void failMissingEnabledFlag() { + void failUnknownOperation() { Assumptions.assumeTrue(isLexicalAvailableForDB()); final String name = freshCollectionName(); @@ -338,19 +297,13 @@ void failMissingEnabledFlag() { """ { "alterCollection": { - "lexical": { } + "operation": { + "unknownOp": { } + } } } """; - postToCollection(name, json) - .statusCode(200) - .body("$", responseIsError()) - .body( - "errors[0].errorCode", - is(SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.name())) - .body( - "errors[0].message", - containsString("'enabled' is required property for 'lexical' Object value")); + postToCollection(name, json).statusCode(200).body("$", responseIsError()); deleteCollection(name); } @@ -366,9 +319,10 @@ void failAnalyzerWrongJsonType() { """ { "alterCollection": { - "lexical": { - "enabled": true, - "analyzer": [1, 2, 3] + "operation": { + "enableLexical": { + "analyzer": [1, 2, 3] + } } } } @@ -398,10 +352,11 @@ void failAnalyzerMisspelledField() { """ { "alterCollection": { - "lexical": { - "enabled": true, - "analyzer": { - "tokeniser": { "name": "standard" } + "operation": { + "enableLexical": { + "analyzer": { + "tokeniser": { "name": "standard" } + } } } } @@ -432,7 +387,9 @@ void failEnableWhenLexicalNotAvailableForDB() { """ { "alterCollection": { - "lexical": { "enabled": true } + "operation": { + "enableLexical": { } + } } } """; From 5d4353a6e44ab8aae5ecdc07e0aa5fd88fe6a78f Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 14 May 2026 13:52:01 -0700 Subject: [PATCH 15/22] Add one more IT --- ...rCollectionWithLexicalIntegrationTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java index 86cfcac626..ccf5e6b612 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java @@ -376,6 +376,33 @@ void failAnalyzerMisspelledField() { deleteCollection(name); } + // Locks in that the `enableLexical` body rejects unknown fields via Jackson's + // FAIL_ON_UNKNOWN_PROPERTIES setting. + @Test + void failEnableLexicalUnknownField() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexicalDisabled(name); + + String json = + """ + { + "alterCollection": { + "operation": { + "enableLexical": { + "analyzer": "standard", + "foo": "bar" + } + } + } + } + """; + postToCollection(name, json).statusCode(200).body("$", responseIsError()); + + deleteCollection(name); + } + @Test void failEnableWhenLexicalNotAvailableForDB() { Assumptions.assumeFalse(isLexicalAvailableForDB()); From 559083849387d2b7872bead017338f852db83c88 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 14 May 2026 13:58:27 -0700 Subject: [PATCH 16/22] Minor improvements --- .../CommandObjectMapperHandler.java | 7 +++++++ ...rCollectionWithLexicalIntegrationTest.java | 19 +++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/configuration/CommandObjectMapperHandler.java b/src/main/java/io/stargate/sgv2/jsonapi/api/configuration/CommandObjectMapperHandler.java index 1367c761b8..9b0830bd69 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/configuration/CommandObjectMapperHandler.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/configuration/CommandObjectMapperHandler.java @@ -65,6 +65,13 @@ public JavaType handleUnknownTypeId( int ix = baseCommand.indexOf("Command"); if (ix > 0) { baseCommand = baseCommand.substring(0, ix) + " " + "Command"; + } else { + // Also handle nested polymorphic operations like "AlterCollectionOperation" -> + // "AlterCollection Operation" so the error message reads more naturally. + int opIx = baseCommand.indexOf("Operation"); + if (opIx > 0) { + baseCommand = baseCommand.substring(0, opIx) + " " + "Operation"; + } } throw RequestException.Code.COMMAND_UNKNOWN.get( diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java index ccf5e6b612..4fe03fd521 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java @@ -12,6 +12,7 @@ import io.quarkus.test.junit.QuarkusIntegrationTest; import io.restassured.http.ContentType; import io.restassured.response.ValidatableResponse; +import io.stargate.sgv2.jsonapi.exception.RequestException; import io.stargate.sgv2.jsonapi.exception.SchemaException; import io.stargate.sgv2.jsonapi.testresource.DseTestResource; import org.apache.commons.lang3.RandomStringUtils; @@ -284,8 +285,7 @@ void failMissingOperation() { } // Unknown operation key under "operation" — Jackson surfaces this via the global - // CommandObjectMapperHandler.handleUnknownTypeId path; we just assert that an error is - // returned with no DDL effect. + // CommandObjectMapperHandler.handleUnknownTypeId path which throws COMMAND_UNKNOWN. @Test void failUnknownOperation() { Assumptions.assumeTrue(isLexicalAvailableForDB()); @@ -303,7 +303,14 @@ void failUnknownOperation() { } } """; - postToCollection(name, json).statusCode(200).body("$", responseIsError()); + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsError()) + .body("errors[0].errorCode", is(RequestException.Code.COMMAND_UNKNOWN.name())) + .body( + "errors[0].message", + containsString("Command 'unknownOp' is not a AlterCollection Operation recognized")) + .body("errors[0].message", containsString("AlterCollection Operations: [enableLexical]")); deleteCollection(name); } @@ -398,7 +405,11 @@ void failEnableLexicalUnknownField() { } } """; - postToCollection(name, json).statusCode(200).body("$", responseIsError()); + postToCollection(name, json) + .statusCode(200) + .body("$", responseIsError()) + .body("errors[0].errorCode", is(RequestException.Code.COMMAND_FIELD_UNKNOWN.name())) + .body("errors[0].message", containsString("Command field 'foo' not recognized")); deleteCollection(name); } From f49f41f41d915943b886448cce7913045eb317f4 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 21 May 2026 15:05:41 -0700 Subject: [PATCH 17/22] Improvements --- .../impl/AlterCollectionOperation.java | 3 +- .../AlterCollectionLexicalOperation.java | 99 +++++++++++++++++-- .../CreateCollectionOperation.java | 14 ++- .../AlterCollectionCommandResolver.java | 16 +-- src/main/resources/errors.yaml | 12 +-- ...rCollectionWithLexicalIntegrationTest.java | 72 ++++++++++++++ 6 files changed, 191 insertions(+), 25 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperation.java index 4a812923bd..aad9212475 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionOperation.java @@ -10,4 +10,5 @@ */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT) @JsonSubTypes({@JsonSubTypes.Type(value = AlterCollectionOperationImpl.EnableLexical.class)}) -public interface AlterCollectionOperation {} +public sealed interface AlterCollectionOperation + permits AlterCollectionOperationImpl.EnableLexical {} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index 07e9edcff9..92d96d54f5 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -1,8 +1,12 @@ package io.stargate.sgv2.jsonapi.service.operation.collections; +import static io.stargate.sgv2.jsonapi.exception.ErrorFormatters.errVars; + import com.datastax.oss.driver.api.core.CqlIdentifier; import com.datastax.oss.driver.api.core.cql.AsyncResultSet; import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.datastax.oss.driver.api.core.metadata.Metadata; +import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata; import com.fasterxml.jackson.core.JacksonException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -10,14 +14,18 @@ import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.CommandResult; import io.stargate.sgv2.jsonapi.api.request.RequestContext; +import io.stargate.sgv2.jsonapi.config.DatabaseLimitsConfig; import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.config.constants.TableCommentConstants; +import io.stargate.sgv2.jsonapi.exception.DatabaseException; +import io.stargate.sgv2.jsonapi.exception.SchemaException; import io.stargate.sgv2.jsonapi.service.cqldriver.executor.QueryExecutor; import io.stargate.sgv2.jsonapi.service.operation.Operation; import io.stargate.sgv2.jsonapi.service.resolver.CreateCollectionCommandResolver; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionLexicalConfig; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionSchemaObject; import java.time.Duration; +import java.util.Optional; import java.util.function.Supplier; /** @@ -35,17 +43,24 @@ *
    *
  • Reverse DDL (DROP COLUMN, DROP INDEX) is itself fallible — a rollback that fails leaves the * schema in a worse state than the original partial failure and obscures the root cause. - *
  • The operation is designed to be retry-safe: ADD COLUMN is skipped when the column already - * exists, CREATE INDEX uses {@code IF NOT EXISTS}, and ALTER TABLE WITH comment is naturally - * idempotent. Re-issuing the same {@code alterCollection} command after the underlying issue - * is resolved completes the unfinished steps without re-running the finished ones. + *
  • The operation is retry-safe: existence is checked against freshly-fetched metadata, so ADD + * COLUMN is skipped when the column already exists, CREATE INDEX uses {@code IF NOT EXISTS}, + * and the comment write is a plain overwrite. Re-issuing the same {@code alterCollection} + * command after the underlying issue is resolved completes the unfinished steps without + * failing on the finished ones. (The backend does not support {@code ADD IF NOT EXISTS}, so + * the skip relies on the metadata check.) *
  • Users get a consistent mental model with {@code createCollection}, which has the same * partial-failure semantics. *
+ * + *

The comment is updated last, so an interrupted run can leave the column/index present while + * {@code findCollections} still reports lexical as disabled; a successful retry reconciles this + * (see {@code trulyEnabled} in {@code AlterCollectionCommandResolver}). */ public record AlterCollectionLexicalOperation( CommandContext commandContext, ObjectMapper objectMapper, + DatabaseLimitsConfig dbLimitsConfig, int ddlDelayMillis, CollectionLexicalConfig newLexicalConfig, boolean noOp) @@ -74,13 +89,79 @@ public Uni> execute( final String newComment; try { newComment = buildUpdatedComment(schemaObject); - } catch (JacksonException e) { - return Uni.createFrom().failure(e); + } catch (JacksonException | RuntimeException e) { + // Resolver guarantees a V1 comment; if reading/updating it still fails, surface a clean error + // rather than a raw Jackson/IllegalState exception. + return Uni.createFrom() + .failure( + DatabaseException.Code.CORRUPTED_COLLECTION_SCHEMA.get( + errVars( + schemaObject, + map -> + map.put( + "errorMessage", + "Unable to update collection 'comment' to enable lexical: " + + e.getMessage())))); } - // Idempotent for retry after partial failure: skip ADD COLUMN if the column already exists. - final boolean columnAlreadyExists = - schemaObject.tableMetadata().getColumn(LEXICAL_COLUMN).isPresent(); + // Base all existence decisions on freshly-fetched metadata rather than the resolve-time + // snapshot, so a column/index left by an interrupted prior run (or a concurrent op) is seen + // here. This is also where we pre-flight the DB-wide index limit, before running any DDL. + return queryExecutor + .getDriverMetadata(requestContext) + .map(Metadata::getKeyspaces) + .flatMap( + allKeyspaces -> { + final TableMetadata currentTable = + Optional.ofNullable(allKeyspaces.get(schemaObject.tableMetadata().getKeyspace())) + .flatMap(ks -> ks.getTable(schemaObject.tableMetadata().getName())) + .orElse(schemaObject.tableMetadata()); + + final boolean columnExists = currentTable.getColumn(LEXICAL_COLUMN).isPresent(); + final boolean indexExists = + currentTable + .getIndexes() + .containsKey( + CqlIdentifier.fromInternal( + CreateCollectionOperation.lexicalIndexName(table))); + + // Only an absent index is net-new, so only then enforce the limit (mirrors + // CreateCollectionOperation): going over fails with TOO_MANY_INDEXES_FOR_COLLECTION + // before any DDL, not a generic error from a failed CREATE INDEX. + if (!indexExists) { + final int saisUsed = + allKeyspaces.values().stream() + .flatMap(ks -> ks.getTables().values().stream()) + .mapToInt(t -> t.getIndexes().size()) + .sum(); + // enableLexical adds exactly one SAI (the analyzed lexical index). + if (saisUsed + 1 > dbLimitsConfig.indexesAvailablePerDatabase()) { + return Uni.createFrom() + .>failure( + SchemaException.Code.TOO_MANY_INDEXES_FOR_COLLECTION.get( + errVars(schemaObject, map -> map.put("indexesPerCollection", "1")))); + } + } + + return executeLexicalDdl( + requestContext, queryExecutor, keyspace, table, newComment, columnExists); + }); + } + + /** + * Runs the enable-lexical DDL: ADD COLUMN (skipped when it already exists), CREATE CUSTOM INDEX + * IF NOT EXISTS, then ALTER TABLE WITH comment, spaced by {@link #ddlDelayMillis}. The {@code + * columnAlreadyExists} flag is derived from freshly-fetched metadata so a leftover column is + * skipped rather than failing the (plain) ADD — the backend does not support {@code ADD IF NOT + * EXISTS}. + */ + private Uni> executeLexicalDdl( + RequestContext requestContext, + QueryExecutor queryExecutor, + String keyspace, + String table, + String newComment, + boolean columnAlreadyExists) { SimpleStatement createIndexStmt = CreateCollectionOperation.buildLexicalIndexStatement( diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java index aaa66b6e67..34f70672b2 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java @@ -669,9 +669,19 @@ public static SimpleStatement buildLexicalIndexStatement( final String prefix = ifNotExists ? "CREATE CUSTOM INDEX IF NOT EXISTS" : "CREATE CUSTOM INDEX"; return SimpleStatement.newInstance( """ - %s "%s_%s" ON "%s"."%s" (%s) + %s "%s" ON "%s"."%s" (%s) USING 'StorageAttachedIndex' WITH OPTIONS = { 'index_analyzer': '%s' } """ - .formatted(prefix, table, lexicalCol, keyspace, table, lexicalCol, analyzerString)); + .formatted( + prefix, lexicalIndexName(table), keyspace, table, lexicalCol, analyzerString)); + } + + /** + * Name of the lexical SAI: {@code "_"}. Shared with {@link + * #buildLexicalIndexStatement} so callers referencing the index by name stay in sync with how it + * is created. + */ + public static String lexicalIndexName(String table) { + return table + "_" + DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME; } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java index 8955182e1a..502b50d916 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java @@ -7,6 +7,7 @@ import io.stargate.sgv2.jsonapi.api.model.command.impl.AlterCollectionCommand; import io.stargate.sgv2.jsonapi.api.model.command.impl.AlterCollectionOperationImpl; import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateCollectionCommand; +import io.stargate.sgv2.jsonapi.config.DatabaseLimitsConfig; import io.stargate.sgv2.jsonapi.config.OperationsConfig; import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.config.constants.TableCommentConstants; @@ -30,10 +31,13 @@ public class AlterCollectionCommandResolver implements CommandResolver resolveCollectionCommand( throw badOptions("must specify 'operation' field"); } + // Sealed interface: switch is exhaustive, so a new operation subtype fails to compile until + // handled here. return switch (command.operation()) { case AlterCollectionOperationImpl.EnableLexical op -> handleEnableLexical(ctx, op); - default -> - throw new IllegalStateException( - "Unexpected AlterCollectionOperation class: " - + command.operation().getClass().getSimpleName()); }; } @@ -97,7 +99,7 @@ private Operation handleEnableLexical( if (!trulyEnabled) { return new AlterCollectionLexicalOperation( - ctx, objectMapper, ddlDelayMillis, requested, /* noOp */ false); + ctx, objectMapper, dbLimitsConfig, ddlDelayMillis, requested, /* noOp */ false); } // Both analyzer definitions are guaranteed non-null here (CollectionLexicalConfig's @@ -109,7 +111,7 @@ private Operation handleEnableLexical( } // Same settings already in effect: no-op success. return new AlterCollectionLexicalOperation( - ctx, objectMapper, ddlDelayMillis, requested, /* noOp */ true); + ctx, objectMapper, dbLimitsConfig, ddlDelayMillis, requested, /* noOp */ true); } private static SchemaException badOptions(String message) { diff --git a/src/main/resources/errors.yaml b/src/main/resources/errors.yaml index a475978073..ba19586544 100644 --- a/src/main/resources/errors.yaml +++ b/src/main/resources/errors.yaml @@ -2044,13 +2044,13 @@ request-errors: - scope: SCHEMA code: TOO_MANY_INDEXES_FOR_COLLECTION - title: Cannot create collection due to number of existing indexes + title: Cannot create indexes for collection due to number of existing indexes body: |- - The command attempted to create an collection, however the number of indexes in the database has reached the maximum allowed. - - Failed to create Collection: ${keyspace}.${table}. - The number of indexes needed for each collection is: ${indexesPerCollection}. - + The command attempted to create one or more indexes for a collection, however the number of indexes in the database has reached the maximum allowed. + + Failed for Collection: ${keyspace}.${table}. + The number of indexes the command needs is: ${indexesPerCollection}. + Reduce the number of indexes in the database and resend the command. - scope: SCHEMA diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java index 4fe03fd521..eb74ac5a34 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionWithLexicalIntegrationTest.java @@ -7,11 +7,13 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertTrue; import io.quarkus.test.common.WithTestResource; import io.quarkus.test.junit.QuarkusIntegrationTest; import io.restassured.http.ContentType; import io.restassured.response.ValidatableResponse; +import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.exception.RequestException; import io.stargate.sgv2.jsonapi.exception.SchemaException; import io.stargate.sgv2.jsonapi.testresource.DseTestResource; @@ -442,6 +444,76 @@ void failEnableWhenLexicalNotAvailableForDB() { } } + @Nested + @Order(3) + class AlterCollectionEnableLexicalIdempotency { + + // An interrupted prior run (or a concurrent op) can leave the lexical column present while the + // stored comment still says lexical is disabled. The resolver then treats the collection as + // "not truly enabled" and re-runs the full DDL pipeline. The operation checks freshly-fetched + // metadata, sees the column already exists, and skips ADD COLUMN (the backend does not support + // ADD IF NOT EXISTS), so enableLexical still succeeds and reconciles the state instead of + // failing with "column already exists". + @Test + void enableLexicalWhenColumnAlreadyPresent() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String name = freshCollectionName(); + createCollectionWithLexicalDisabled(name); + + // Simulate the leftover column from an interrupted alter, bypassing the Data API. + boolean applied = + executeCqlStatement( + "ALTER TABLE \"%s\".\"%s\" ADD %s text" + .formatted( + keyspaceName, name, DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME)); + assertTrue(applied, "Pre-condition: manual ADD COLUMN should apply"); + + // enableLexical forces a schema refresh, so the resolver sees the orphan column; the DDL + // then runs ADD IF NOT EXISTS (no-op) + CREATE INDEX IF NOT EXISTS + comment update. + String alter = + """ + { + "alterCollection": { + "operation": { + "enableLexical": { } + } + } + } + """; + postToCollection(name, alter) + .statusCode(200) + .body("$", responseIsDDLSuccess()) + .body("status.ok", is(1)); + + // Lexical must actually work now (column + index + comment all consistent). + String insertOk = + """ + { + "insertOne": { + "document": { "_id": "doc1", "$lexical": "hello world" } + } + } + """; + postToCollection(name, insertOk).statusCode(200).body("errors", is(nullValue())); + + String find = + """ + { + "findOne": { + "sort": { "$lexical": "hello" } + } + } + """; + postToCollection(name, find) + .statusCode(200) + .body("errors", is(nullValue())) + .body("data.document._id", is("doc1")); + + deleteCollection(name); + } + } + // ----------------------------------------------------------------- // Helpers // ----------------------------------------------------------------- From 16d4d02f8665f0702c87072649a315e1ecda14e8 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 21 May 2026 15:18:41 -0700 Subject: [PATCH 18/22] Add one more IT --- ...llectionTooManyIndexesIntegrationTest.java | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionTooManyIndexesIntegrationTest.java diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionTooManyIndexesIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionTooManyIndexesIntegrationTest.java new file mode 100644 index 0000000000..0daf8a8cae --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AlterCollectionTooManyIndexesIntegrationTest.java @@ -0,0 +1,102 @@ +package io.stargate.sgv2.jsonapi.api.v1; + +import static io.stargate.sgv2.jsonapi.api.v1.ResponseAssertions.responseIsDDLSuccess; +import static io.stargate.sgv2.jsonapi.api.v1.ResponseAssertions.responseIsError; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.stargate.sgv2.jsonapi.exception.SchemaException; +import io.stargate.sgv2.jsonapi.testresource.DseTestResource; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; + +/** + * Separate integration test (its own container with a deliberately small DB-wide index budget) that + * verifies the index-limit pre-flight of {@code alterCollection}: enabling lexical adds one SAI, + * and if that would exceed the database index limit it must be rejected with {@code + * TOO_MANY_INDEXES_FOR_COLLECTION} before any DDL runs. + * + *

Companion to {@link CreateCollectionTooManyIndexesIntegrationTest}, which covers the same + * limit for {@code createCollection}. + */ +@QuarkusIntegrationTest +@QuarkusTestResource( + value = AlterCollectionTooManyIndexesIntegrationTest.LowIndexBudgetTestResource.class, + restrictToAnnotatedClass = true) +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +class AlterCollectionTooManyIndexesIntegrationTest extends AbstractKeyspaceIntegrationTestBase { + + // A lexical-disabled, non-vector collection uses 9 SAIs (a lexical-enabled one uses 10 — see + // CreateCollectionTooManyIndexesIntegrationTest). With the DB budget capped at 10, creating the + // disabled collection fits, but enabling lexical afterwards needs the 10th index. + private static final int INDEXES_PER_DB = 10; + + public static class LowIndexBudgetTestResource extends DseTestResource { + public LowIndexBudgetTestResource() {} + + @Override + public int getIndexesPerDBOverride() { + return INDEXES_PER_DB; + } + } + + @Test + public void enableLexicalRejectedWhenIndexBudgetExhausted() { + Assumptions.assumeTrue(isLexicalAvailableForDB()); + + final String coll = "alter_lex_limit"; + + // 1) Create a collection with lexical disabled (uses 9 of the 10 available SAIs). + String create = + """ + { + "createCollection": { + "name": "%s", + "options": { "lexical": { "enabled": false } } + } + } + """ + .formatted(coll); + givenHeadersAndJson(create) + .when() + .post(KeyspaceResource.BASE_PATH, keyspaceName) + .then() + .statusCode(200) + .body("$", responseIsDDLSuccess()) + .body("status.ok", is(1)); + + // 2) Push the DB to its index ceiling out-of-band (via CQL, to bypass the API's own create-time + // limit check). 9 (collection) + 2 (padding) = 11 SAIs, already over the limit of 10. + boolean padded = + executeCqlStatement( + "CREATE TABLE \"%s\".\"alter_lex_pad\" (id int PRIMARY KEY, c0 int, c1 int)" + .formatted(keyspaceName), + "CREATE CUSTOM INDEX alter_lex_pad_c0 ON \"%s\".\"alter_lex_pad\" (c0) USING 'StorageAttachedIndex'" + .formatted(keyspaceName), + "CREATE CUSTOM INDEX alter_lex_pad_c1 ON \"%s\".\"alter_lex_pad\" (c1) USING 'StorageAttachedIndex'" + .formatted(keyspaceName)); + assertTrue(padded, "Pre-condition: padding table and indexes should be created"); + + // 3) enableLexical needs one more SAI -> over the limit -> rejected by the pre-flight, no DDL. + String alter = + """ + { + "alterCollection": { + "operation": { "enableLexical": { } } + } + } + """; + givenHeadersAndJson(alter) + .when() + .post(CollectionResource.BASE_PATH, keyspaceName, coll) + .then() + .statusCode(200) + .body("$", responseIsError()) + .body( + "errors[0].errorCode", is(SchemaException.Code.TOO_MANY_INDEXES_FOR_COLLECTION.name())); + } +} From cdb38108d76e9468860f4e6f301ad7bc287b1602 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 21 May 2026 15:21:55 -0700 Subject: [PATCH 19/22] Minor comment rewording i --- .../collections/AlterCollectionLexicalOperation.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index 92d96d54f5..a656f253d0 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -167,9 +167,9 @@ private Uni> executeLexicalDdl( CreateCollectionOperation.buildLexicalIndexStatement( keyspace, table, newLexicalConfig, /* ifNotExists */ true); - // Cassandra does not accept bind parameters for table options like `comment`; embed the - // JSON directly with CQL single-quote escaping (matches - // CreateCollectionOperation.getCreateTable). + // Cassandra does not accept bind parameters for table options like `comment`, so the comment + // JSON is embedded directly into the CQL (as createCollection does); single quotes are doubled + // to keep the string literal valid. SimpleStatement alterCommentStmt = SimpleStatement.newInstance( "ALTER TABLE \"%s\".\"%s\" WITH comment = '%s'" From 4618aeb9852035f79d0d64b9b88b57357cf5efe9 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 21 May 2026 15:30:07 -0700 Subject: [PATCH 20/22] Collection table comment refactoring --- .../AlterCollectionLexicalOperation.java | 6 +-- .../AlterCollectionCommandResolver.java | 24 +--------- .../collections/CollectionSchemaObject.java | 2 +- .../collections/CollectionTableComment.java | 46 +++++++++++++++++++ 4 files changed, 51 insertions(+), 27 deletions(-) create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionTableComment.java diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index a656f253d0..51a922346a 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -24,6 +24,7 @@ import io.stargate.sgv2.jsonapi.service.resolver.CreateCollectionCommandResolver; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionLexicalConfig; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionSchemaObject; +import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionTableComment; import java.time.Duration; import java.util.Optional; import java.util.function.Supplier; @@ -66,8 +67,6 @@ public record AlterCollectionLexicalOperation( boolean noOp) implements Operation { - private static final CqlIdentifier COMMENT_OPTION = CqlIdentifier.fromInternal("comment"); - private static final CqlIdentifier LEXICAL_COLUMN = CqlIdentifier.fromInternal(DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME); @@ -215,8 +214,7 @@ private Uni> executeLexicalDdl( * rejected before reaching the operation). */ private String buildUpdatedComment(CollectionSchemaObject schemaObject) throws JacksonException { - final Object commentObj = schemaObject.tableMetadata().getOptions().get(COMMENT_OPTION); - final String comment = commentObj == null ? null : commentObj.toString(); + final String comment = CollectionTableComment.rawComment(schemaObject.tableMetadata()); if (comment == null || comment.isBlank()) { // Defensive: resolver should have rejected this case. throw new IllegalStateException( diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java index 502b50d916..1ea894bfe1 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java @@ -1,7 +1,6 @@ package io.stargate.sgv2.jsonapi.service.resolver; import com.datastax.oss.driver.api.core.CqlIdentifier; -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.impl.AlterCollectionCommand; @@ -10,13 +9,13 @@ import io.stargate.sgv2.jsonapi.config.DatabaseLimitsConfig; import io.stargate.sgv2.jsonapi.config.OperationsConfig; import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; -import io.stargate.sgv2.jsonapi.config.constants.TableCommentConstants; import io.stargate.sgv2.jsonapi.config.feature.ApiFeature; import io.stargate.sgv2.jsonapi.exception.SchemaException; import io.stargate.sgv2.jsonapi.service.operation.Operation; import io.stargate.sgv2.jsonapi.service.operation.collections.AlterCollectionLexicalOperation; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionLexicalConfig; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionSchemaObject; +import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionTableComment; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import java.util.Map; @@ -25,8 +24,6 @@ @ApplicationScoped public class AlterCollectionCommandResolver implements CommandResolver { - private static final CqlIdentifier COMMENT_OPTION = CqlIdentifier.fromInternal("comment"); - private static final CqlIdentifier LEXICAL_COLUMN = CqlIdentifier.fromInternal(DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME); @@ -80,7 +77,7 @@ private Operation handleEnableLexical( SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS); // Reject legacy / pre-lexical collections: must have a V1 comment with collection.options. - if (isLegacyComment(ctx.schemaObject())) { + if (!CollectionTableComment.hasV1Options(objectMapper, ctx.schemaObject().tableMetadata())) { throw badOptions( "collection has legacy metadata (pre-lexical schema); recreate the collection with lexical enabled"); } @@ -117,21 +114,4 @@ private Operation handleEnableLexical( private static SchemaException badOptions(String message) { return SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS.get(Map.of("message", message)); } - - private boolean isLegacyComment(CollectionSchemaObject schemaObject) { - final Object commentObj = schemaObject.tableMetadata().getOptions().get(COMMENT_OPTION); - if (commentObj == null) { - return true; - } - try { - JsonNode optionsNode = - objectMapper - .readTree(commentObj.toString()) - .path(TableCommentConstants.TOP_LEVEL_KEY) - .path(TableCommentConstants.OPTIONS_KEY); - return !optionsNode.isObject(); - } catch (Exception e) { - return true; - } - } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionSchemaObject.java b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionSchemaObject.java index 1a4376a138..716c3548d6 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionSchemaObject.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionSchemaObject.java @@ -150,7 +150,7 @@ public static CollectionSchemaObject getCollectionSettings( final Optional vectorColumn = table.getColumn(DocumentConstants.Columns.VECTOR_SEARCH_INDEX_COLUMN_NAME); boolean vectorEnabled = vectorColumn.isPresent(); - final String comment = (String) table.getOptions().get(CqlIdentifier.fromInternal("comment")); + final String comment = CollectionTableComment.rawComment(table); // if vector column exists if (vectorEnabled) { diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionTableComment.java b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionTableComment.java new file mode 100644 index 0000000000..110a5e8057 --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/collections/CollectionTableComment.java @@ -0,0 +1,46 @@ +package io.stargate.sgv2.jsonapi.service.schema.collections; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.stargate.sgv2.jsonapi.config.constants.TableCommentConstants; + +/** + * Helpers for the JSON stored as the CQL {@code comment} table option of a Collection's backing + * table. Centralizes "where the comment lives" and "what a V1 comment looks like" so callers (e.g. + * createCollection / alterCollection / settings parsing) do not each re-derive it. + */ +public final class CollectionTableComment { + + private static final CqlIdentifier COMMENT_OPTION = CqlIdentifier.fromInternal("comment"); + + private CollectionTableComment() {} + + /** The raw comment string stored on the table, or {@code null} if there is none. */ + public static String rawComment(TableMetadata table) { + Object comment = table.getOptions().get(COMMENT_OPTION); + return comment == null ? null : comment.toString(); + } + + /** + * Whether the table carries a V1-shaped comment, i.e. one with a {@code collection.options} JSON + * object. Legacy / pre-V1 comments (and missing or malformed ones) return {@code false}. + */ + public static boolean hasV1Options(ObjectMapper mapper, TableMetadata table) { + String comment = rawComment(table); + if (comment == null || comment.isBlank()) { + return false; + } + try { + JsonNode options = + mapper + .readTree(comment) + .path(TableCommentConstants.TOP_LEVEL_KEY) + .path(TableCommentConstants.OPTIONS_KEY); + return options.isObject(); + } catch (Exception e) { + return false; + } + } +} From 3fd994ad8b1477b7d8ee8239d6a171c7be9718dd Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 21 May 2026 16:28:20 -0700 Subject: [PATCH 21/22] Comment update --- .../collections/AlterCollectionLexicalOperation.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java index 51a922346a..5457598fd8 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/AlterCollectionLexicalOperation.java @@ -35,7 +35,7 @@ * "comment" JSON to record the new lexical config. * *

When {@link #noOp} is true the operation returns success without executing any DDL: this is - * the "already enabled with same settings" case. + * used for the "already enabled with same settings" case. * *

No rollback on partial failure. If e.g. ADD COLUMN succeeds but CREATE INDEX fails, the * column is left in place and the failure is propagated to the caller. This matches {@link @@ -75,9 +75,6 @@ public Uni> execute( RequestContext requestContext, QueryExecutor queryExecutor) { if (noOp) { - // Type witness needed: Mutiny's item(T) and item(Supplier) overloads otherwise - // both match SchemaChangeResult (which is a Supplier), and inference picks - // the wrong T. return Uni.createFrom().>item(new SchemaChangeResult(true)); } @@ -89,8 +86,8 @@ public Uni> execute( try { newComment = buildUpdatedComment(schemaObject); } catch (JacksonException | RuntimeException e) { - // Resolver guarantees a V1 comment; if reading/updating it still fails, surface a clean error - // rather than a raw Jackson/IllegalState exception. + // Resolver guarantees a V1 comment; if reading/updating still fails, surface a clean error + // rather than a raw JacksonException/IllegalStateException. return Uni.createFrom() .failure( DatabaseException.Code.CORRUPTED_COLLECTION_SCHEMA.get( From ec7d10d52f177c93134be5a273fa992ce2388233 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 28 May 2026 08:33:42 -0700 Subject: [PATCH 22/22] Minor improvements --- .../command/impl/AlterCollectionCommand.java | 5 ----- .../collections/CreateCollectionOperation.java | 5 +++++ .../resolver/AlterCollectionCommandResolver.java | 15 +++++++++------ 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java index d9b9b11207..d7a8049a73 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/AlterCollectionCommand.java @@ -17,9 +17,4 @@ public record AlterCollectionCommand(AlterCollectionOperation operation) public CommandName commandName() { return CommandName.ALTER_COLLECTION; } - - @Override - public boolean isForceSchemaRefresh() { - return true; - } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java index 8e17ace513..d4b36d9780 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/collections/CreateCollectionOperation.java @@ -765,6 +765,11 @@ public static SimpleStatement buildLexicalIndexStatement( * Name of the lexical SAI: {@code "

_"}. Shared with {@link * #buildLexicalIndexStatement} so callers referencing the index by name stay in sync with how it * is created. + * + *

The {@code "

_"} format is part of the on-disk schema: existing collections + * have indexes named this way, and recovery paths (e.g. {@code alterCollection}'s already-exists + * check) match by this exact name. Do not change the format without a migration for existing + * collections. */ public static String lexicalIndexName(String table) { return table + "_" + DocumentConstants.Columns.LEXICAL_INDEX_COLUMN_NAME; diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java index c9a3602031..5667353ca6 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/AlterCollectionCommandResolver.java @@ -59,6 +59,15 @@ public Operation resolveCollectionCommand( private Operation handleEnableLexical( CommandContext ctx, AlterCollectionOperationImpl.EnableLexical op) { + // Reject legacy / pre-lexical collections up front: must have a V1 comment with + // collection.options. Doing this before analyzer validation gives users the actionable + // "recreate the collection" error on legacy schemas instead of an analyzer-validation + // error they can't act on. + if (!CollectionTableComment.hasV1Options(objectMapper, ctx.schemaObject().tableMetadata())) { + throw badOptions( + "collection has legacy metadata (pre-lexical schema); recreate the collection with lexical enabled"); + } + // Synthesize a LexicalDesc with enabled=true so we can reuse the existing // validation pipeline that createCollection uses. final var lexicalDesc = @@ -76,12 +85,6 @@ private Operation handleEnableLexical( SchemaException.Code.INVALID_ALTER_COLLECTION_OPTIONS) .runningValue(); - // Reject legacy / pre-lexical collections: must have a V1 comment with collection.options. - if (!CollectionTableComment.hasV1Options(objectMapper, ctx.schemaObject().tableMetadata())) { - throw badOptions( - "collection has legacy metadata (pre-lexical schema); recreate the collection with lexical enabled"); - } - final CollectionLexicalDef current = ctx.schemaObject().lexicalDef(); final int ddlDelayMillis = ctx.config().get(OperationsConfig.class).databaseConfig().ddlDelayMillis();