This is an automated email from the ASF dual-hosted git repository.
zhangstar333 pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new c28259d339c branch-4.1: [feature](lance) Add statically validated
Lance index DDL surface (#67201)
c28259d339c is described below
commit c28259d339ca8f57a977d4d6d63a8a513933b545
Author: kid <[email protected]>
AuthorDate: Wed Sep 9 20:29:39 2026 +0800
branch-4.1: [feature](lance) Add statically validated Lance index DDL
surface (#67201)
### What problem does this PR solve?
Issue Number: #66497
Related PR: #66637 (merged), #66671 (open — independent; this PR shares
no files with it)
Problem Summary:
This is the first sub-PR (PR3A) of delivery slice 3 of the v5.1 design
([final 4.2
contract](https://github.com/apache/doris/issues/66497#issuecomment-5301314544),
scope confirmed in [this
review](https://github.com/apache/doris/issues/66497#issuecomment-5301637401)):
the Lance index DDL surface with static validation and target-aware
routing, in reject-all mode. Slice 3's remaining pieces — durable jobs,
same-name fences, unresolved quotas, replay, job SQL, and dispatch with
fake-worker fault tests — land as follow-up PRs; this PR contains no job
admission, no index-metadata reads, and no enablement gate.
What this PR adds:
- Top-level `CREATE [OR REPLACE] INDEX ... USING ANN/BTREE/BITMAP` and
`DROP INDEX [IF EXISTS]` parsing for Lance Directory catalog tables. `OR
REPLACE` is mutually exclusive with `IF NOT EXISTS`; `BTREE` is a new
non-reserved keyword. The `indexDef` rule used by `CREATE TABLE` and
`ALTER TABLE ... ADD INDEX` is unchanged, and `ALTER TABLE ... ADD/DROP
INDEX` remains unsupported per Section 2.1, so previously parseable
internal SQL behaves byte-identically.
- `IndexDefinition` carries `orReplace` and the Lance-only type name
without extending the persisted internal `IndexDef.IndexType` enum
(Section 4.4). New `validate()` guards reject Lance-only syntax on
internal tables and fire only for SQL that was a syntax error before
this PR.
- `LanceIndexMutationValidator` applies the FE static bounds of Section
2.4: the ANN/`IVF_PQ` property matrix (required `index_type=IVF_PQ`,
`metric` in `l2/cosine/dot`, positive required
`num_partitions`/`num_sub_vectors`, fixed `num_bits=8`, unknown and
case-variant duplicate properties rejected), the BTREE/BITMAP
column-type sets (uint64/`LARGEINT` included as integral for both),
exactly one non-null column, and a bounded index name. Arrow-level
revalidation (fixed-size-list, dimension, float16-vs-float32, subvector
divisibility) is deferred to the isolated worker per Sections 2.4/4.2
because `LanceTypeConverter` erases those facts.
- Target-aware routing in `AlterTableCommand.validate` (after the
existing table `ALTER` privilege check and catalog resolution, before
any op validation): top-level CREATE/DROP INDEX on a Lance table is
statically validated and then rejected with a typed per-op message
before any `Env.getNextId()` allocation. Lance REST catalogs receive a
fixed unsupported error mirroring PR1's `SHOW INDEX` stance. Internal
tables never enter this branch.
Explicitly not in this PR: durable job records, fences, quotas, and
replay (PR3B); authoritative `IF` semantics, admission, and job SQL
(Section 2.2, PR3C); dispatch and the isolated worker (PR3D/slice 4);
`FORCE_RELEASE` (PR3E); the mutation enablement configuration (Section
9.7, arrives with admission). Property-value normalization is
validation-local; persisting normalized values belongs to admission.
`SHOW INDEX` behavior from PR1 and the PR2 inspection surface are
untouched.
Known accepted behavior change: the new `BTREE` keyword token makes
stored-procedure bodies that use `btree` as a bare identifier (e.g.
`CLOSE btree`) fail PL parsing, the same hazard class as the
pre-existing `ANN` token; all other identifier positions are covered by
`nonReserved`.
### Release note
Add the `CREATE [OR REPLACE] INDEX ... USING ANN/BTREE/BITMAP` and `DROP
INDEX` SQL surface for Lance Directory catalog tables with static
validation; statements are currently rejected with a typed not-supported
error while the mutation lifecycle is staged.
Co-authored-by: u70b3 <[email protected]>
---
.../antlr4/org/apache/doris/nereids/DorisLexer.g4 | 1 +
.../antlr4/org/apache/doris/nereids/DorisParser.g4 | 5 +-
.../java/org/apache/doris/common/ErrorCode.java | 5 +
.../lance/LanceIndexMutationValidator.java | 230 +++++++++++
.../doris/nereids/parser/LogicalPlanBuilder.java | 10 +-
.../trees/plans/commands/AlterTableCommand.java | 51 ++-
.../trees/plans/commands/info/IndexDefinition.java | 49 ++-
.../lance/LanceIndexMutationValidatorTest.java | 459 +++++++++++++++++++++
.../nereids/parser/CreateIndexParserTest.java | 187 +++++++++
.../commands/AlterTableCommandLanceIndexTest.java | 375 +++++++++++++++++
.../lance/test_lance_index_ddl.groovy | 176 ++++++++
11 files changed, 1541 insertions(+), 7 deletions(-)
diff --git a/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisLexer.g4
b/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisLexer.g4
index a56811806ff..f54117a4596 100644
--- a/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisLexer.g4
+++ b/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisLexer.g4
@@ -113,6 +113,7 @@ BOTH: 'BOTH';
BRANCH: 'BRANCH';
BRIEF: 'BRIEF';
BROKER: 'BROKER';
+BTREE: 'BTREE';
BUCKETS: 'BUCKETS';
BUILD: 'BUILD';
BUILTIN: 'BUILTIN';
diff --git a/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4
b/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4
index cb84945ce76..4d97fdab5b8 100644
--- a/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4
+++ b/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4
@@ -234,9 +234,9 @@ supportedCreateStatement
name=identifier properties=propertyClause?
#createStoragePolicy
| BUILD INDEX (name=identifier)? ON tableName=multipartIdentifier
partitionSpec?
#buildIndex
- | CREATE INDEX (IF NOT EXISTS)? name=identifier
+ | CREATE (OR REPLACE)? INDEX (IF NOT EXISTS)? name=identifier
ON tableName=multipartIdentifier identifierList
- (USING (NGRAM_BF | INVERTED | ANN))?
+ (USING (NGRAM_BF | INVERTED | ANN | BTREE | BITMAP))?
properties=propertyClause? (COMMENT STRING_LITERAL)?
#createIndex
| CREATE WORKLOAD POLICY (IF NOT EXISTS)? name=identifierOrText
(CONDITIONS LEFT_PAREN workloadPolicyConditions RIGHT_PAREN)?
@@ -2046,6 +2046,7 @@ nonReserved
| BRANCH
| BRIEF
| BROKER
+ | BTREE
| BUCKETS
| BUILD
| BUILTIN
diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/ErrorCode.java
b/fe/fe-core/src/main/java/org/apache/doris/common/ErrorCode.java
index 8f5fe32bb30..92e6cd82a58 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/common/ErrorCode.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/common/ErrorCode.java
@@ -1234,6 +1234,11 @@ public enum ErrorCode {
ERR_NO_CLUSTER_ERROR(5099, new byte[]{'4', '2', '0', '0', '0'}, "No
compute group (cloud cluster) selected"),
+ ERR_LANCE_INDEX_INVALID(5100, new byte[]{'4', '2', '0', '0', '0'}, "%s"),
+
+ ERR_LANCE_INDEX_OPERATION_NOT_SUPPORTED(5101, new byte[]{'4', '2', '0',
'0', '0'},
+ "%s is not supported for Lance %s"),
+
ERR_NOT_CLOUD_MODE(6000, new byte[]{'4', '2', '0', '0', '0'},
"Command only support in cloud mode.");
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceIndexMutationValidator.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceIndexMutationValidator.java
new file mode 100644
index 00000000000..bce0ca061a7
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceIndexMutationValidator.java
@@ -0,0 +1,230 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.lance;
+
+import org.apache.doris.analysis.IndexDef;
+import org.apache.doris.catalog.ArrayType;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.common.ErrorCode;
+import org.apache.doris.common.ErrorReport;
+import org.apache.doris.nereids.trees.plans.commands.info.IndexDefinition;
+
+import com.google.common.collect.ImmutableSet;
+
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Static FE-side validation for CREATE/DROP INDEX statements targeting Lance
catalog tables.
+ *
+ * <p>These are the FE static bounds of the Lance index lifecycle design
(section 2.4). Exact
+ * Arrow-level revalidation — fixed-size-list-ness, vector dimension, float16
vs float32, and
+ * num_sub_vectors divisibility — is deferred to the isolated index-build
worker per design
+ * sections 2.4/4.2, because {@link LanceTypeConverter} erases those facts
when mapping Arrow
+ * types to Doris types (LanceTypeConverter.java:101-106).
+ */
+public final class LanceIndexMutationValidator {
+ private static final int MAX_INDEX_NAME_BYTES = 64;
+ private static final Set<String> ANN_PROPERTY_KEYS = ImmutableSet.of(
+ "index_type", "metric", "num_partitions", "num_sub_vectors",
"num_bits");
+ private static final Set<String> ANN_METRICS = ImmutableSet.of("l2",
"cosine", "dot");
+
+ private LanceIndexMutationValidator() {
+ }
+
+ /**
+ * Validates a top-level CREATE [OR REPLACE] INDEX statement against a
Lance catalog table.
+ * Returns normally when the statement is statically valid; the caller
then decides whether
+ * the statement is admitted.
+ */
+ public static void validateCreateIndex(LanceExternalCatalog catalog,
LanceExternalTable table,
+ IndexDefinition def) throws AnalysisException {
+ validateCreateIndexCatalog(catalog, def);
+ String lanceType = def.getLanceIndexType();
+ if (lanceType == null) {
+ lanceType = def.getIndexType() == IndexDef.IndexType.ANN ? "ANN" :
null;
+ }
+ if (lanceType == null) {
+ rejectInvalidDefinition("Lance catalog tables only support USING
ANN, BTREE, or BITMAP");
+ }
+ if (def.getCols() == null || def.getCols().size() != 1) {
+ rejectInvalidDefinition("Lance index must be built on exactly one
column");
+ }
+ validateIndexName(def.getIndexName());
+ String columnName = def.getCols().get(0);
+ Column column = table.getColumn(columnName);
+ if (column == null) {
+ rejectInvalidDefinition("Index column '" + columnName + "' does
not exist");
+ }
+ if (column.isAllowNull()) {
+ rejectInvalidDefinition(lanceType + " index must be built on a
column that is not nullable");
+ }
+ switch (lanceType) {
+ case "ANN":
+ validateAnnIndex(column, def.getProperties());
+ break;
+ case "BTREE":
+ validateBtreeIndex(column, def.getProperties());
+ break;
+ case "BITMAP":
+ validateBitmapIndex(column, def.getProperties());
+ break;
+ default:
+ rejectInvalidDefinition("Lance catalog tables only support
USING ANN, BTREE, or BITMAP");
+ }
+ }
+
+ /**
+ * Rejects CREATE INDEX against a Lance REST catalog without resolving its
database or table.
+ */
+ public static void validateCreateIndexCatalog(LanceExternalCatalog
catalog, IndexDefinition def)
+ throws AnalysisException {
+ if (catalog.isRestCatalogConfigured()) {
+ rejectUnsupportedOperation(def.isOrReplace() ? "CREATE OR REPLACE
INDEX" : "CREATE INDEX",
+ "REST catalogs");
+ }
+ }
+
+ /**
+ * Validates a top-level DROP INDEX statement targeting a Lance catalog
table. The REST
+ * rejection keeps failing fast; Directory catalogs then get the same
index-name bounds as
+ * the CREATE path.
+ */
+ public static void validateDropIndex(LanceExternalCatalog catalog, String
indexName)
+ throws AnalysisException {
+ if (catalog.isRestCatalogConfigured()) {
+ rejectUnsupportedOperation("DROP INDEX", "REST catalogs");
+ }
+ validateIndexName(indexName);
+ }
+
+ /**
+ * Shared Lance index-name bounds for the CREATE and DROP paths: the name
becomes the durable
+ * logical identity that an admitted job and its same-name fence key are
built on, so
+ * null/empty names are rejected here instead of being masked as
unsupported operations.
+ */
+ private static void validateIndexName(String indexName) throws
AnalysisException {
+ if (indexName == null || indexName.isEmpty()) {
+ rejectInvalidDefinition("index name cannot be empty");
+ }
+ if (indexName.getBytes(StandardCharsets.UTF_8).length >
MAX_INDEX_NAME_BYTES) {
+ rejectInvalidDefinition("index name too long, the index name
length at most is 64.");
+ }
+ }
+
+ private static void validateAnnIndex(Column column, Map<String, String>
properties)
+ throws AnalysisException {
+ Type columnType = column.getType();
+ if (!(columnType instanceof ArrayType)) {
+ rejectInvalidDefinition("ANN index column must be array type");
+ }
+ Type itemType = ((ArrayType) columnType).getItemType();
+ if (!itemType.isScalarType(PrimitiveType.FLOAT)) {
+ rejectInvalidDefinition("ANN index column item type must be float
type");
+ }
+ // Keys match case-insensitively; the normalized view below is
validation-local only.
+ // Persisting normalized keys/values into the admitted job spec is
owned by admission.
+ Map<String, String> lowerCaseProperties = new HashMap<>();
+ for (Map.Entry<String, String> entry : properties.entrySet()) {
+ String key = entry.getKey().toLowerCase(Locale.ROOT);
+ if (!ANN_PROPERTY_KEYS.contains(key)) {
+ rejectInvalidDefinition("Unknown property '" + entry.getKey()
+ "' for Lance ANN index");
+ }
+ if (lowerCaseProperties.put(key, entry.getValue()) != null) {
+ rejectInvalidDefinition("Duplicate property '" +
entry.getKey() + "' for Lance ANN index");
+ }
+ }
+ String indexType = lowerCaseProperties.get("index_type");
+ if (indexType == null || !indexType.equalsIgnoreCase("IVF_PQ")) {
+ rejectInvalidDefinition("Lance ANN index requires property
\"index_type\" = \"IVF_PQ\"");
+ }
+ String metric = lowerCaseProperties.get("metric");
+ if (metric != null &&
!ANN_METRICS.contains(metric.toLowerCase(Locale.ROOT))) {
+ rejectInvalidDefinition("metric must be one of l2, cosine, dot");
+ }
+ checkRequiredPositiveInt(lowerCaseProperties, "num_partitions");
+ checkRequiredPositiveInt(lowerCaseProperties, "num_sub_vectors");
+ String numBits = lowerCaseProperties.get("num_bits");
+ if (numBits != null && parsePositiveInt(numBits) != 8) {
+ rejectInvalidDefinition("num_bits must be 8");
+ }
+ }
+
+ private static void validateBtreeIndex(Column column, Map<String, String>
properties)
+ throws AnalysisException {
+ if (!properties.isEmpty()) {
+ rejectInvalidDefinition("BTREE indexes do not support properties");
+ }
+ Type columnType = column.getType();
+ // LARGEINT (Arrow uint64) and TIMESTAMPTZ are included deliberately.
+ if (!columnType.isIntegerType() && !columnType.isLargeIntType()
+ && !columnType.isFloatingPointType() &&
!columnType.isDecimalV3()
+ && !columnType.isStringType() && !columnType.isDateV2()
+ && !columnType.isDatetimeV2() && !columnType.isTimeStampTz()) {
+ rejectInvalidDefinition("BTREE index does not support column type
" + columnType);
+ }
+ }
+
+ private static void validateBitmapIndex(Column column, Map<String, String>
properties)
+ throws AnalysisException {
+ if (!properties.isEmpty()) {
+ rejectInvalidDefinition("BITMAP indexes do not support
properties");
+ }
+ Type columnType = column.getType();
+ // LARGEINT (Arrow uint64) is integral, included here exactly as in
the BTREE matrix.
+ if (!columnType.isBoolean() && !columnType.isIntegerType() &&
!columnType.isLargeIntType()
+ && !columnType.isStringType() && !columnType.isDateV2()) {
+ rejectInvalidDefinition("BITMAP index does not support column type
" + columnType);
+ }
+ }
+
+ private static void checkRequiredPositiveInt(Map<String, String>
properties, String key)
+ throws AnalysisException {
+ String value = properties.get(key);
+ if (value == null || parsePositiveInt(value) <= 0) {
+ rejectInvalidDefinition(key + " must be a positive integer");
+ }
+ }
+
+ /**
+ * Rejects a Lance index operation with a stable client-visible error code.
+ */
+ public static void rejectUnsupportedOperation(String operation, String
target)
+ throws AnalysisException {
+
ErrorReport.reportAnalysisException(ErrorCode.ERR_LANCE_INDEX_OPERATION_NOT_SUPPORTED,
+ operation, target);
+ }
+
+ private static void rejectInvalidDefinition(String detail) throws
AnalysisException {
+ ErrorReport.reportAnalysisException(ErrorCode.ERR_LANCE_INDEX_INVALID,
detail);
+ }
+
+ private static int parsePositiveInt(String value) {
+ try {
+ return Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
index 4659bf3b69e..5d485db0353 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
@@ -6488,6 +6488,10 @@ public class LogicalPlanBuilder extends
DorisParserBaseVisitor<Object> {
public Command visitCreateIndex(CreateIndexContext ctx) {
String indexName = ctx.name.getText();
boolean ifNotExists = ctx.EXISTS() != null;
+ boolean orReplace = ctx.REPLACE() != null;
+ if (orReplace && ifNotExists) {
+ throw new AnalysisException("[OR REPLACE] and [IF NOT EXISTS]
cannot used at the same time");
+ }
TableNameInfo tableNameInfo = new
TableNameInfo(visitMultipartIdentifier(ctx.tableName));
List<String> indexCols = visitIdentifierList(ctx.identifierList());
Map<String, String> properties = ctx.properties != null
@@ -6500,10 +6504,14 @@ public class LogicalPlanBuilder extends
DorisParserBaseVisitor<Object> {
indexType = "INVERTED";
} else if (ctx.ANN() != null) {
indexType = "ANN";
+ } else if (ctx.BTREE() != null) {
+ indexType = "BTREE";
+ } else if (ctx.BITMAP() != null) {
+ indexType = "BITMAP";
}
String comment = ctx.STRING_LITERAL() == null ? "" :
stripQuotes(ctx.STRING_LITERAL().getText());
IndexDefinition indexDefinition = new IndexDefinition(indexName,
ifNotExists, indexCols, indexType,
- properties, comment);
+ properties, comment, orReplace);
List<AlterTableOp> alterTableOps = Lists.newArrayList(new
CreateIndexOp(tableNameInfo,
indexDefinition, false));
return new AlterTableCommand(tableNameInfo, alterTableOps);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterTableCommand.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterTableCommand.java
index 9b7eda21119..05d6a225d2c 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterTableCommand.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterTableCommand.java
@@ -37,7 +37,11 @@ import org.apache.doris.common.ErrorReport;
import org.apache.doris.common.UserException;
import org.apache.doris.common.util.InternalDatabaseUtil;
import org.apache.doris.common.util.PropertyAnalyzer;
+import org.apache.doris.datasource.CatalogIf;
import org.apache.doris.datasource.iceberg.IcebergExternalTable;
+import org.apache.doris.datasource.lance.LanceExternalCatalog;
+import org.apache.doris.datasource.lance.LanceExternalTable;
+import org.apache.doris.datasource.lance.LanceIndexMutationValidator;
import org.apache.doris.info.TableNameInfo;
import org.apache.doris.mysql.privilege.PrivPredicate;
import org.apache.doris.nereids.trees.plans.PlanType;
@@ -47,14 +51,17 @@ import
org.apache.doris.nereids.trees.plans.commands.info.AddPartitionFieldOp;
import org.apache.doris.nereids.trees.plans.commands.info.AddRollupOp;
import org.apache.doris.nereids.trees.plans.commands.info.AlterTableOp;
import org.apache.doris.nereids.trees.plans.commands.info.ColumnDefinition;
+import org.apache.doris.nereids.trees.plans.commands.info.CreateIndexOp;
import
org.apache.doris.nereids.trees.plans.commands.info.CreateOrReplaceBranchOp;
import org.apache.doris.nereids.trees.plans.commands.info.CreateOrReplaceTagOp;
import org.apache.doris.nereids.trees.plans.commands.info.DropBranchOp;
import org.apache.doris.nereids.trees.plans.commands.info.DropColumnOp;
+import org.apache.doris.nereids.trees.plans.commands.info.DropIndexOp;
import org.apache.doris.nereids.trees.plans.commands.info.DropPartitionFieldOp;
import org.apache.doris.nereids.trees.plans.commands.info.DropRollupOp;
import org.apache.doris.nereids.trees.plans.commands.info.DropTagOp;
import org.apache.doris.nereids.trees.plans.commands.info.EnableFeatureOp;
+import org.apache.doris.nereids.trees.plans.commands.info.IndexDefinition;
import
org.apache.doris.nereids.trees.plans.commands.info.ModifyColumnCommentOp;
import org.apache.doris.nereids.trees.plans.commands.info.ModifyColumnOp;
import org.apache.doris.nereids.trees.plans.commands.info.ModifyEngineOp;
@@ -135,14 +142,31 @@ public class AlterTableCommand extends Command implements
ForwardWithSync {
String ctlName = tbl.getCtl();
String dbName = tbl.getDb();
String tableName = tbl.getTbl();
- DatabaseIf dbIf = Env.getCurrentEnv().getCatalogMgr()
- .getCatalogOrException(ctlName, catalog -> new
DdlException("Unknown catalog " + catalog))
- .getDbOrDdlException(dbName);
+ CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr()
+ .getCatalogOrException(ctlName, catalogName -> new
DdlException("Unknown catalog " + catalogName));
+ validateLanceIndexOperationsBeforeResolution(catalog);
+ DatabaseIf dbIf = catalog.getDbOrDdlException(dbName);
TableIf tableIf = dbIf.getTableOrDdlException(tableName);
if (tableIf.isTemporary()) {
throw new AnalysisException("Do not support alter temporary
table[" + tableName + "]");
}
checkColumnOperationsSupported(tableIf, ops);
+ if (tableIf instanceof LanceExternalTable) {
+ // Top-level CREATE/DROP INDEX on Lance catalog tables:
static-validate, then reject
+ // until the Lance index build path lands. ALTER TABLE ADD/DROP
INDEX (alter = true)
+ // falls through to the existing generic external-table rejection
below.
+ for (AlterTableOp op : ops) {
+ if (op instanceof CreateIndexOp && !((CreateIndexOp)
op).isAlter()) {
+ IndexDefinition indexDef = ((CreateIndexOp)
op).getIndexDef();
+
LanceIndexMutationValidator.validateCreateIndex((LanceExternalCatalog) catalog,
+ (LanceExternalTable) tableIf, indexDef);
+ LanceIndexMutationValidator.rejectUnsupportedOperation(
+ indexDef.isOrReplace() ? "CREATE OR REPLACE INDEX"
: "CREATE INDEX", "catalog tables");
+ } else if (op instanceof DropIndexOp && !((DropIndexOp)
op).isAlter()) {
+
LanceIndexMutationValidator.rejectUnsupportedOperation("DROP INDEX", "catalog
tables");
+ }
+ }
+ }
for (AlterTableOp op : ops) {
op.setTableName(tbl);
op.validate(ctx);
@@ -154,6 +178,27 @@ public class AlterTableCommand extends Command implements
ForwardWithSync {
}
}
+ /**
+ * Lance index checks that do not need the resolved table: REST catalogs
fail fast for
+ * top-level CREATE/DROP INDEX before database/table metadata resolution,
and DROP INDEX on
+ * Directory catalogs gets the shared index-name bounds here because the
op-level
+ * DropIndexOp.validate() is never reached on the typed-rejection path
below.
+ */
+ private void validateLanceIndexOperationsBeforeResolution(CatalogIf
catalog) throws AnalysisException {
+ if (!(catalog instanceof LanceExternalCatalog)) {
+ return;
+ }
+ for (AlterTableOp op : ops) {
+ if (op instanceof CreateIndexOp && !((CreateIndexOp)
op).isAlter()) {
+
LanceIndexMutationValidator.validateCreateIndexCatalog((LanceExternalCatalog)
catalog,
+ ((CreateIndexOp) op).getIndexDef());
+ } else if (op instanceof DropIndexOp && !((DropIndexOp)
op).isAlter()) {
+
LanceIndexMutationValidator.validateDropIndex((LanceExternalCatalog) catalog,
+ ((DropIndexOp) op).getIndexName());
+ }
+ }
+ }
+
static void checkColumnOperationsSupported(TableIf table,
List<AlterTableOp> alterTableOps)
throws AnalysisException {
if (table instanceof IcebergExternalTable) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/IndexDefinition.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/IndexDefinition.java
index 8ab371db208..d725fd7191b 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/IndexDefinition.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/IndexDefinition.java
@@ -52,6 +52,10 @@ public class IndexDefinition {
// instead of the column name which from DorisParser
private List<String> caseSensitivityCols = Lists.newArrayList();
private IndexType indexType;
+ // Lance-only index type name ("BTREE" or "BITMAP"); null for internal
index types.
+ // Deliberately kept out of IndexDef.IndexType so the persisted internal
enum is untouched.
+ private final String lanceIndexType;
+ private final boolean orReplace;
private Map<String, String> properties = new HashMap<>();
private boolean isBuildDeferred = false;
@@ -64,9 +68,19 @@ public class IndexDefinition {
*/
public IndexDefinition(String name, boolean ifNotExists, List<String>
cols, String indexTypeName,
Map<String, String> properties, String comment) {
+ this(name, ifNotExists, cols, indexTypeName, properties, comment,
false);
+ }
+
+ /**
+ * constructor for IndexDefinition
+ */
+ public IndexDefinition(String name, boolean ifNotExists, List<String>
cols, String indexTypeName,
+ Map<String, String> properties, String comment, boolean orReplace)
{
this.name = name;
this.ifNotExists = ifNotExists;
+ this.orReplace = orReplace;
this.cols = Utils.copyRequiredList(cols);
+ String lanceType = null;
this.indexType = IndexType.INVERTED;
if (indexTypeName != null) {
switch (indexTypeName) {
@@ -82,10 +96,19 @@ public class IndexDefinition {
this.indexType = IndexType.ANN;
break;
}
+ case "BTREE":
+ case "BITMAP": {
+ // Lance catalog index types: no IndexDef.IndexType
mapping, validate() rejects
+ // them for internal tables before any internal code path
reads indexType.
+ this.indexType = null;
+ lanceType = indexTypeName;
+ break;
+ }
default:
throw new AnalysisException("unknown index type " +
indexTypeName);
}
}
+ this.lanceIndexType = lanceType;
if (properties != null) {
this.properties.putAll(properties);
@@ -105,6 +128,8 @@ public class IndexDefinition {
public IndexDefinition(String name, PartitionNamesInfo partitionNames,
IndexType indexType) {
this.name = name;
this.indexType = indexType;
+ this.lanceIndexType = null;
+ this.orReplace = false;
this.partitionNames = partitionNames;
this.isBuildDeferred = true;
this.cols = null;
@@ -233,6 +258,14 @@ public class IndexDefinition {
* validate
*/
public void validate() {
+ // Lance-only syntax guards: these fire before any internal validation
so that SQL which
+ // only parses for Lance catalog tables never reaches internal index
code paths.
+ if (lanceIndexType != null) {
+ throw new AnalysisException("USING " + lanceIndexType + " is only
supported for Lance catalog tables");
+ }
+ if (orReplace) {
+ throw new AnalysisException("CREATE OR REPLACE INDEX is only
supported for Lance catalog tables");
+ }
if (partitionNames != null) {
partitionNames.validate();
}
@@ -279,6 +312,10 @@ public class IndexDefinition {
}
}
+ public List<String> getCols() {
+ return cols;
+ }
+
public String getIndexName() {
return name;
}
@@ -287,6 +324,14 @@ public class IndexDefinition {
return indexType;
}
+ public boolean isOrReplace() {
+ return orReplace;
+ }
+
+ public String getLanceIndexType() {
+ return lanceIndexType;
+ }
+
public Index translateToCatalogStyle() {
return new Index(Env.getCurrentEnv().getNextId(), name, cols,
indexType, properties,
comment);
@@ -315,7 +360,7 @@ public class IndexDefinition {
* toSql
*/
public String toSql(String tableName) {
- StringBuilder sb = new StringBuilder("INDEX ");
+ StringBuilder sb = new StringBuilder(orReplace ? "OR REPLACE INDEX " :
"INDEX ");
sb.append(name);
if (tableName != null && !tableName.isEmpty()) {
sb.append(" ON ").append(tableName);
@@ -335,6 +380,8 @@ public class IndexDefinition {
}
if (indexType != null) {
sb.append(" USING ").append(indexType.toString());
+ } else if (lanceIndexType != null) {
+ sb.append(" USING ").append(lanceIndexType);
}
if (properties != null && properties.size() > 0) {
sb.append(" PROPERTIES(");
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceIndexMutationValidatorTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceIndexMutationValidatorTest.java
new file mode 100644
index 00000000000..a3a108e575d
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceIndexMutationValidatorTest.java
@@ -0,0 +1,459 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.lance;
+
+import org.apache.doris.catalog.ArrayType;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.common.ErrorCode;
+import org.apache.doris.nereids.trees.plans.commands.info.IndexDefinition;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Unit coverage for the section 2.4 static validation matrix in
+ * {@link LanceIndexMutationValidator}. Every check is exercised positively
and negatively;
+ * the per-op typed rejections that follow a successful validation live in
+ * AlterTableCommandLanceIndexTest.
+ */
+public class LanceIndexMutationValidatorTest {
+
+ private static LanceExternalCatalog filesystemCatalog() {
+ LanceExternalCatalog catalog =
Mockito.mock(LanceExternalCatalog.class);
+ Mockito.when(catalog.isRestCatalogConfigured()).thenReturn(false);
+ return catalog;
+ }
+
+ private static LanceExternalCatalog restCatalog() {
+ LanceExternalCatalog catalog =
Mockito.mock(LanceExternalCatalog.class);
+ Mockito.when(catalog.isRestCatalogConfigured()).thenReturn(true);
+ return catalog;
+ }
+
+ /**
+ * Builds a Lance table mock whose getColumn lookup is case-insensitive,
mirroring
+ * ExternalTable.getColumn.
+ */
+ private static LanceExternalTable tableWithColumns(Map<String, Column>
columns) {
+ LanceExternalTable table = Mockito.mock(LanceExternalTable.class);
+
Mockito.when(table.getColumn(Mockito.anyString())).thenAnswer(invocation -> {
+ String name = invocation.getArgument(0);
+ for (Map.Entry<String, Column> entry : columns.entrySet()) {
+ if (entry.getKey().equalsIgnoreCase(name)) {
+ return entry.getValue();
+ }
+ }
+ return null;
+ });
+ return table;
+ }
+
+ private static Column notNullColumn(String name, Type type) {
+ return new Column(name, type, false, null, false, null, "");
+ }
+
+ private static Column nullableColumn(String name, Type type) {
+ return new Column(name, type, false, null, true, null, "");
+ }
+
+ private static IndexDefinition indexDef(String name, List<String> cols,
String indexTypeName,
+ Map<String, String> properties) {
+ return new IndexDefinition(name, false, cols, indexTypeName,
properties, "");
+ }
+
+ private static IndexDefinition annDef(Map<String, String> properties) {
+ return indexDef("idx", Collections.singletonList("v"), "ANN",
properties);
+ }
+
+ private static Map<String, String> validAnnProperties() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("index_type", "IVF_PQ");
+ properties.put("metric", "l2");
+ properties.put("num_partitions", "256");
+ properties.put("num_sub_vectors", "16");
+ return properties;
+ }
+
+ private static LanceExternalTable annTable() {
+ return tableWithColumns(Collections.singletonMap("v",
+ notNullColumn("v", new ArrayType(Type.FLOAT))));
+ }
+
+ private static void assertRejected(String expectedMessage, IndexDefinition
def,
+ LanceExternalTable table) {
+ AnalysisException exception =
Assertions.assertThrows(AnalysisException.class,
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), table,
def));
+ Assertions.assertEquals(expectedMessage, exception.getDetailMessage());
+ Assertions.assertEquals(ErrorCode.ERR_LANCE_INDEX_INVALID,
exception.getMysqlErrorCode());
+ }
+
+ @Test
+ public void testAnnHappyPath() {
+ Assertions.assertDoesNotThrow(
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), annTable(),
+ annDef(validAnnProperties())));
+ // num_bits = 8 is the one accepted optional value.
+ Map<String, String> withNumBits = validAnnProperties();
+ withNumBits.put("num_bits", "8");
+ Assertions.assertDoesNotThrow(
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), annTable(),
+ annDef(withNumBits)));
+ // metric is optional.
+ Map<String, String> noMetric = validAnnProperties();
+ noMetric.remove("metric");
+ Assertions.assertDoesNotThrow(
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), annTable(),
+ annDef(noMetric)));
+ // Column lookup is case-insensitive.
+ Assertions.assertDoesNotThrow(
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), annTable(),
+ indexDef("idx", Collections.singletonList("V"), "ANN",
validAnnProperties())));
+ }
+
+ @Test
+ public void testAnnPropertyKeysAndValuesAreCaseInsensitive() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("Index_Type", "ivf_pq");
+ properties.put("METRIC", "COSINE");
+ properties.put("Num_Partitions", "256");
+ properties.put("NUM_SUB_VECTORS", "16");
+ Assertions.assertDoesNotThrow(
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), annTable(),
+ annDef(properties)));
+ }
+
+ @Test
+ public void testAnnCaseVariantDuplicatePropertyRejected() {
+ // Case-variant duplicates must fail deterministically regardless of
map iteration order.
+ for (String badValue : new String[] {"garbage", "256"}) {
+ Map<String, String> properties = validAnnProperties();
+ properties.put("NUM_PARTITIONS", badValue);
+ assertRejected("Duplicate property 'NUM_PARTITIONS' for Lance ANN
index",
+ annDef(properties), annTable());
+ }
+ }
+
+ @Test
+ public void testAnnNullableColumnRejected() {
+ LanceExternalTable table =
tableWithColumns(Collections.singletonMap("v",
+ nullableColumn("v", new ArrayType(Type.FLOAT))));
+ assertRejected("ANN index must be built on a column that is not
nullable",
+ annDef(validAnnProperties()), table);
+ }
+
+ @Test
+ public void testAnnNonArrayColumnRejected() {
+ LanceExternalTable table =
tableWithColumns(Collections.singletonMap("v",
+ notNullColumn("v", Type.FLOAT)));
+ assertRejected("ANN index column must be array type",
annDef(validAnnProperties()), table);
+ }
+
+ @Test
+ public void testAnnDoubleItemTypeRejected() {
+ LanceExternalTable table =
tableWithColumns(Collections.singletonMap("v",
+ notNullColumn("v", new ArrayType(Type.DOUBLE))));
+ assertRejected("ANN index column item type must be float type",
+ annDef(validAnnProperties()), table);
+ }
+
+ @Test
+ public void testAnnIndexTypePropertyRequired() {
+ Map<String, String> missing = validAnnProperties();
+ missing.remove("index_type");
+ assertRejected("Lance ANN index requires property \"index_type\" =
\"IVF_PQ\"",
+ annDef(missing), annTable());
+
+ Map<String, String> wrongValue = validAnnProperties();
+ wrongValue.put("index_type", "IVF_FLAT");
+ assertRejected("Lance ANN index requires property \"index_type\" =
\"IVF_PQ\"",
+ annDef(wrongValue), annTable());
+ }
+
+ @Test
+ public void testAnnMetricValidated() {
+ Map<String, String> badMetric = validAnnProperties();
+ badMetric.put("metric", "l1");
+ assertRejected("metric must be one of l2, cosine, dot",
annDef(badMetric), annTable());
+
+ for (String metric : new String[] {"l2", "cosine", "dot", "L2",
"Cosine", "DOT"}) {
+ Map<String, String> properties = validAnnProperties();
+ properties.put("metric", metric);
+ Assertions.assertDoesNotThrow(
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), annTable(),
+ annDef(properties)));
+ }
+ }
+
+ @Test
+ public void testAnnPositiveIntegerProperties() {
+ for (String key : new String[] {"num_partitions", "num_sub_vectors"}) {
+ Map<String, String> missing = validAnnProperties();
+ missing.remove(key);
+ assertRejected(key + " must be a positive integer",
annDef(missing), annTable());
+
+ for (String badValue : new String[] {"0", "-1", "abc", "1.5"}) {
+ Map<String, String> bad = validAnnProperties();
+ bad.put(key, badValue);
+ assertRejected(key + " must be a positive integer",
annDef(bad), annTable());
+ }
+ }
+ }
+
+ @Test
+ public void testAnnNumBitsMustBeEight() {
+ Map<String, String> nine = validAnnProperties();
+ nine.put("num_bits", "9");
+ assertRejected("num_bits must be 8", annDef(nine), annTable());
+
+ Map<String, String> notNumeric = validAnnProperties();
+ notNumeric.put("num_bits", "abc");
+ assertRejected("num_bits must be 8", annDef(notNumeric), annTable());
+ }
+
+ @Test
+ public void testAnnUnknownPropertyRejected() {
+ Map<String, String> properties = validAnnProperties();
+ properties.put("Foo", "1");
+ assertRejected("Unknown property 'Foo' for Lance ANN index",
annDef(properties), annTable());
+ }
+
+ @Test
+ public void testBtreeAllowedColumnTypes() {
+ for (Type type : new Type[] {Type.TINYINT, Type.SMALLINT, Type.INT,
Type.BIGINT, Type.LARGEINT,
+ Type.FLOAT, Type.DOUBLE, Type.DEFAULT_DECIMALV3, Type.STRING,
Type.DATEV2,
+ Type.DATETIMEV2, Type.TIMESTAMP_TZ}) {
+ LanceExternalTable table =
tableWithColumns(Collections.singletonMap("c",
+ notNullColumn("c", type)));
+ IndexDefinition def = indexDef("idx",
Collections.singletonList("c"), "BTREE",
+ Collections.emptyMap());
+ Assertions.assertDoesNotThrow(
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), table,
def),
+ "BTREE should accept column type " + type);
+ }
+ }
+
+ @Test
+ public void testBtreeRejectedColumnTypes() {
+ for (Type type : new Type[] {Type.BOOLEAN, Type.TIMEV2, Type.VARBINARY,
+ new ArrayType(Type.INT)}) {
+ LanceExternalTable table =
tableWithColumns(Collections.singletonMap("c",
+ notNullColumn("c", type)));
+ IndexDefinition def = indexDef("idx",
Collections.singletonList("c"), "BTREE",
+ Collections.emptyMap());
+ AnalysisException exception =
Assertions.assertThrows(AnalysisException.class,
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), table,
def));
+ Assertions.assertTrue(
+ exception.getDetailMessage().startsWith("BTREE index does
not support column type"),
+ "unexpected message for type " + type + ": " +
exception.getDetailMessage());
+ }
+ }
+
+ @Test
+ public void testBtreePropertiesRejected() {
+ LanceExternalTable table =
tableWithColumns(Collections.singletonMap("c",
+ notNullColumn("c", Type.INT)));
+ IndexDefinition def = indexDef("idx", Collections.singletonList("c"),
"BTREE",
+ Collections.singletonMap("k", "v"));
+ assertRejected("BTREE indexes do not support properties", def, table);
+ }
+
+ @Test
+ public void testBtreeNullableColumnRejected() {
+ LanceExternalTable table =
tableWithColumns(Collections.singletonMap("c",
+ nullableColumn("c", Type.INT)));
+ IndexDefinition def = indexDef("idx", Collections.singletonList("c"),
"BTREE",
+ Collections.emptyMap());
+ assertRejected("BTREE index must be built on a column that is not
nullable", def, table);
+ }
+
+ @Test
+ public void testBitmapAllowedColumnTypes() {
+ for (Type type : new Type[] {Type.BOOLEAN, Type.TINYINT,
Type.SMALLINT, Type.INT, Type.BIGINT,
+ Type.LARGEINT, Type.STRING, Type.DATEV2}) {
+ LanceExternalTable table =
tableWithColumns(Collections.singletonMap("c",
+ notNullColumn("c", type)));
+ IndexDefinition def = indexDef("idx",
Collections.singletonList("c"), "BITMAP",
+ Collections.emptyMap());
+ Assertions.assertDoesNotThrow(
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), table,
def),
+ "BITMAP should accept column type " + type);
+ }
+ }
+
+ @Test
+ public void testBitmapRejectedColumnTypes() {
+ // LARGEINT (Arrow uint64) is integral and accepted, exactly as in the
BTREE matrix.
+ for (Type type : new Type[] {Type.FLOAT, Type.DATETIMEV2}) {
+ LanceExternalTable table =
tableWithColumns(Collections.singletonMap("c",
+ notNullColumn("c", type)));
+ IndexDefinition def = indexDef("idx",
Collections.singletonList("c"), "BITMAP",
+ Collections.emptyMap());
+ AnalysisException exception =
Assertions.assertThrows(AnalysisException.class,
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), table,
def));
+ Assertions.assertTrue(
+ exception.getDetailMessage().startsWith("BITMAP index does
not support column type"),
+ "unexpected message for type " + type + ": " +
exception.getDetailMessage());
+ }
+ }
+
+ @Test
+ public void testBitmapPropertiesRejected() {
+ LanceExternalTable table =
tableWithColumns(Collections.singletonMap("c",
+ notNullColumn("c", Type.INT)));
+ IndexDefinition def = indexDef("idx", Collections.singletonList("c"),
"BITMAP",
+ Collections.singletonMap("k", "v"));
+ assertRejected("BITMAP indexes do not support properties", def, table);
+ }
+
+ @Test
+ public void testMultiColumnRejected() {
+ LanceExternalTable table =
tableWithColumns(Collections.singletonMap("v",
+ notNullColumn("v", new ArrayType(Type.FLOAT))));
+ IndexDefinition def = indexDef("idx", Arrays.asList("v", "v"), "ANN",
validAnnProperties());
+ assertRejected("Lance index must be built on exactly one column", def,
table);
+ }
+
+ @Test
+ public void testMissingColumnRejected() {
+ IndexDefinition def = indexDef("idx",
Collections.singletonList("nope"), "ANN",
+ validAnnProperties());
+ assertRejected("Index column 'nope' does not exist", def, annTable());
+ }
+
+ @Test
+ public void testIndexNameLengthBoundIsInUtf8Bytes() {
+ char[] chars = new char[64];
+ Arrays.fill(chars, 'a');
+ IndexDefinition sixtyFour = indexDef(new String(chars),
Collections.singletonList("v"),
+ "ANN", validAnnProperties());
+ Assertions.assertDoesNotThrow(
+ () ->
LanceIndexMutationValidator.validateCreateIndex(filesystemCatalog(), annTable(),
+ sixtyFour));
+
+ char[] tooLong = new char[65];
+ Arrays.fill(tooLong, 'a');
+ IndexDefinition sixtyFive = indexDef(new String(tooLong),
Collections.singletonList("v"),
+ "ANN", validAnnProperties());
+ assertRejected("index name too long, the index name length at most is
64.", sixtyFive, annTable());
+
+ // 33 two-byte characters are 66 UTF-8 bytes: the bound counts bytes,
not characters.
+ StringBuilder multibyte = new StringBuilder();
+ for (int i = 0; i < 33; i++) {
+ multibyte.append('é');
+ }
+ IndexDefinition multibyteName = indexDef(multibyte.toString(),
Collections.singletonList("v"),
+ "ANN", validAnnProperties());
+ assertRejected("index name too long, the index name length at most is
64.", multibyteName, annTable());
+ }
+
+ @Test
+ public void testUnsupportedIndexTypesRejected() {
+ // A CREATE INDEX without USING defaults to INVERTED, which is not a
Lance index type.
+ IndexDefinition inverted = indexDef("idx",
Collections.singletonList("v"), "INVERTED",
+ Collections.emptyMap());
+ assertRejected("Lance catalog tables only support USING ANN, BTREE, or
BITMAP", inverted, annTable());
+
+ IndexDefinition ngram = indexDef("idx",
Collections.singletonList("v"), "NGRAM_BF",
+ Collections.emptyMap());
+ assertRejected("Lance catalog tables only support USING ANN, BTREE, or
BITMAP", ngram, annTable());
+
+ IndexDefinition noUsing = indexDef("idx",
Collections.singletonList("v"), null,
+ Collections.emptyMap());
+ assertRejected("Lance catalog tables only support USING ANN, BTREE, or
BITMAP", noUsing, annTable());
+ }
+
+ @Test
+ public void testRestCatalogRejectedBeforeAnyOtherCheck() {
+ IndexDefinition createDef = indexDef("idx",
Collections.singletonList("missing"), "BTREE",
+ Collections.singletonMap("k", "v"));
+ AnalysisException createException =
Assertions.assertThrows(AnalysisException.class,
+ () ->
LanceIndexMutationValidator.validateCreateIndex(restCatalog(), annTable(),
createDef));
+ Assertions.assertEquals("CREATE INDEX is not supported for Lance REST
catalogs",
+ createException.getDetailMessage());
+
Assertions.assertEquals(ErrorCode.ERR_LANCE_INDEX_OPERATION_NOT_SUPPORTED,
+ createException.getMysqlErrorCode());
+
+ IndexDefinition orReplaceDef = new IndexDefinition("idx", false,
Collections.singletonList("v"),
+ "BTREE", Collections.emptyMap(), "", true);
+ AnalysisException orReplaceException =
Assertions.assertThrows(AnalysisException.class,
+ () ->
LanceIndexMutationValidator.validateCreateIndex(restCatalog(), annTable(),
orReplaceDef));
+ Assertions.assertEquals("CREATE OR REPLACE INDEX is not supported for
Lance REST catalogs",
+ orReplaceException.getDetailMessage());
+
Assertions.assertEquals(ErrorCode.ERR_LANCE_INDEX_OPERATION_NOT_SUPPORTED,
+ orReplaceException.getMysqlErrorCode());
+
+ AnalysisException dropException =
Assertions.assertThrows(AnalysisException.class,
+ () ->
LanceIndexMutationValidator.validateDropIndex(restCatalog(), "idx"));
+ Assertions.assertEquals("DROP INDEX is not supported for Lance REST
catalogs",
+ dropException.getDetailMessage());
+
Assertions.assertEquals(ErrorCode.ERR_LANCE_INDEX_OPERATION_NOT_SUPPORTED,
+ dropException.getMysqlErrorCode());
+
+ // The REST fail-fast precedes even the index-name bounds on the DROP
path.
+ AnalysisException dropEmptyNameException =
Assertions.assertThrows(AnalysisException.class,
+ () ->
LanceIndexMutationValidator.validateDropIndex(restCatalog(), ""));
+ Assertions.assertEquals("DROP INDEX is not supported for Lance REST
catalogs",
+ dropEmptyNameException.getDetailMessage());
+
Assertions.assertEquals(ErrorCode.ERR_LANCE_INDEX_OPERATION_NOT_SUPPORTED,
+ dropEmptyNameException.getMysqlErrorCode());
+ }
+
+ @Test
+ public void testBlankIndexNameRejectedOnBothPaths() {
+ // Nereids accepts an empty backquoted identifier (``), so both the
CREATE and the DROP
+ // path must reject the empty name instead of masking it as an
unsupported operation.
+ assertRejected("index name cannot be empty",
+ indexDef("", Collections.singletonList("v"), "ANN",
validAnnProperties()), annTable());
+ assertRejected("index name cannot be empty",
+ indexDef(null, Collections.singletonList("v"), "ANN",
validAnnProperties()), annTable());
+
+ for (String blankName : new String[] {null, ""}) {
+ AnalysisException exception =
Assertions.assertThrows(AnalysisException.class,
+ () ->
LanceIndexMutationValidator.validateDropIndex(filesystemCatalog(), blankName));
+ Assertions.assertEquals("index name cannot be empty",
exception.getDetailMessage());
+ Assertions.assertEquals(ErrorCode.ERR_LANCE_INDEX_INVALID,
exception.getMysqlErrorCode());
+ }
+ }
+
+ @Test
+ public void testDropIndexNameLengthBound() {
+ char[] tooLong = new char[65];
+ Arrays.fill(tooLong, 'a');
+ AnalysisException exception =
Assertions.assertThrows(AnalysisException.class,
+ () ->
LanceIndexMutationValidator.validateDropIndex(filesystemCatalog(),
+ new String(tooLong)));
+ Assertions.assertEquals("index name too long, the index name length at
most is 64.",
+ exception.getDetailMessage());
+ Assertions.assertEquals(ErrorCode.ERR_LANCE_INDEX_INVALID,
exception.getMysqlErrorCode());
+ }
+
+ @Test
+ public void testDropIndexOnFilesystemCatalogPasses() {
+ Assertions.assertDoesNotThrow(
+ () ->
LanceIndexMutationValidator.validateDropIndex(filesystemCatalog(), "idx"));
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/CreateIndexParserTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/CreateIndexParserTest.java
new file mode 100644
index 00000000000..c48b63269c2
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/CreateIndexParserTest.java
@@ -0,0 +1,187 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.parser;
+
+import org.apache.doris.analysis.IndexDef;
+import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.exceptions.ParseException;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.commands.AlterTableCommand;
+import org.apache.doris.nereids.trees.plans.commands.info.AlterTableOp;
+import org.apache.doris.nereids.trees.plans.commands.info.CreateIndexOp;
+import org.apache.doris.nereids.trees.plans.commands.info.DropIndexOp;
+import org.apache.doris.nereids.trees.plans.commands.info.IndexDefinition;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * Parser coverage for the Lance index DDL grammar: top-level CREATE [OR
REPLACE] INDEX
+ * ... USING ANN/BTREE/BITMAP and top-level DROP INDEX, plus the guarantees
that the
+ * ALTER TABLE ADD INDEX grammar rule is unchanged.
+ */
+public class CreateIndexParserTest extends ParserTestBase {
+ private final NereidsParser parser = new NereidsParser();
+
+ private CreateIndexOp parseCreateIndexOp(String sql) {
+ Plan plan = parser.parseSingle(sql);
+ AlterTableCommand command =
Assertions.assertInstanceOf(AlterTableCommand.class, plan);
+ Assertions.assertEquals(1, command.getNereidsOps().size());
+ AlterTableOp op = command.getNereidsOps().get(0);
+ return Assertions.assertInstanceOf(CreateIndexOp.class, op);
+ }
+
+ private DropIndexOp parseDropIndexOp(String sql) {
+ Plan plan = parser.parseSingle(sql);
+ AlterTableCommand command =
Assertions.assertInstanceOf(AlterTableCommand.class, plan);
+ Assertions.assertEquals(1, command.getNereidsOps().size());
+ AlterTableOp op = command.getNereidsOps().get(0);
+ return Assertions.assertInstanceOf(DropIndexOp.class, op);
+ }
+
+ @Test
+ public void testCreateAnnIndexParses() {
+ CreateIndexOp op = parseCreateIndexOp(
+ "CREATE INDEX idx ON ctl.db.tbl (v) USING ANN "
+ + "PROPERTIES(\"index_type\"=\"IVF_PQ\",
\"metric\"=\"l2\", "
+ + "\"num_partitions\"=\"256\",
\"num_sub_vectors\"=\"16\") COMMENT 'ann index'");
+ Assertions.assertFalse(op.isAlter());
+ IndexDefinition def = op.getIndexDef();
+ Assertions.assertEquals("idx", def.getIndexName());
+ Assertions.assertEquals(IndexDef.IndexType.ANN, def.getIndexType());
+ Assertions.assertNull(def.getLanceIndexType());
+ Assertions.assertFalse(def.isOrReplace());
+ Assertions.assertEquals(Collections.singletonList("v"), def.getCols());
+ Map<String, String> properties = def.getProperties();
+ Assertions.assertEquals(4, properties.size());
+ Assertions.assertEquals("IVF_PQ", properties.get("index_type"));
+ Assertions.assertEquals("l2", properties.get("metric"));
+ Assertions.assertEquals("256", properties.get("num_partitions"));
+ Assertions.assertEquals("16", properties.get("num_sub_vectors"));
+ }
+
+ @Test
+ public void testCreateBtreeIndexParses() {
+ CreateIndexOp op = parseCreateIndexOp("CREATE INDEX idx ON db.tbl (c)
USING BTREE");
+ IndexDefinition def = op.getIndexDef();
+ Assertions.assertNull(def.getIndexType());
+ Assertions.assertEquals("BTREE", def.getLanceIndexType());
+ Assertions.assertFalse(def.isOrReplace());
+ Assertions.assertEquals(Collections.singletonList("c"), def.getCols());
+ Assertions.assertTrue(def.getProperties().isEmpty());
+ }
+
+ @Test
+ public void testCreateBitmapIndexParses() {
+ CreateIndexOp op = parseCreateIndexOp("CREATE INDEX idx ON db.tbl (c)
USING BITMAP");
+ IndexDefinition def = op.getIndexDef();
+ Assertions.assertNull(def.getIndexType());
+ Assertions.assertEquals("BITMAP", def.getLanceIndexType());
+ Assertions.assertFalse(def.isOrReplace());
+ }
+
+ @Test
+ public void testLowercaseUsingClausesParse() {
+ // The lexer stream is case-insensitive; the new tokens must behave
like the old ones.
+ CreateIndexOp btreeOp = parseCreateIndexOp("create index idx on db.tbl
(c) using btree");
+ Assertions.assertEquals("BTREE",
btreeOp.getIndexDef().getLanceIndexType());
+ CreateIndexOp bitmapOp = parseCreateIndexOp("create index idx on
db.tbl (c) using bitmap");
+ Assertions.assertEquals("BITMAP",
bitmapOp.getIndexDef().getLanceIndexType());
+ }
+
+ @Test
+ public void testCreateOrReplaceIndexParses() {
+ CreateIndexOp op = parseCreateIndexOp("CREATE OR REPLACE INDEX idx ON
db.tbl (c) USING BTREE");
+ IndexDefinition def = op.getIndexDef();
+ Assertions.assertTrue(def.isOrReplace());
+ Assertions.assertEquals("BTREE", def.getLanceIndexType());
+ Assertions.assertEquals(
+ "CREATE OR REPLACE INDEX idx ON `db`.`tbl` (`c`) USING BTREE
COMMENT ''", op.toSql());
+ }
+
+ @Test
+ public void testCreateIndexIfNotExistsParses() {
+ CreateIndexOp op = parseCreateIndexOp(
+ "CREATE INDEX IF NOT EXISTS idx ON db.tbl (v) USING ANN "
+ + "PROPERTIES(\"index_type\"=\"IVF_PQ\",
\"num_partitions\"=\"256\", "
+ + "\"num_sub_vectors\"=\"16\")");
+ Assertions.assertFalse(op.getIndexDef().isOrReplace());
+ Assertions.assertEquals(IndexDef.IndexType.ANN,
op.getIndexDef().getIndexType());
+ }
+
+ @Test
+ public void testOrReplaceAndIfNotExistsAreExclusive() {
+ AnalysisException exception =
Assertions.assertThrows(AnalysisException.class,
+ () -> parser.parseSingle("CREATE OR REPLACE INDEX IF NOT
EXISTS idx ON db.tbl (c) USING BTREE"));
+ Assertions.assertEquals("[OR REPLACE] and [IF NOT EXISTS] cannot used
at the same time",
+ exception.getMessage());
+ }
+
+ @Test
+ public void testInternalUsingClausesStillParse() {
+ // Previously-parseable internal index SQL must keep working unchanged.
+ CreateIndexOp noUsing = parseCreateIndexOp("CREATE INDEX idx ON db.tbl
(c)");
+ Assertions.assertEquals(IndexDef.IndexType.INVERTED,
noUsing.getIndexDef().getIndexType());
+ Assertions.assertNull(noUsing.getIndexDef().getLanceIndexType());
+
+ CreateIndexOp inverted = parseCreateIndexOp("CREATE INDEX idx ON
db.tbl (c) USING INVERTED");
+ Assertions.assertEquals(IndexDef.IndexType.INVERTED,
inverted.getIndexDef().getIndexType());
+
+ CreateIndexOp ngram = parseCreateIndexOp(
+ "CREATE INDEX idx ON db.tbl (c) USING NGRAM_BF
PROPERTIES(\"gram_size\"=\"3\", \"bf_size\"=\"10000\")");
+ Assertions.assertEquals(IndexDef.IndexType.NGRAM_BF,
ngram.getIndexDef().getIndexType());
+
+ CreateIndexOp multiColumn = parseCreateIndexOp("CREATE INDEX idx ON
db.tbl (c1, c2) USING INVERTED");
+ Assertions.assertEquals(Arrays.asList("c1", "c2"),
multiColumn.getIndexDef().getCols());
+ }
+
+ @Test
+ public void testAlterTableAddIndexGrammarUnchanged() {
+ // The indexDef rule was deliberately not extended: Lance-only index
types stay
+ // parse errors in ALTER TABLE ADD INDEX.
+ Plan plan = parser.parseSingle("ALTER TABLE db.tbl ADD INDEX idx (c)
USING INVERTED");
+ AlterTableCommand command =
Assertions.assertInstanceOf(AlterTableCommand.class, plan);
+ CreateIndexOp op = Assertions.assertInstanceOf(CreateIndexOp.class,
command.getNereidsOps().get(0));
+ Assertions.assertTrue(op.isAlter());
+
+ Plan annPlan = parser.parseSingle("ALTER TABLE db.tbl ADD INDEX idx
(v) USING ANN");
+ AlterTableOp annOp = ((AlterTableCommand)
annPlan).getNereidsOps().get(0);
+ Assertions.assertTrue(((CreateIndexOp) annOp).isAlter());
+
+ Assertions.assertThrows(ParseException.class,
+ () -> parser.parseSingle("ALTER TABLE db.tbl ADD INDEX idx (c)
USING BTREE"));
+ Assertions.assertThrows(ParseException.class,
+ () -> parser.parseSingle("ALTER TABLE db.tbl ADD INDEX idx (c)
USING BITMAP"));
+ }
+
+ @Test
+ public void testDropIndexParses() {
+ DropIndexOp op = parseDropIndexOp("DROP INDEX idx ON db.tbl");
+ Assertions.assertEquals("idx", op.getIndexName());
+ Assertions.assertFalse(op.isAlter());
+ Assertions.assertFalse(op.isSetIfExists());
+
+ DropIndexOp ifExists = parseDropIndexOp("DROP INDEX IF EXISTS idx ON
ctl.db.tbl");
+ Assertions.assertTrue(ifExists.isSetIfExists());
+ Assertions.assertFalse(ifExists.isAlter());
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AlterTableCommandLanceIndexTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AlterTableCommandLanceIndexTest.java
new file mode 100644
index 00000000000..eac8627433e
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AlterTableCommandLanceIndexTest.java
@@ -0,0 +1,375 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.trees.plans.commands;
+
+import org.apache.doris.catalog.ArrayType;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.DatabaseIf;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.TableIf;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.common.DdlException;
+import org.apache.doris.common.ErrorCode;
+import org.apache.doris.datasource.CatalogIf;
+import org.apache.doris.datasource.CatalogMgr;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.datasource.lance.LanceExternalCatalog;
+import org.apache.doris.datasource.lance.LanceExternalDatabase;
+import org.apache.doris.datasource.lance.LanceExternalTable;
+import org.apache.doris.mysql.privilege.AccessControllerManager;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.nereids.parser.NereidsParser;
+import org.apache.doris.qe.ConnectContext;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+/**
+ * Routes-and-rejection coverage for Lance index DDL: top-level CREATE [OR
REPLACE] INDEX and
+ * DROP INDEX against Lance catalog tables run the static validation matrix
and are then
+ * rejected with typed messages (reject-all mode), ALTER TABLE ADD/DROP INDEX
keeps the generic
+ * external-table rejection, the ALTER privilege check precedes the typed
rejection, and no
+ * Env.getNextId() allocation happens on any rejected path.
+ */
+public class AlterTableCommandLanceIndexTest {
+ private static final String CTL = "lance_ctl";
+ private static final String DB = "db";
+ private static final String TBL = "tbl";
+ private static final String VALID_ANN_PROPERTIES =
+ "PROPERTIES(\"index_type\"=\"IVF_PQ\", \"metric\"=\"l2\", "
+ + "\"num_partitions\"=\"256\",
\"num_sub_vectors\"=\"16\")";
+
+ private final NereidsParser parser = new NereidsParser();
+ private ConnectContext connectContext;
+
+ @BeforeEach
+ public void setUp() {
+ connectContext = new ConnectContext();
+ connectContext.setThreadLocalInfo();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ ConnectContext.remove();
+ }
+
+ /**
+ * Fully mocked catalog resolution chain ending at a LanceExternalTable
with three NOT NULL
+ * columns: v ARRAY<FLOAT>, c INT, s STRING.
+ */
+ private static class LanceFixture implements AutoCloseable {
+ private final MockedStatic<Env> mockedEnv;
+ private final Env env;
+ private final CatalogMgr catalogMgr;
+ private final LanceExternalCatalog catalog;
+ private final LanceExternalDatabase database;
+ private final LanceExternalTable table;
+
+ LanceFixture(boolean restCatalog, boolean alterGranted) throws
DdlException {
+ mockedEnv = Mockito.mockStatic(Env.class);
+ env = Mockito.mock(Env.class);
+ catalogMgr = Mockito.mock(CatalogMgr.class);
+ AccessControllerManager accessManager =
Mockito.mock(AccessControllerManager.class);
+ catalog = Mockito.mock(LanceExternalCatalog.class);
+ database = Mockito.mock(LanceExternalDatabase.class);
+ table = Mockito.mock(LanceExternalTable.class);
+
+ mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+ Mockito.when(env.getAccessManager()).thenReturn(accessManager);
+
Mockito.when(accessManager.checkTblPriv(Mockito.any(ConnectContext.class),
+ Mockito.eq(CTL), Mockito.eq(DB), Mockito.eq(TBL),
+ Mockito.eq(PrivPredicate.ALTER))).thenReturn(alterGranted);
+ Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
+ Mockito.when(catalogMgr.getCatalogOrException(Mockito.eq(CTL),
Mockito.any()))
+ .thenReturn(catalog);
+ Mockito.doReturn(database).when(catalog).getDbOrDdlException(DB);
+ Mockito.doReturn(table).when(database).getTableOrDdlException(TBL);
+
Mockito.when(catalog.isRestCatalogConfigured()).thenReturn(restCatalog);
+
Mockito.when(table.getColumn(Mockito.anyString())).thenAnswer(invocation -> {
+ String name = invocation.getArgument(0);
+ if ("v".equalsIgnoreCase(name)) {
+ return new Column("v", new ArrayType(Type.FLOAT), false,
null, false, null, "");
+ }
+ if ("c".equalsIgnoreCase(name)) {
+ return new Column("c", Type.INT, false, null, false, null,
"");
+ }
+ if ("s".equalsIgnoreCase(name)) {
+ return new Column("s", Type.STRING, false, null, false,
null, "");
+ }
+ return null;
+ });
+
Mockito.when(table.getType()).thenReturn(TableIf.TableType.LANCE_EXTERNAL_TABLE);
+ Mockito.when(table.getName()).thenReturn(TBL);
+ }
+
+ @Override
+ public void close() {
+ mockedEnv.close();
+ }
+ }
+
+ /**
+ * Mocked catalog resolution chain ending at an internal OlapTable.
+ */
+ private static class InternalFixture implements AutoCloseable {
+ private final MockedStatic<Env> mockedEnv;
+ private final Env env;
+
+ InternalFixture() throws DdlException {
+ mockedEnv = Mockito.mockStatic(Env.class);
+ env = Mockito.mock(Env.class);
+ CatalogMgr catalogMgr = Mockito.mock(CatalogMgr.class);
+ AccessControllerManager accessManager =
Mockito.mock(AccessControllerManager.class);
+ CatalogIf internalCatalog = Mockito.mock(InternalCatalog.class);
+ DatabaseIf database = Mockito.mock(DatabaseIf.class);
+ OlapTable table = Mockito.mock(OlapTable.class);
+
+ mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+ Mockito.when(env.getAccessManager()).thenReturn(accessManager);
+
Mockito.when(accessManager.checkTblPriv(Mockito.any(ConnectContext.class),
+ Mockito.anyString(), Mockito.anyString(),
Mockito.anyString(),
+ Mockito.eq(PrivPredicate.ALTER))).thenReturn(true);
+ Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
+ Mockito.when(catalogMgr.getCatalogOrException(
+ Mockito.eq(InternalCatalog.INTERNAL_CATALOG_NAME),
Mockito.any()))
+ .thenReturn(internalCatalog);
+
Mockito.doReturn(database).when(internalCatalog).getDbOrDdlException(DB);
+ Mockito.doReturn(table).when(database).getTableOrDdlException(TBL);
+ }
+
+ @Override
+ public void close() {
+ mockedEnv.close();
+ }
+ }
+
+ private String runAndGetMessage(String sql) {
+ AlterTableCommand command = (AlterTableCommand)
parser.parseSingle(sql);
+ try {
+ command.run(connectContext, null);
+ throw new AssertionError("expected an AnalysisException but the
statement succeeded: " + sql);
+ } catch (AnalysisException e) {
+ // Catalog-resolution paths (privilege check, Lance validator,
typed rejections) throw
+ // the legacy AnalysisException; getDetailMessage() strips the
"errCode = 2" prefix.
+ return e.getDetailMessage();
+ } catch (org.apache.doris.nereids.exceptions.AnalysisException e) {
+ // IndexDefinition.validate() guards throw the nereids
AnalysisException instead.
+ return e.getMessage();
+ } catch (Exception e) {
+ throw new AssertionError("unexpected exception " +
e.getClass().getName() + ": " + e.getMessage(), e);
+ }
+ }
+
+ private AnalysisException runAndGetCommonAnalysisException(String sql) {
+ AlterTableCommand command = (AlterTableCommand)
parser.parseSingle(sql);
+ try {
+ command.run(connectContext, null);
+ throw new AssertionError("expected an AnalysisException but the
statement succeeded: " + sql);
+ } catch (AnalysisException e) {
+ return e;
+ } catch (Exception e) {
+ throw new AssertionError("unexpected exception " +
e.getClass().getName() + ": " + e.getMessage(), e);
+ }
+ }
+
+ @Test
+ public void testCreateIndexOnLanceTableIsTypedRejected() throws Exception {
+ try (LanceFixture fixture = new LanceFixture(false, true)) {
+ String message = runAndGetMessage(
+ "CREATE INDEX idx ON " + CTL + "." + DB + "." + TBL + "
(v) USING ANN "
+ + VALID_ANN_PROPERTIES);
+ Assertions.assertEquals("CREATE INDEX is not supported for Lance
catalog tables", message);
+ Mockito.verify(fixture.env, Mockito.never()).getNextId();
+ }
+ }
+
+ @Test
+ public void testCreateOrReplaceIndexOnLanceTableIsTypedRejected() throws
Exception {
+ try (LanceFixture fixture = new LanceFixture(false, true)) {
+ String message = runAndGetMessage(
+ "CREATE OR REPLACE INDEX idx ON " + CTL + "." + DB + "." +
TBL + " (c) USING BTREE");
+ Assertions.assertEquals("CREATE OR REPLACE INDEX is not supported
for Lance catalog tables",
+ message);
+ Mockito.verify(fixture.env, Mockito.never()).getNextId();
+ }
+ }
+
+ @Test
+ public void testDropIndexOnLanceTableIsTypedRejected() throws Exception {
+ try (LanceFixture fixture = new LanceFixture(false, true)) {
+ Assertions.assertEquals("DROP INDEX is not supported for Lance
catalog tables",
+ runAndGetMessage("DROP INDEX idx ON " + CTL + "." + DB +
"." + TBL));
+ // Reject-all mode is uniform: IF EXISTS does not change the
outcome.
+ Assertions.assertEquals("DROP INDEX is not supported for Lance
catalog tables",
+ runAndGetMessage("DROP INDEX IF EXISTS idx ON " + CTL +
"." + DB + "." + TBL));
+ Mockito.verify(fixture.env, Mockito.never()).getNextId();
+ }
+ }
+
+ @Test
+ public void testFilesystemCatalogRejectionsExposeNotSupportedErrorCode()
throws Exception {
+ try (LanceFixture fixture = new LanceFixture(false, true)) {
+ for (String sql : new String[] {
+ "CREATE INDEX idx ON " + CTL + "." + DB + "." + TBL + "
(c) USING BTREE",
+ "CREATE OR REPLACE INDEX idx ON " + CTL + "." + DB + "." +
TBL + " (c) USING BTREE",
+ "DROP INDEX idx ON " + CTL + "." + DB + "." + TBL}) {
+ AnalysisException exception =
runAndGetCommonAnalysisException(sql);
+
Assertions.assertEquals(ErrorCode.ERR_LANCE_INDEX_OPERATION_NOT_SUPPORTED,
+ exception.getMysqlErrorCode());
+ }
+ }
+ }
+
+ @Test
+ public void testRestCatalogMessagesFireFirst() throws Exception {
+ try (LanceFixture fixture = new LanceFixture(true, true)) {
+ Assertions.assertEquals("CREATE INDEX is not supported for Lance
REST catalogs",
+ runAndGetMessage("CREATE INDEX idx ON " + CTL + "." + DB +
"." + TBL
+ + " (v) USING ANN " + VALID_ANN_PROPERTIES));
+ Assertions.assertEquals("CREATE OR REPLACE INDEX is not supported
for Lance REST catalogs",
+ runAndGetMessage("CREATE OR REPLACE INDEX idx ON " + CTL +
"." + DB + "." + TBL
+ + " (c) USING BTREE"));
+ Assertions.assertEquals("DROP INDEX is not supported for Lance
REST catalogs",
+ runAndGetMessage("DROP INDEX idx ON " + CTL + "." + DB +
"." + TBL));
+ // The REST fail-fast precedes even the index-name bounds.
+ Assertions.assertEquals("CREATE INDEX is not supported for Lance
REST catalogs",
+ runAndGetMessage("CREATE INDEX `` ON " + CTL + "." + DB +
"." + TBL
+ + " (v) USING ANN " + VALID_ANN_PROPERTIES));
+ Assertions.assertEquals("DROP INDEX is not supported for Lance
REST catalogs",
+ runAndGetMessage("DROP INDEX `` ON " + CTL + "." + DB +
"." + TBL));
+ Mockito.verify(fixture.catalog,
Mockito.never()).getDbOrDdlException(Mockito.anyString());
+ Mockito.verify(fixture.database,
Mockito.never()).getTableOrDdlException(Mockito.anyString());
+ Mockito.verify(fixture.env, Mockito.never()).getNextId();
+ }
+ }
+
+ @Test
+ public void testStaticValidationErrorsPrecedeTheTypedRejection() throws
Exception {
+ try (LanceFixture fixture = new LanceFixture(false, true)) {
+ Assertions.assertEquals("metric must be one of l2, cosine, dot",
+ runAndGetMessage("CREATE INDEX idx ON " + CTL + "." + DB +
"." + TBL
+ + " (v) USING ANN
PROPERTIES(\"index_type\"=\"IVF_PQ\", \"metric\"=\"l1\", "
+ + "\"num_partitions\"=\"256\",
\"num_sub_vectors\"=\"16\")"));
+ Assertions.assertEquals("num_partitions must be a positive
integer",
+ runAndGetMessage("CREATE INDEX idx ON " + CTL + "." + DB +
"." + TBL
+ + " (v) USING ANN
PROPERTIES(\"index_type\"=\"IVF_PQ\", "
+ + "\"num_sub_vectors\"=\"16\")"));
+ Assertions.assertEquals("BTREE indexes do not support properties",
+ runAndGetMessage("CREATE INDEX idx ON " + CTL + "." + DB +
"." + TBL
+ + " (c) USING BTREE PROPERTIES(\"k\"=\"v\")"));
+ Assertions.assertEquals("Index column 'nope' does not exist",
+ runAndGetMessage("CREATE INDEX idx ON " + CTL + "." + DB +
"." + TBL
+ + " (nope) USING BTREE"));
+ Mockito.verify(fixture.env, Mockito.never()).getNextId();
+ }
+ }
+
+ @Test
+ public void testBlankQuotedIndexNameRejectedOnBothPaths() throws Exception
{
+ try (LanceFixture fixture = new LanceFixture(false, true)) {
+ // Nereids accepts an empty backquoted identifier, so `CREATE
INDEX `` ` and
+ // `DROP INDEX `` ` reach the command with an empty index name.
Both paths must
+ // surface the name error, not the typed unsupported-operation
rejection.
+ Assertions.assertEquals("index name cannot be empty",
+ runAndGetMessage("CREATE INDEX `` ON " + CTL + "." + DB +
"." + TBL
+ + " (v) USING ANN " + VALID_ANN_PROPERTIES));
+ Assertions.assertEquals("index name cannot be empty",
+ runAndGetMessage("CREATE OR REPLACE INDEX `` ON " + CTL +
"." + DB + "." + TBL
+ + " (c) USING BTREE"));
+ Assertions.assertEquals("index name cannot be empty",
+ runAndGetMessage("DROP INDEX `` ON " + CTL + "." + DB +
"." + TBL));
+ Assertions.assertEquals("index name cannot be empty",
+ runAndGetMessage("DROP INDEX IF EXISTS `` ON " + CTL + "."
+ DB + "." + TBL));
+ AnalysisException exception = runAndGetCommonAnalysisException(
+ "DROP INDEX `` ON " + CTL + "." + DB + "." + TBL);
+ Assertions.assertEquals(ErrorCode.ERR_LANCE_INDEX_INVALID,
exception.getMysqlErrorCode());
+ Mockito.verify(fixture.env, Mockito.never()).getNextId();
+ }
+ }
+
+ @Test
+ public void testNonLanceIndexTypesRejectedEndToEnd() throws Exception {
+ try (LanceFixture fixture = new LanceFixture(false, true)) {
+ // Index types outside the Lance matrix surface the vocabulary
error through the
+ // command path, before the typed rejection and without any id
allocation.
+ Assertions.assertEquals("Lance catalog tables only support USING
ANN, BTREE, or BITMAP",
+ runAndGetMessage("CREATE INDEX idx ON " + CTL + "." + DB +
"." + TBL
+ + " (c) USING NGRAM_BF"));
+ Assertions.assertEquals("Lance catalog tables only support USING
ANN, BTREE, or BITMAP",
+ runAndGetMessage("CREATE INDEX idx ON " + CTL + "." + DB +
"." + TBL
+ + " (c) USING INVERTED"));
+ Assertions.assertEquals("Lance catalog tables only support USING
ANN, BTREE, or BITMAP",
+ runAndGetMessage("CREATE INDEX idx ON " + CTL + "." + DB +
"." + TBL + " (c)"));
+ Mockito.verify(fixture.env, Mockito.never()).getNextId();
+ }
+ }
+
+ @Test
+ public void testAlterTableAddAndDropIndexKeepTheGenericRejection() throws
Exception {
+ try (LanceFixture fixture = new LanceFixture(true, true)) {
+ // alter = true ops skip both the REST fail-fast branch and the
Lance branch, then
+ // fall through to the existing generic external-table rejection.
+ String addMessage = runAndGetMessage(
+ "ALTER TABLE " + CTL + "." + DB + "." + TBL + " ADD INDEX
idx (c) USING INVERTED");
+ Assertions.assertTrue(addMessage.contains("do not support
SCHEMA_CHANGE clause now"),
+ addMessage);
+
+ String dropMessage = runAndGetMessage(
+ "ALTER TABLE " + CTL + "." + DB + "." + TBL + " DROP INDEX
idx");
+ Assertions.assertTrue(dropMessage.contains("do not support
SCHEMA_CHANGE clause now"),
+ dropMessage);
+ Mockito.verify(fixture.catalog,
Mockito.times(2)).getDbOrDdlException(DB);
+ Mockito.verify(fixture.database,
Mockito.times(2)).getTableOrDdlException(TBL);
+ }
+ }
+
+ @Test
+ public void testPrivilegeDeniedPrecedesTheTypedRejection() throws
Exception {
+ try (LanceFixture fixture = new LanceFixture(true, false)) {
+ String message = runAndGetMessage(
+ "CREATE INDEX idx ON " + CTL + "." + DB + "." + TBL + "
(v) USING ANN "
+ + VALID_ANN_PROPERTIES);
+ Assertions.assertTrue(message.contains("command denied to user"),
message);
+ // The privilege check fires before any catalog resolution.
+ Mockito.verify(fixture.catalogMgr, Mockito.never())
+ .getCatalogOrException(Mockito.anyString(), Mockito.any());
+ Mockito.verify(fixture.catalog,
Mockito.never()).isRestCatalogConfigured();
+ Mockito.verify(fixture.env, Mockito.never()).getNextId();
+ }
+ }
+
+ @Test
+ public void testInternalTableGuardsRejectLanceOnlySyntax() throws
Exception {
+ try (InternalFixture fixture = new InternalFixture()) {
+ Assertions.assertEquals("CREATE OR REPLACE INDEX is only supported
for Lance catalog tables",
+ runAndGetMessage("CREATE OR REPLACE INDEX idx ON
internal." + DB + "." + TBL
+ + " (c) USING INVERTED"));
+ Assertions.assertEquals("USING BTREE is only supported for Lance
catalog tables",
+ runAndGetMessage("CREATE INDEX idx ON internal." + DB +
"." + TBL + " (c) USING BTREE"));
+ Assertions.assertEquals("USING BITMAP is only supported for Lance
catalog tables",
+ runAndGetMessage("CREATE INDEX idx ON internal." + DB +
"." + TBL + " (c) USING BITMAP"));
+ Mockito.verify(fixture.env, Mockito.never()).getNextId();
+ }
+ }
+}
diff --git
a/regression-test/suites/external_table_p0/lance/test_lance_index_ddl.groovy
b/regression-test/suites/external_table_p0/lance/test_lance_index_ddl.groovy
new file mode 100644
index 00000000000..c0bb1550453
--- /dev/null
+++ b/regression-test/suites/external_table_p0/lance/test_lance_index_ddl.groovy
@@ -0,0 +1,176 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("test_lance_index_ddl", "p0,external") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable Lance index DDL test because the Iceberg MinIO
environment is disabled.")
+ return
+ }
+
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String lanceRestPort = context.config.otherConfigs.get("lance_rest_port")
+ String filesystemCatalog = "test_lance_index_ddl"
+ String restCatalog = "test_lance_index_ddl_rest"
+ String user = "test_lance_index_ddl_user"
+ String password = "C123_567p"
+
+ sql """DROP CATALOG IF EXISTS `${filesystemCatalog}`"""
+ sql """DROP CATALOG IF EXISTS `${restCatalog}`"""
+ try_sql "DROP USER '${user}'@'%'"
+
+ try {
+ sql """
+ CREATE CATALOG `${filesystemCatalog}` PROPERTIES (
+ "type" = "lance",
+ "lance.catalog.type" = "filesystem",
+ "warehouse" = "s3://warehouse/lance",
+ "s3.endpoint" = "http://${externalEnvIp}:${minioPort}",
+ "s3.access_key" = "admin",
+ "s3.secret_key" = "password",
+ "s3.region" = "us-east-1",
+ "use_path_style" = "true"
+ )
+ """
+
+ // doris.vs_ivf_pq_f32 schema (all NOT NULL): embedding array<float>,
row_id bigint,
+ // category text, label text. Statically valid index DDL passes the
section 2.4 matrix
+ // and is then uniformly rejected until the Lance index build path
lands.
+ test {
+ sql """CREATE INDEX idx ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32` (embedding) USING ANN
+ PROPERTIES("index_type"="IVF_PQ", "metric"="l2",
"num_partitions"="256", "num_sub_vectors"="16")"""
+ exception "CREATE INDEX is not supported for Lance catalog tables"
+ }
+
+ test {
+ sql """CREATE INDEX idx ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32` (embedding) USING ANN
+ PROPERTIES("index_type"="IVF_PQ", "metric"="l1",
"num_partitions"="256", "num_sub_vectors"="16")"""
+ exception "metric must be one of l2, cosine, dot"
+ }
+
+ test {
+ sql """CREATE INDEX idx ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32` (embedding) USING ANN
+ PROPERTIES("index_type"="IVF_PQ", "num_sub_vectors"="16")"""
+ exception "num_partitions must be a positive integer"
+ }
+
+ test {
+ sql """CREATE INDEX idx ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32` (row_id) USING BTREE"""
+ exception "CREATE INDEX is not supported for Lance catalog tables"
+ }
+
+ test {
+ sql """CREATE INDEX idx ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32` (row_id) USING BTREE
+ PROPERTIES("k"="v")"""
+ exception "BTREE indexes do not support properties"
+ }
+
+ test {
+ sql """CREATE INDEX idx ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32` (category) USING BITMAP"""
+ exception "CREATE INDEX is not supported for Lance catalog tables"
+ }
+
+ test {
+ sql """CREATE OR REPLACE INDEX idx ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32` (row_id) USING BTREE"""
+ exception "CREATE OR REPLACE INDEX is not supported for Lance
catalog tables"
+ }
+
+ test {
+ sql """CREATE OR REPLACE INDEX IF NOT EXISTS idx ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32` (row_id) USING BTREE"""
+ exception "[OR REPLACE] and [IF NOT EXISTS] cannot used at the
same time"
+ }
+
+ test {
+ sql """DROP INDEX idx ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32`"""
+ exception "DROP INDEX is not supported for Lance catalog tables"
+ }
+
+ // Reject-all mode is uniform: IF EXISTS does not change the outcome.
+ test {
+ sql """DROP INDEX IF EXISTS idx ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32`"""
+ exception "DROP INDEX is not supported for Lance catalog tables"
+ }
+
+ // An empty backquoted index name is a blank name, not an unsupported
operation.
+ test {
+ sql """CREATE INDEX `` ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32` (embedding) USING ANN
+ PROPERTIES("index_type"="IVF_PQ", "num_partitions"="256",
"num_sub_vectors"="16")"""
+ exception "index name cannot be empty"
+ }
+
+ test {
+ sql """DROP INDEX `` ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32`"""
+ exception "index name cannot be empty"
+ }
+
+ // ALTER TABLE ADD/DROP INDEX keeps the generic external-table
rejection.
+ test {
+ sql """ALTER TABLE `${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32`
ADD INDEX idx (category) USING INVERTED"""
+ exception "do not support SCHEMA_CHANGE clause now"
+ }
+
+ sql """
+ CREATE CATALOG `${restCatalog}` PROPERTIES (
+ "type" = "lance",
+ "lance.catalog.type" = "rest",
+ "lance.rest.uri" = "http://${externalEnvIp}:${lanceRestPort}",
+ "lance.rest.security.type" = "bearer",
+ "lance.rest.bearer-token" = "doris-lance-rest-test-token",
+ "lance.namespace.root_database" = "default",
+ "s3.endpoint" = "http://${externalEnvIp}:${minioPort}",
+ "s3.region" = "us-east-1",
+ "use_path_style" = "true",
+ "test_connection" = "true"
+ )
+ """
+
+ test {
+ sql """CREATE INDEX idx ON `${restCatalog}`.`default`.`all_types`
(row_id) USING BTREE"""
+ exception "CREATE INDEX is not supported for Lance REST catalogs"
+ }
+
+ test {
+ sql """CREATE OR REPLACE INDEX idx ON
`${restCatalog}`.`default`.`all_types` (row_id) USING BTREE"""
+ exception "CREATE OR REPLACE INDEX is not supported for Lance REST
catalogs"
+ }
+
+ test {
+ sql """DROP INDEX idx ON `${restCatalog}`.`default`.`all_types`"""
+ exception "DROP INDEX is not supported for Lance REST catalogs"
+ }
+
+ sql """CREATE USER '${user}'@'%' IDENTIFIED BY '${password}'"""
+ sql """GRANT SELECT_PRIV ON regression_test TO '${user}'@'%'"""
+ if (isCloudMode()) {
+ def clusters = sql "SHOW CLUSTERS"
+ assertTrue(!clusters.isEmpty())
+ sql """GRANT USAGE_PRIV ON CLUSTER `${clusters[0][0]}` TO
'${user}'@'%'"""
+ }
+
+ connect(user, password, context.config.jdbcUrl) {
+ // The ALTER privilege check precedes the typed Lance rejection.
+ test {
+ sql """CREATE INDEX idx ON
`${filesystemCatalog}`.`doris`.`vs_ivf_pq_f32` (row_id) USING BTREE"""
+ exception "denied"
+ }
+ }
+ } finally {
+ try_sql "DROP USER '${user}'@'%'"
+ // Keep both catalogs for debugging when the suite fails.
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]