This is an automated email from the ASF dual-hosted git repository.
zhoujinsong pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/amoro.git
The following commit(s) were added to refs/heads/master by this push:
new 505a66e1e [AMORO-4280] Add support for Lance format on REST metastore
(#4334)
505a66e1e is described below
commit 505a66e1e454a4706caca6857e84d4e7f7a39282
Author: WenLingzhang <[email protected]>
AuthorDate: Wed Aug 26 11:04:02 2026 +0800
[AMORO-4280] Add support for Lance format on REST metastore (#4334)
* Add support for Lance format on REST metastore
* skip TableMetaStore auth build for REST + Lance-only catalog
* fixup style
---
.../amoro/server/catalog/CatalogBuilder.java | 9 +-
.../dashboard/controller/CatalogController.java | 16 +-
.../java/org/apache/amoro/utils/CatalogUtil.java | 12 +
.../amoro/formats/lance/AbstractLanceCatalog.java | 111 ++++++++
.../amoro/formats/lance/LanceCatalogFactory.java | 4 +
.../formats/lance/LanceDirectoryV1Catalog.java | 121 ++-------
.../amoro/formats/lance/LanceRestCatalog.java | 134 ++++++++++
amoro-web/src/views/catalogs/Detail.vue | 280 ++++++++++++---------
8 files changed, 471 insertions(+), 216 deletions(-)
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/catalog/CatalogBuilder.java
b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/CatalogBuilder.java
index af39ba533..5c5594e56 100644
---
a/amoro-ams/src/main/java/org/apache/amoro/server/catalog/CatalogBuilder.java
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/catalog/CatalogBuilder.java
@@ -59,7 +59,7 @@ public class CatalogBuilder {
CATALOG_TYPE_GLUE,
Sets.newHashSet(TableFormat.ICEBERG, TableFormat.MIXED_ICEBERG),
CATALOG_TYPE_REST,
- Sets.newHashSet(TableFormat.ICEBERG, TableFormat.MIXED_ICEBERG),
+ Sets.newHashSet(TableFormat.ICEBERG, TableFormat.MIXED_ICEBERG,
TableFormat.LANCE),
CATALOG_TYPE_CUSTOM,
Sets.newHashSet(TableFormat.ICEBERG, TableFormat.MIXED_ICEBERG),
CATALOG_TYPE_HIVE,
@@ -86,6 +86,13 @@ public class CatalogBuilder {
"Table format %s is not supported for metastore type: %s",
tableFormats,
type);
+ Preconditions.checkState(
+ !(CATALOG_TYPE_REST.equals(type)
+ && tableFormats.contains(TableFormat.LANCE)
+ && tableFormats.size() > 1),
+ "REST catalog serves a single protocol per uri,"
+ + " Lance cannot be combined with other table formats: %s",
+ tableFormats);
switch (type) {
case CATALOG_TYPE_HADOOP:
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/CatalogController.java
b/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/CatalogController.java
index 172b2a0c8..b7dcd5651 100644
---
a/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/CatalogController.java
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/CatalogController.java
@@ -208,6 +208,15 @@ public class CatalogController {
CatalogDescriptor.of(CATALOG_TYPE_REST,
STORAGE_CONFIGS_VALUE_TYPE_OSS, ICEBERG));
VALIDATE_CATALOGS.add(
CatalogDescriptor.of(CATALOG_TYPE_REST,
STORAGE_CONFIGS_VALUE_TYPE_OSS, MIXED_ICEBERG));
+
+ // Lance REST is a data proxy: the client never talks to object storage,
so the storage config
+ // is not consumed, but it still reuses the standard REST storage types.
+ VALIDATE_CATALOGS.add(
+ CatalogDescriptor.of(CATALOG_TYPE_REST, STORAGE_CONFIGS_VALUE_TYPE_S3,
LANCE));
+ VALIDATE_CATALOGS.add(
+ CatalogDescriptor.of(CATALOG_TYPE_REST,
STORAGE_CONFIGS_VALUE_TYPE_HADOOP, LANCE));
+ VALIDATE_CATALOGS.add(
+ CatalogDescriptor.of(CATALOG_TYPE_REST,
STORAGE_CONFIGS_VALUE_TYPE_OSS, LANCE));
}
private final PlatformFileManager platformFileInfoService;
@@ -473,8 +482,11 @@ public class CatalogController {
catalogMeta.putToCatalogProperties(
CatalogProperties.CATALOG_IMPL, GlueCatalog.class.getName());
} else if (CatalogMetaProperties.CATALOG_TYPE_REST.equals(metastoreType)) {
- catalogMeta.putToCatalogProperties(
- CatalogProperties.CATALOG_IMPL, RESTCatalog.class.getName());
+ // Only the Iceberg-family formats need the Iceberg RESTCatalog
implementation.
+ if (!info.getTableFormatList().contains(LANCE.name())) {
+ catalogMeta.putToCatalogProperties(
+ CatalogProperties.CATALOG_IMPL, RESTCatalog.class.getName());
+ }
}
catalogMeta.putToCatalogProperties(
diff --git a/amoro-common/src/main/java/org/apache/amoro/utils/CatalogUtil.java
b/amoro-common/src/main/java/org/apache/amoro/utils/CatalogUtil.java
index e35fbc65a..46285b9d0 100644
--- a/amoro-common/src/main/java/org/apache/amoro/utils/CatalogUtil.java
+++ b/amoro-common/src/main/java/org/apache/amoro/utils/CatalogUtil.java
@@ -121,6 +121,13 @@ public class CatalogUtil {
/** Build {@link TableMetaStore} from catalog meta. */
public static TableMetaStore buildMetaStore(CatalogMeta catalogMeta) {
+ // REST + Lance manages storage/auth server-side, so a client-side
TableMetaStore is
+ // unnecessary. Set a local configuration so build() returns a
disabled-auth store.
+ if
(CatalogMetaProperties.CATALOG_TYPE_REST.equalsIgnoreCase(catalogMeta.getCatalogType())
+ && isLanceOnly(catalogMeta)) {
+ return TableMetaStore.builder().withConfiguration(new
Configuration()).build();
+ }
+
// load storage configs
TableMetaStore.Builder builder = TableMetaStore.builder();
boolean isLocalStorage = false;
@@ -215,6 +222,11 @@ public class CatalogUtil {
return builder.build();
}
+ private static boolean isLanceOnly(CatalogMeta catalogMeta) {
+ Set<TableFormat> formats = tableFormats(catalogMeta);
+ return formats.size() == 1 && formats.contains(TableFormat.LANCE);
+ }
+
public static TableIdentifier tableId(TableMeta tableMeta) {
return TableIdentifier.of(
tableMeta.getTableIdentifier().getCatalog(),
diff --git
a/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/AbstractLanceCatalog.java
b/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/AbstractLanceCatalog.java
new file mode 100644
index 000000000..8ba7e1fc0
--- /dev/null
+++
b/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/AbstractLanceCatalog.java
@@ -0,0 +1,111 @@
+/*
+ * 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.amoro.formats.lance;
+
+import org.apache.amoro.AmoroTable;
+import org.apache.amoro.FormatCatalog;
+import org.apache.amoro.NoSuchDatabaseException;
+import org.apache.amoro.NoSuchTableException;
+import org.apache.amoro.table.TableIdentifier;
+import org.lance.Dataset;
+import org.lance.namespace.LanceNamespace;
+import org.lance.namespace.errors.TableNotFoundException;
+import org.lance.namespace.model.DropTableRequest;
+import org.lance.namespace.model.ListTablesRequest;
+import org.lance.namespace.model.ListTablesResponse;
+import org.lance.namespace.model.TableExistsRequest;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Base implementation of {@link FormatCatalog} for Lance. */
+public abstract class AbstractLanceCatalog implements FormatCatalog {
+
+ protected final String catalogName;
+ protected final LanceNamespace namespace;
+
+ protected AbstractLanceCatalog(String catalogName, LanceNamespace namespace)
{
+ this.catalogName = catalogName;
+ this.namespace = namespace;
+ }
+
+ /** Composes the namespace table id used to reference a single table. */
+ protected abstract List<String> tableId(String database, String table);
+
+ /** Composes the namespace id used to list tables of a database. */
+ protected abstract List<String> tableIdForListTables(String database);
+
+ @Override
+ public boolean tableExists(String database, String table) {
+ if (!databaseExists(database)) {
+ return false;
+ }
+
+ try {
+ namespace.tableExists(new TableExistsRequest().id(tableId(database,
table)));
+ return true;
+ } catch (TableNotFoundException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public AmoroTable<?> loadTable(String database, String tableName) {
+ if (!tableExists(database, tableName)) {
+ throw new NoSuchTableException("Table: " + database + "." + tableName +
" does not exist");
+ }
+
+ TableIdentifier identifier = TableIdentifier.of(catalogName, database,
tableName);
+ Dataset dataset =
+ Dataset.open().namespaceClient(namespace).tableId(tableId(database,
tableName)).build();
+ return new LanceTable(identifier, dataset, Collections.emptyMap());
+ }
+
+ @Override
+ public boolean dropTable(String database, String table, boolean purge) {
+ validateDatabase(database);
+
+ try {
+ namespace.dropTable(new DropTableRequest().id(tableId(database, table)));
+ return true;
+ } catch (TableNotFoundException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public List<String> listTables(String database) {
+ if (!databaseExists(database)) {
+ return Collections.emptyList();
+ }
+ ListTablesRequest request = new
ListTablesRequest().id(tableIdForListTables(database));
+ ListTablesResponse response = namespace.listTables(request);
+ if (response == null) {
+ return Collections.emptyList();
+ }
+ return new ArrayList<>(response.getTables());
+ }
+
+ protected void validateDatabase(String database) {
+ if (!databaseExists(database)) {
+ throw new NoSuchDatabaseException("Database: " + database + " does not
exist");
+ }
+ }
+}
diff --git
a/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/LanceCatalogFactory.java
b/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/LanceCatalogFactory.java
index a78fa1ae3..a3faa8b2d 100755
---
a/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/LanceCatalogFactory.java
+++
b/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/LanceCatalogFactory.java
@@ -21,6 +21,7 @@ package org.apache.amoro.formats.lance;
import org.apache.amoro.FormatCatalog;
import org.apache.amoro.FormatCatalogFactory;
import org.apache.amoro.TableFormat;
+import org.apache.amoro.properties.CatalogMetaProperties;
import org.apache.amoro.table.TableMetaStore;
import java.util.HashMap;
@@ -38,6 +39,9 @@ public class LanceCatalogFactory implements
FormatCatalogFactory {
String metastoreType,
Map<String, String> properties,
TableMetaStore metaStore) {
+ if (CatalogMetaProperties.CATALOG_TYPE_REST.equals(metastoreType)) {
+ return new LanceRestCatalog(catalogName, properties);
+ }
return new LanceDirectoryV1Catalog(catalogName, properties);
}
diff --git
a/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/LanceDirectoryV1Catalog.java
b/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/LanceDirectoryV1Catalog.java
index 926d91ec8..19f8f2b08 100755
---
a/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/LanceDirectoryV1Catalog.java
+++
b/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/LanceDirectoryV1Catalog.java
@@ -18,26 +18,13 @@
package org.apache.amoro.formats.lance;
-import org.apache.amoro.AmoroTable;
-import org.apache.amoro.FormatCatalog;
-import org.apache.amoro.NoSuchDatabaseException;
-import org.apache.amoro.NoSuchTableException;
import org.apache.amoro.properties.CatalogMetaProperties;
-import org.apache.amoro.table.TableIdentifier;
-import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.memory.RootAllocator;
import org.apache.arrow.util.Preconditions;
import org.apache.iceberg.aliyun.AliyunProperties;
import org.apache.iceberg.aws.s3.S3FileIOProperties;
-import org.lance.Dataset;
import org.lance.namespace.LanceNamespace;
-import org.lance.namespace.errors.TableNotFoundException;
-import org.lance.namespace.model.DropTableRequest;
-import org.lance.namespace.model.ListTablesRequest;
-import org.lance.namespace.model.ListTablesResponse;
-import org.lance.namespace.model.TableExistsRequest;
-import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -50,22 +37,22 @@ import java.util.Map;
* root directory, each immediate subdirectory whose name ends with ".lance"
is treated as a Lance
* dataset. All tables live in a single logical database named "default".
*/
-public class LanceDirectoryV1Catalog implements FormatCatalog {
+public class LanceDirectoryV1Catalog extends AbstractLanceCatalog {
private static final String DEFAULT_DATABASE = "default";
private static final String STORAGE_ACCESS_KEY_ID = "storage.access_key_id";
private static final String STORAGE_SECRET_ACCESS_KEY =
"storage.secret_access_key";
private static final String STORAGE_ENDPOINT = "storage.endpoint";
- private final String catalogName;
- private final Map<String, String> namespaceProperties;
- private final LanceNamespace namespace;
public LanceDirectoryV1Catalog(String catalogName, Map<String, String>
catalogProperties) {
+ super(catalogName, buildNamespace(catalogProperties));
+ }
+
+ private static LanceNamespace buildNamespace(Map<String, String>
catalogProperties) {
Preconditions.checkArgument(
catalogProperties != null && !catalogProperties.isEmpty(),
"Catalog properties must be set.");
- this.catalogName = catalogName;
- this.namespaceProperties = new HashMap<>(catalogProperties);
+ Map<String, String> namespaceProperties = new HashMap<>(catalogProperties);
String root =
namespaceProperties.remove(CatalogMetaProperties.KEY_WAREHOUSE);
String storageAccessKey =
removeFirstNonNull(
@@ -80,42 +67,37 @@ public class LanceDirectoryV1Catalog implements
FormatCatalog {
Preconditions.checkArgument(
root != null && !root.isEmpty(), "Warehouse must be set in
catalogProperties.");
- this.namespaceProperties.put("manifest_enabled", "false");
- this.namespaceProperties.put("vend_input_storage_options", "true");
- this.namespaceProperties.put("root", root);
+ namespaceProperties.put("manifest_enabled", "false");
+ namespaceProperties.put("vend_input_storage_options", "true");
+ namespaceProperties.put("root", root);
if (storageAccessKey != null) {
- this.namespaceProperties.put(STORAGE_ACCESS_KEY_ID, storageAccessKey);
+ namespaceProperties.put(STORAGE_ACCESS_KEY_ID, storageAccessKey);
}
if (storageSecretKey != null) {
- this.namespaceProperties.put(STORAGE_SECRET_ACCESS_KEY,
storageSecretKey);
+ namespaceProperties.put(STORAGE_SECRET_ACCESS_KEY, storageSecretKey);
}
putStorageEndpointIfPresent(namespaceProperties);
- this.namespace = initializeNamespace(new RootAllocator(Long.MAX_VALUE));
+ return LanceNamespace.connect("dir", namespaceProperties, new
RootAllocator(Long.MAX_VALUE));
}
@Override
- public List<String> listDatabases() {
- return Collections.singletonList(DEFAULT_DATABASE);
+ protected List<String> tableId(String database, String table) {
+ return Collections.singletonList(table);
}
@Override
- public boolean databaseExists(String database) {
- return DEFAULT_DATABASE.equals(database);
+ protected List<String> tableIdForListTables(String database) {
+ return Collections.emptyList();
}
@Override
- public boolean tableExists(String database, String table) {
- if (!databaseExists(database)) {
- return false;
- }
+ public List<String> listDatabases() {
+ return Collections.singletonList(DEFAULT_DATABASE);
+ }
- try {
- TableExistsRequest request = new
TableExistsRequest().id(Collections.singletonList(table));
- namespace.tableExists(request);
- return true;
- } catch (TableNotFoundException e) {
- return false;
- }
+ @Override
+ public boolean databaseExists(String database) {
+ return DEFAULT_DATABASE.equals(database);
}
@Override
@@ -128,59 +110,6 @@ public class LanceDirectoryV1Catalog implements
FormatCatalog {
throw new UnsupportedOperationException("Dropping Lance databases is not
supported.");
}
- @Override
- public AmoroTable<?> loadTable(String database, String tableName) {
- if (!databaseExists(database) || !tableExists(database, tableName)) {
- throw new NoSuchTableException("Table: " + database + "." + tableName +
" does not exist");
- }
-
- TableIdentifier identifier = TableIdentifier.of(catalogName, database,
tableName);
- Dataset dataset =
- Dataset.open()
- .namespaceClient(namespace)
- .tableId(Collections.singletonList(tableName))
- .build();
- return new LanceTable(identifier, dataset, Collections.emptyMap());
- }
-
- @Override
- public boolean dropTable(String database, String table, boolean purge) {
- validateDatabase(database);
-
- try {
- namespace.dropTable(new
DropTableRequest().id(Collections.singletonList(table)));
- return true;
- } catch (TableNotFoundException e) {
- return false;
- }
- }
-
- @Override
- public List<String> listTables(String database) {
- if (!databaseExists(database)) {
- return Collections.emptyList();
- }
- return listTablesFromNamespace();
- }
-
- private List<String> listTablesFromNamespace() {
- if (namespace == null) {
- return Collections.emptyList();
- }
-
- ListTablesRequest request = new
ListTablesRequest().id(Collections.emptyList());
- ListTablesResponse response = namespace.listTables(request);
- if (response == null) {
- return Collections.emptyList();
- }
-
- return new ArrayList<>(response.getTables());
- }
-
- private LanceNamespace initializeNamespace(BufferAllocator allocator) {
- return LanceNamespace.connect("dir", namespaceProperties, allocator);
- }
-
private static String removeFirstNonNull(Map<String, String> properties,
String... keys) {
String value = null;
for (String key : keys) {
@@ -205,10 +134,4 @@ public class LanceDirectoryV1Catalog implements
FormatCatalog {
properties.put(STORAGE_ENDPOINT, endpoint);
}
}
-
- private void validateDatabase(String database) {
- if (!databaseExists(database)) {
- throw new NoSuchDatabaseException("Database: " + database + " does not
exist");
- }
- }
}
diff --git
a/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/LanceRestCatalog.java
b/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/LanceRestCatalog.java
new file mode 100644
index 000000000..fdf237c12
--- /dev/null
+++
b/amoro-format-lance/src/main/java/org/apache/amoro/formats/lance/LanceRestCatalog.java
@@ -0,0 +1,134 @@
+/*
+ * 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.amoro.formats.lance;
+
+import org.apache.amoro.AlreadyExistsException;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.util.Preconditions;
+import org.lance.namespace.LanceNamespace;
+import org.lance.namespace.errors.NamespaceAlreadyExistsException;
+import org.lance.namespace.errors.NamespaceNotFoundException;
+import org.lance.namespace.model.CreateNamespaceRequest;
+import org.lance.namespace.model.DropNamespaceRequest;
+import org.lance.namespace.model.ListNamespacesRequest;
+import org.lance.namespace.model.ListNamespacesResponse;
+import org.lance.namespace.model.NamespaceExistsRequest;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * REST catalog implementation for Lance.
+ *
+ * <p>This catalog connects to a Lance Namespace REST service via {@code
+ * LanceNamespace.connect("rest", ...)} and maps the REST namespace hierarchy
to Amoro's
+ * database/table model.
+ */
+public class LanceRestCatalog extends AbstractLanceCatalog {
+
+ private static final String URI_PROPERTY = "uri";
+
+ /**
+ * Optional catalog name prepended to the REST namespace id, mapping Amoro's
database/table onto
+ * the service's schema/table levels (catalog/schema/table hierarchy). Empty
when the service has
+ * no catalog level.
+ */
+ private static final String CATALOG_PROPERTY = "catalog-name";
+
+ private final List<String> catalogPrefix;
+
+ public LanceRestCatalog(String catalogName, Map<String, String>
catalogProperties) {
+ super(catalogName, buildNamespace(catalogProperties));
+ this.catalogPrefix = catalogPrefix(catalogProperties);
+ }
+
+ private static List<String> catalogPrefix(Map<String, String>
catalogProperties) {
+ String catalog = catalogProperties.get(CATALOG_PROPERTY);
+ return catalog == null || catalog.isEmpty()
+ ? Collections.emptyList()
+ : Collections.singletonList(catalog);
+ }
+
+ private static LanceNamespace buildNamespace(Map<String, String>
namespaceProperties) {
+ Preconditions.checkArgument(
+ namespaceProperties != null && !namespaceProperties.isEmpty(),
+ "Catalog properties must be set.");
+ Preconditions.checkArgument(
+ namespaceProperties.containsKey(URI_PROPERTY),
+ "URI must be set in namespaceProperties for REST metastore type.");
+ Map<String, String> restProperties = new HashMap<>(namespaceProperties);
+ return LanceNamespace.connect("rest", restProperties, new
RootAllocator(Long.MAX_VALUE));
+ }
+
+ @Override
+ protected List<String> tableId(String database, String table) {
+ return concat(catalogPrefix, database, table);
+ }
+
+ @Override
+ protected List<String> tableIdForListTables(String database) {
+ return concat(catalogPrefix, database);
+ }
+
+ private static List<String> concat(List<String> prefix, String... parts) {
+ List<String> id = new ArrayList<>(prefix);
+ Collections.addAll(id, parts);
+ return id;
+ }
+
+ @Override
+ public List<String> listDatabases() {
+ ListNamespacesRequest request = new
ListNamespacesRequest().id(catalogPrefix);
+ ListNamespacesResponse response = namespace.listNamespaces(request);
+ if (response == null) {
+ return Collections.emptyList();
+ }
+ return new ArrayList<>(response.getNamespaces());
+ }
+
+ @Override
+ public boolean databaseExists(String database) {
+ try {
+ NamespaceExistsRequest request =
+ new NamespaceExistsRequest().id(concat(catalogPrefix, database));
+ namespace.namespaceExists(request);
+ return true;
+ } catch (NamespaceNotFoundException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public void createDatabase(String database) {
+ try {
+ namespace.createNamespace(new
CreateNamespaceRequest().id(concat(catalogPrefix, database)));
+ } catch (NamespaceAlreadyExistsException e) {
+ throw new AlreadyExistsException("Database: " + database + " already
exists", e);
+ }
+ }
+
+ @Override
+ public void dropDatabase(String database) {
+ validateDatabase(database);
+ namespace.dropNamespace(new
DropNamespaceRequest().id(concat(catalogPrefix, database)));
+ }
+}
diff --git a/amoro-web/src/views/catalogs/Detail.vue
b/amoro-web/src/views/catalogs/Detail.vue
index e088b2f39..6f78999be 100644
--- a/amoro-web/src/views/catalogs/Detail.vue
+++ b/amoro-web/src/views/catalogs/Detail.vue
@@ -119,6 +119,7 @@ const tableFormatMap = {
MIXED_ICEBERG: 'MIXED_ICEBERG',
PAIMON: 'PAIMON',
HUDI: 'HUDI',
+ LANCE: 'LANCE',
}
const tableFormatText = {
@@ -127,6 +128,7 @@ const tableFormatText = {
[tableFormatMap.MIXED_ICEBERG]: 'Mixed Iceberg',
[tableFormatMap.PAIMON]: 'Paimon',
[tableFormatMap.HUDI]: 'Hudi',
+ [tableFormatMap.LANCE]: 'Lance',
}
const storageConfigFileNameMap = {
@@ -248,7 +250,12 @@ async function loadMetastoreCapabilities(loadTableFormats:
boolean) {
const options = (formats || []) as string[]
tableFormatOptions.value = options
if (loadTableFormats) {
- formState.tableFormatList = [...options]
+ // REST catalog: Lance and Iceberg use different protocols (one protocol
per uri),
+ // so they can't coexist. Default to Iceberg family; Lance stays unchecked
(opt-in).
+ const defaultFormats = type === 'rest'
+ ? options.filter(format => format !== tableFormatMap.LANCE)
+ : options
+ formState.tableFormatList = defaultFormats.length > 0 ?
[...defaultFormats] : [...options]
}
const storageTypes = await getMetastoreStorageTypes(type)
storageTypeOptions.value = (storageTypes || []).map((val: string) => ({
@@ -394,6 +401,11 @@ const authTypeOptions = computed(() => {
const isAuthDisabled = computed(() => formState.storageConfig['storage.type']
=== 'Local')
+// REST + Lance needs no client-side object storage/auth; storage is
server-managed.
+const isRestLance = computed(() => {
+ return formState.catalog.type === 'rest' &&
formState.tableFormatList.includes(tableFormatMap.LANCE)
+})
+
watch(
() => formState.storageConfig['storage.type'],
(storageType) => {
@@ -409,7 +421,37 @@ watch(
formState.authConfig[simpleUsernameKey] = 'local'
}
}
- }
+ },
+)
+
+watch(
+ () => formState.tableFormatList,
+ (formats, oldFormats) => {
+ const isRest = formState.catalog.type === 'rest'
+ const hasLance = formats.includes(tableFormatMap.LANCE)
+
+ if (isRest && hasLance && formats.length > 1) {
+ // If Lance was just added, keep only Lance; otherwise the user switched
away from Lance.
+ const hadLance = oldFormats?.includes(tableFormatMap.LANCE)
+ formState.tableFormatList = hadLance
+ ? formats.filter(format => format !== tableFormatMap.LANCE)
+ : [tableFormatMap.LANCE]
+ return
+ }
+
+ // REST + Lance needs no client-side storage/auth; drop them on selection .
+ if (isRest && hasLance) {
+ Object.keys(formState.storageConfig).forEach((key) => {
+ delete formState.storageConfig[key]
+ })
+ Object.keys(formState.authConfig).forEach((key) => {
+ delete formState.authConfig[key]
+ })
+ }
+ else if (isRest && !formState.storageConfig['storage.type']) {
+ formState.storageConfig['storage.type'] = 'S3'
+ }
+ },
)
async function changeMetastore() {
@@ -488,7 +530,7 @@ function handleSave() {
formRef.value
.validateFields()
.then(async () => {
- const { catalog, tableFormatList, storageConfig, authConfig } = formState
+ const { catalog, tableFormatList } = formState
const properties = await propertiesRef.value.getProperties()
const tableProperties = await tablePropertiesRef.value.getProperties()
if (!properties) {
@@ -500,6 +542,14 @@ function handleSave() {
loading.value = true
const catalogParams = catalog
getFileIdParams()
+ let storageConfig = formState.storageConfig
+ let authConfig = formState.authConfig
+ if (isRestLance.value) {
+ // Lance REST manages storage server-side; send a neutral S3 storage
type
+ // (accepted by the backend REST+S3+LANCE whitelist) and no auth
config.
+ storageConfig = { 'storage.type': 'S3' }
+ authConfig = {}
+ }
await saveCatalogsSetting({
isCreate: isNewCatalog.value,
...catalogParams,
@@ -641,126 +691,128 @@ onMounted(() => {
/>
<span v-else>{{ formState.catalog.optimizerGroup }}</span>
</a-form-item>
- <a-form-item>
- <p class="header">
- {{ $t('storageConfigName') }}
- </p>
- </a-form-item>
- <a-form-item :label="$t('type')" :name="['storageConfig',
'storage.type']" :rules="[{ required: isEdit }]">
- <a-select
- v-if="isEdit"
v-model:value="formState.storageConfig['storage.type']"
- :placeholder="placeholder.selectPh" :options="storageTypeOptions"
- />
- <span v-else class="config-value">{{
formState.storageConfig['storage.type'] }}</span>
- </a-form-item>
- <a-form-item
- v-if="formState.storageConfig['storage.type'] === 'S3'"
:label="$t('endpoint')"
- :name="['storageConfig', 'storage.s3.endpoint']" :rules="[{
required: false }]"
- >
- <a-input v-if="isEdit"
v-model:value="formState.storageConfig['storage.s3.endpoint']" />
- <span v-else class="config-value">{{
formState.storageConfig['storage.s3.endpoint'] }}</span>
- </a-form-item>
- <a-form-item
- v-if="formState.storageConfig['storage.type'] === 'S3'"
:label="$t('region')"
- :name="['storageConfig', 'storage.s3.region']" :rules="[{
required: false }]"
- >
- <a-input v-if="isEdit"
v-model:value="formState.storageConfig['storage.s3.region']" />
- <span v-else class="config-value">{{
formState.storageConfig['storage.s3.region'] }}</span>
- </a-form-item>
- <a-form-item
+ <template v-if="!isRestLance">
+ <a-form-item>
+ <p class="header">
+ {{ $t('storageConfigName') }}
+ </p>
+ </a-form-item>
+ <a-form-item :label="$t('type')" :name="['storageConfig',
'storage.type']" :rules="[{ required: isEdit }]">
+ <a-select
+ v-if="isEdit"
v-model:value="formState.storageConfig['storage.type']"
+ :placeholder="placeholder.selectPh"
:options="storageTypeOptions"
+ />
+ <span v-else class="config-value">{{
formState.storageConfig['storage.type'] }}</span>
+ </a-form-item>
+ <a-form-item
+ v-if="formState.storageConfig['storage.type'] === 'S3'"
:label="$t('endpoint')"
+ :name="['storageConfig', 'storage.s3.endpoint']" :rules="[{
required: false }]"
+ >
+ <a-input v-if="isEdit"
v-model:value="formState.storageConfig['storage.s3.endpoint']" />
+ <span v-else class="config-value">{{
formState.storageConfig['storage.s3.endpoint'] }}</span>
+ </a-form-item>
+ <a-form-item
+ v-if="formState.storageConfig['storage.type'] === 'S3'"
:label="$t('region')"
+ :name="['storageConfig', 'storage.s3.region']" :rules="[{
required: false }]"
+ >
+ <a-input v-if="isEdit"
v-model:value="formState.storageConfig['storage.s3.region']" />
+ <span v-else class="config-value">{{
formState.storageConfig['storage.s3.region'] }}</span>
+ </a-form-item>
+ <a-form-item
v-if="formState.storageConfig['storage.type'] === 'OSS'"
:label="$t('endpoint')"
:name="['storageConfig', 'storage.oss.endpoint']" :rules="[{
required: false }]"
- >
- <a-input v-if="isEdit"
v-model:value="formState.storageConfig['storage.oss.endpoint']" />
- <span v-else class="config-value">{{
formState.storageConfig['storage.oss.endpoint'] }}</span>
- </a-form-item>
- <div v-if="formState.storageConfig['storage.type'] === 'Hadoop'">
- <a-form-item
- v-for="config in formState.storageConfigArray"
:key="config.label" :label="config.label"
- class="g-flex-ac"
>
- <a-upload
- v-if="isEdit" v-model:file-list="config.fileList" name="file"
accept=".xml"
- :show-upload-list="false" :action="uploadUrl"
:disabled="config.uploadLoading"
- :headers="uploadHeaders"
- @change="(args: UploadChangeParam<UploadFile<any>>) =>
uploadFile(args, config, 'STORAGE')"
+ <a-input v-if="isEdit"
v-model:value="formState.storageConfig['storage.oss.endpoint']" />
+ <span v-else class="config-value">{{
formState.storageConfig['storage.oss.endpoint'] }}</span>
+ </a-form-item>
+ <div v-if="formState.storageConfig['storage.type'] === 'Hadoop'">
+ <a-form-item
+ v-for="config in formState.storageConfigArray"
:key="config.label" :label="config.label"
+ class="g-flex-ac"
>
- <a-button type="primary" ghost :loading="config.uploadLoading"
class="g-mr-12">
- {{ $t('upload')
- }}
- </a-button>
- </a-upload>
- <span
- v-if="config.isSuccess || config.fileName" class="config-value"
- :class="{ 'view-active': !!config.fileUrl }"
@click="viewFileDetail(config.fileUrl, config.fileName)"
- >{{ config.fileName
- }}</span>
+ <a-upload
+ v-if="isEdit" v-model:file-list="config.fileList"
name="file" accept=".xml"
+ :show-upload-list="false" :action="uploadUrl"
:disabled="config.uploadLoading"
+ :headers="uploadHeaders"
+ @change="(args: UploadChangeParam<UploadFile<any>>) =>
uploadFile(args, config, 'STORAGE')"
+ >
+ <a-button type="primary" ghost
:loading="config.uploadLoading" class="g-mr-12">
+ {{ $t('upload')
+ }}
+ </a-button>
+ </a-upload>
+ <span
+ v-if="config.isSuccess || config.fileName"
class="config-value"
+ :class="{ 'view-active': !!config.fileUrl }"
@click="viewFileDetail(config.fileUrl, config.fileName)"
+ >{{ config.fileName
+ }}</span>
+ </a-form-item>
+ </div>
+ <a-form-item>
+ <p class="header">
+ {{ $t('authenticationConfig') }}
+ </p>
+ </a-form-item>
+ <a-form-item :label="$t('type')" :name="['authConfig',
'auth.type']" :rules="[{ required: isEdit && !isAuthDisabled }]">
+ <a-select
+ v-if="isEdit" v-model:value="formState.authConfig['auth.type']"
+ :placeholder="placeholder.selectPh" :options="authTypeOptions"
:disabled="isAuthDisabled"
+ />
+ <span v-else class="config-value">{{
formState.authConfig['auth.type'] }}</span>
</a-form-item>
- </div>
- <a-form-item>
- <p class="header">
- {{ $t('authenticationConfig') }}
- </p>
- </a-form-item>
- <a-form-item :label="$t('type')" :name="['authConfig', 'auth.type']"
:rules="[{ required: isEdit && !isAuthDisabled }]">
- <a-select
- v-if="isEdit" v-model:value="formState.authConfig['auth.type']"
- :placeholder="placeholder.selectPh" :options="authTypeOptions"
:disabled="isAuthDisabled"
- />
- <span v-else class="config-value">{{
formState.authConfig['auth.type'] }}</span>
- </a-form-item>
- <a-form-item
- v-if="formState.authConfig['auth.type'] === 'SIMPLE'"
:label="$t('hadoopUsername')"
- :name="['authConfig', 'auth.simple.hadoop_username']" :rules="[{
required: isEdit && !isAuthDisabled }]"
- >
- <a-input v-if="isEdit"
v-model:value="formState.authConfig['auth.simple.hadoop_username']"
:disabled="isAuthDisabled" />
- <span v-else class="config-value">{{
formState.authConfig['auth.simple.hadoop_username'] }}</span>
- </a-form-item>
- <a-form-item
- v-if="formState.authConfig['auth.type'] === 'KERBEROS'"
:label="$t('kerberosPrincipal')"
- :name="['authConfig', 'auth.kerberos.principal']" :rules="[{
required: isEdit && !isAuthDisabled }]"
- >
- <a-input v-if="isEdit"
v-model:value="formState.authConfig['auth.kerberos.principal']"
:disabled="isAuthDisabled" />
- <span v-else class="config-value">{{
formState.authConfig['auth.kerberos.principal'] }}</span>
- </a-form-item>
- <div v-if="formState.authConfig['auth.type'] === 'KERBEROS'">
<a-form-item
- v-for="config in formState.authConfigArray" :key="config.label"
:label="config.label"
- class="g-flex-ac"
+ v-if="formState.authConfig['auth.type'] === 'SIMPLE'"
:label="$t('hadoopUsername')"
+ :name="['authConfig', 'auth.simple.hadoop_username']" :rules="[{
required: isEdit && !isAuthDisabled }]"
>
- <a-upload
- v-if="isEdit" v-model:file-list="config.fileList" name="file"
- :accept="config.key === 'auth.kerberos.keytab' ? '.keytab' :
'.conf'" :show-upload-list="false"
- :action="uploadUrl" :disabled="config.uploadLoading"
- :headers="uploadHeaders"
- @change="(args: UploadChangeParam<UploadFile<any>>) =>
uploadFile(args, config)"
+ <a-input v-if="isEdit"
v-model:value="formState.authConfig['auth.simple.hadoop_username']"
:disabled="isAuthDisabled" />
+ <span v-else class="config-value">{{
formState.authConfig['auth.simple.hadoop_username'] }}</span>
+ </a-form-item>
+ <a-form-item
+ v-if="formState.authConfig['auth.type'] === 'KERBEROS'"
:label="$t('kerberosPrincipal')"
+ :name="['authConfig', 'auth.kerberos.principal']" :rules="[{
required: isEdit && !isAuthDisabled }]"
+ >
+ <a-input v-if="isEdit"
v-model:value="formState.authConfig['auth.kerberos.principal']"
:disabled="isAuthDisabled" />
+ <span v-else class="config-value">{{
formState.authConfig['auth.kerberos.principal'] }}</span>
+ </a-form-item>
+ <div v-if="formState.authConfig['auth.type'] === 'KERBEROS'">
+ <a-form-item
+ v-for="config in formState.authConfigArray"
:key="config.label" :label="config.label"
+ class="g-flex-ac"
>
- <a-button type="primary" ghost :loading="config.uploadLoading"
class="g-mr-12">
- {{ $t('upload')
- }}
- </a-button>
- </a-upload>
- <span
- v-if="config.isSuccess || config.fileName" class="config-value
auth-filename"
- :class="{ 'view-active': !!config.fileUrl }"
:title="config.fileName"
- @click="viewFileDetail(config.fileUrl, config.fileName)"
- >{{ config.fileName }}</span>
+ <a-upload
+ v-if="isEdit" v-model:file-list="config.fileList" name="file"
+ :accept="config.key === 'auth.kerberos.keytab' ? '.keytab' :
'.conf'" :show-upload-list="false"
+ :action="uploadUrl" :disabled="config.uploadLoading"
+ :headers="uploadHeaders"
+ @change="(args: UploadChangeParam<UploadFile<any>>) =>
uploadFile(args, config)"
+ >
+ <a-button type="primary" ghost
:loading="config.uploadLoading" class="g-mr-12">
+ {{ $t('upload')
+ }}
+ </a-button>
+ </a-upload>
+ <span
+ v-if="config.isSuccess || config.fileName"
class="config-value auth-filename"
+ :class="{ 'view-active': !!config.fileUrl }"
:title="config.fileName"
+ @click="viewFileDetail(config.fileUrl, config.fileName)"
+ >{{ config.fileName }}</span>
+ </a-form-item>
+ </div>
+ <a-form-item
+ v-if="formState.authConfig['auth.type'] === 'AK/SK'"
:label="$t('accessKey')"
+ :name="['authConfig', 'auth.ak_sk.access_key']" :rules="[{
required: isEdit && !isAuthDisabled }]"
+ >
+ <a-input v-if="isEdit"
v-model:value="formState.authConfig['auth.ak_sk.access_key']"
:disabled="isAuthDisabled" />
+ <span v-else class="config-value">{{
formState.authConfig['auth.ak_sk.access_key'] }}</span>
</a-form-item>
- </div>
- <a-form-item
- v-if="formState.authConfig['auth.type'] === 'AK/SK'"
:label="$t('accessKey')"
- :name="['authConfig', 'auth.ak_sk.access_key']" :rules="[{
required: isEdit && !isAuthDisabled }]"
- >
- <a-input v-if="isEdit"
v-model:value="formState.authConfig['auth.ak_sk.access_key']"
:disabled="isAuthDisabled" />
- <span v-else class="config-value">{{
formState.authConfig['auth.ak_sk.access_key'] }}</span>
- </a-form-item>
- <a-form-item
- v-if="formState.authConfig['auth.type'] === 'AK/SK'"
:label="$t('secretKey')"
- :name="['authConfig', 'auth.ak_sk.secret_key']" :rules="[{
required: isEdit && !isAuthDisabled }]"
- >
- <a-input v-if="isEdit"
v-model:value="formState.authConfig['auth.ak_sk.secret_key']"
:disabled="isAuthDisabled" />
- <span v-else class="config-value">{{
formState.authConfig['auth.ak_sk.secret_key'] }}</span>
- </a-form-item>
+ <a-form-item
+ v-if="formState.authConfig['auth.type'] === 'AK/SK'"
:label="$t('secretKey')"
+ :name="['authConfig', 'auth.ak_sk.secret_key']" :rules="[{
required: isEdit && !isAuthDisabled }]"
+ >
+ <a-input v-if="isEdit"
v-model:value="formState.authConfig['auth.ak_sk.secret_key']"
:disabled="isAuthDisabled" />
+ <span v-else class="config-value">{{
formState.authConfig['auth.ak_sk.secret_key'] }}</span>
+ </a-form-item>
+ </template>
<a-form-item>
<p class="header">
{{ $t('properties') }}