getOptimizerRules(
+ OptimizerRulesContext optimizerRulesContext,
+ PlannerPhase phase) {
+ switch (phase) {
+ case LOGICAL:
+ return ImmutableSet.of(
+ AccumuloPushSortIntoScan.SORT_ON_SCAN
+ );
+ case PHYSICAL:
+ return ImmutableSet.of(
+ AccumuloPushFilterIntoScan.FILTER_ON_SCAN,
+ AccumuloPushFilterIntoScan.FILTER_ON_PROJECT
+ );
+ default:
+ return ImmutableSet.of();
+ }
+ }
+
+ @Override
+ public void close() throws Exception {
+ logger.debug("Closing Accumulo storage plugin: {}", getName());
+ connectionManager.close();
+ }
+}
diff --git a/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/AccumuloStoragePluginConfig.java b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/AccumuloStoragePluginConfig.java
new file mode 100644
index 00000000000..7a838feb838
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/AccumuloStoragePluginConfig.java
@@ -0,0 +1,414 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.drill.common.PlanStringBuilder;
+import org.apache.drill.common.logical.StoragePluginConfig;
+import org.apache.drill.common.logical.security.CredentialsProvider;
+import org.apache.drill.exec.store.security.CredentialProviderUtils;
+import org.apache.drill.exec.proto.UserBitShared.UserCredentials;
+import org.apache.drill.exec.store.security.UsernamePasswordCredentials;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+
+/**
+ * Configuration for the Accumulo storage plugin.
+ *
+ * This configuration supports connecting to an Accumulo cluster via ZooKeeper
+ * and includes settings for authentication, Kerberos, user impersonation,
+ * and optional schema metadata table.
+ *
+ * Password Authentication Example:
+ *
+ * {
+ * "type": "accumulo",
+ * "zookeeperQuorum": "localhost:2181",
+ * "instanceName": "accumulo",
+ * "username": "root",
+ * "password": "secret",
+ * "enabled": true
+ * }
+ *
+ *
+ * Kerberos Authentication Example:
+ *
+ * {
+ * "type": "accumulo",
+ * "zookeeperQuorum": "zk1:2181,zk2:2181",
+ * "instanceName": "accumulo",
+ * "authenticationType": "KERBEROS",
+ * "principal": "drill/hostname@REALM",
+ * "keytabPath": "/etc/security/keytabs/drill.keytab",
+ * "saslQop": "auth",
+ * "useDelegationTokens": true,
+ * "authMode": "USER_IMPERSONATION",
+ * "enabled": true
+ * }
+ *
+ */
+@JsonTypeName(AccumuloStoragePluginConfig.NAME)
+public class AccumuloStoragePluginConfig extends StoragePluginConfig {
+
+ public static final String NAME = "accumulo";
+
+ /**
+ * Default SASL QoP (Quality of Protection) value.
+ */
+ public static final String DEFAULT_SASL_QOP = "auth";
+
+ /**
+ * Default Accumulo service name for SASL authentication.
+ */
+ public static final String DEFAULT_ACCUMULO_SERVICE_PRIMARY = "accumulo";
+
+ // ===== Connection settings =====
+
+ /**
+ * Comma-separated list of ZooKeeper servers (host:port format).
+ * Example: "zk1:2181,zk2:2181,zk3:2181"
+ */
+ private final String zookeeperQuorum;
+
+ /**
+ * The Accumulo instance name.
+ */
+ private final String instanceName;
+
+ // ===== Password authentication settings (for backward compatibility) =====
+
+ /**
+ * The username for password authentication.
+ * Deprecated: prefer using credentialsProvider.
+ */
+ private final String username;
+
+ /**
+ * The password for password authentication.
+ * Deprecated: prefer using credentialsProvider.
+ */
+ private final String password;
+
+ // ===== Kerberos authentication settings =====
+
+ /**
+ * The authentication type: PASSWORD or KERBEROS.
+ */
+ private final AccumuloAuthType authenticationType;
+
+ /**
+ * Kerberos principal for service authentication.
+ * Format: primary/instance@REALM (e.g., "drill/hostname@EXAMPLE.COM")
+ */
+ private final String principal;
+
+ /**
+ * Path to the Kerberos keytab file.
+ */
+ private final String keytabPath;
+
+ /**
+ * SASL Quality of Protection: "auth", "auth-int", or "auth-conf".
+ * - auth: authentication only
+ * - auth-int: authentication + integrity protection
+ * - auth-conf: authentication + integrity + confidentiality (encryption)
+ */
+ private final String saslQop;
+
+ /**
+ * Accumulo service primary name for SASL authentication.
+ * Default is "accumulo".
+ */
+ private final String accumuloServicePrimary;
+
+ /**
+ * Whether to use delegation tokens for distributed execution.
+ * When true, the service will obtain delegation tokens for query users.
+ */
+ private final boolean useDelegationTokens;
+
+ // ===== Optional settings =====
+
+ /**
+ * Optional name of the schema metadata table.
+ * If set, the plugin will look for table schema definitions in this Accumulo table.
+ * Default is "_drill_schema".
+ */
+ private final String schemaMetadataTable;
+
+ /**
+ * Timeout in milliseconds for Accumulo client operations.
+ * Default is 30000 (30 seconds).
+ */
+ private final Integer clientTimeout;
+
+ /**
+ * Number of threads for BatchScanner operations.
+ * Default is 10.
+ */
+ private final Integer batchScannerThreads;
+
+ @JsonCreator
+ public AccumuloStoragePluginConfig(
+ @JsonProperty("zookeeperQuorum") String zookeeperQuorum,
+ @JsonProperty("instanceName") String instanceName,
+ @JsonProperty("username") String username,
+ @JsonProperty("password") String password,
+ @JsonProperty("authenticationType") String authenticationType,
+ @JsonProperty("principal") String principal,
+ @JsonProperty("keytabPath") String keytabPath,
+ @JsonProperty("saslQop") String saslQop,
+ @JsonProperty("accumuloServicePrimary") String accumuloServicePrimary,
+ @JsonProperty("useDelegationTokens") Boolean useDelegationTokens,
+ @JsonProperty("authMode") String authMode,
+ @JsonProperty("credentialsProvider") CredentialsProvider credentialsProvider,
+ @JsonProperty("schemaMetadataTable") String schemaMetadataTable,
+ @JsonProperty("clientTimeout") Integer clientTimeout,
+ @JsonProperty("batchScannerThreads") Integer batchScannerThreads) {
+
+ super(
+ CredentialProviderUtils.getCredentialsProvider(username, password, credentialsProvider),
+ credentialsProvider == null,
+ AuthMode.parseOrDefault(authMode, AuthMode.SHARED_USER)
+ );
+
+ this.zookeeperQuorum = zookeeperQuorum;
+ this.instanceName = instanceName;
+ this.username = username;
+ this.password = password;
+
+ this.authenticationType = AccumuloAuthType.parseOrDefault(authenticationType, AccumuloAuthType.PASSWORD);
+ this.principal = principal;
+ this.keytabPath = keytabPath;
+ this.saslQop = saslQop != null ? saslQop : DEFAULT_SASL_QOP;
+ this.accumuloServicePrimary = accumuloServicePrimary != null ? accumuloServicePrimary : DEFAULT_ACCUMULO_SERVICE_PRIMARY;
+ this.useDelegationTokens = useDelegationTokens != null ? useDelegationTokens : false;
+
+ this.schemaMetadataTable = schemaMetadataTable != null ? schemaMetadataTable : "_drill_schema";
+ this.clientTimeout = clientTimeout != null ? clientTimeout : 30000;
+ this.batchScannerThreads = batchScannerThreads != null ? batchScannerThreads : 10;
+ }
+
+ /**
+ * Simplified constructor for password authentication (backward compatible).
+ */
+ public AccumuloStoragePluginConfig(
+ String zookeeperQuorum,
+ String instanceName,
+ String username,
+ String password) {
+ this(zookeeperQuorum, instanceName, username, password,
+ null, null, null, null, null, null, null, null, null, null, null);
+ }
+
+ // ===== Connection Getters =====
+
+ @JsonProperty("zookeeperQuorum")
+ public String getZookeeperQuorum() {
+ return zookeeperQuorum;
+ }
+
+ @JsonProperty("instanceName")
+ public String getInstanceName() {
+ return instanceName;
+ }
+
+ // ===== Password Auth Getters =====
+
+ @JsonProperty("username")
+ public String getUsername() {
+ return username;
+ }
+
+ @JsonProperty("password")
+ public String getPassword() {
+ return password;
+ }
+
+ // ===== Kerberos Auth Getters =====
+
+ @JsonProperty("authenticationType")
+ public AccumuloAuthType getAuthenticationType() {
+ return authenticationType;
+ }
+
+ @JsonProperty("principal")
+ public String getPrincipal() {
+ return principal;
+ }
+
+ @JsonProperty("keytabPath")
+ public String getKeytabPath() {
+ return keytabPath;
+ }
+
+ @JsonProperty("saslQop")
+ public String getSaslQop() {
+ return saslQop;
+ }
+
+ @JsonProperty("accumuloServicePrimary")
+ public String getAccumuloServicePrimary() {
+ return accumuloServicePrimary;
+ }
+
+ @JsonProperty("useDelegationTokens")
+ public boolean isUseDelegationTokens() {
+ return useDelegationTokens;
+ }
+
+ // ===== Optional Settings Getters =====
+
+ @JsonProperty("schemaMetadataTable")
+ public String getSchemaMetadataTable() {
+ return schemaMetadataTable;
+ }
+
+ @JsonProperty("clientTimeout")
+ public Integer getClientTimeout() {
+ return clientTimeout;
+ }
+
+ @JsonProperty("batchScannerThreads")
+ public Integer getBatchScannerThreads() {
+ return batchScannerThreads;
+ }
+
+ // ===== Credential Helper Methods =====
+
+ /**
+ * Returns username/password credentials for the specified user context.
+ *
+ * For SHARED_USER mode, returns the configured credentials.
+ * For USER_TRANSLATION mode, returns per-user credentials from the provider.
+ *
+ * @param userCredentials the query user credentials (may be null for SHARED_USER)
+ * @return Optional containing credentials if available
+ */
+ @JsonIgnore
+ public Optional getUsernamePasswordCredentials(
+ UserCredentials userCredentials) {
+
+ switch (authMode) {
+ case SHARED_USER:
+ return new UsernamePasswordCredentials.Builder()
+ .setCredentialsProvider(credentialsProvider)
+ .build();
+
+ case USER_TRANSLATION:
+ if (userCredentials == null) {
+ return Optional.empty();
+ }
+ return new UsernamePasswordCredentials.Builder()
+ .setCredentialsProvider(credentialsProvider)
+ .setQueryUser(userCredentials.getUserName())
+ .build();
+
+ case USER_IMPERSONATION:
+ // For impersonation, service credentials are used for initial auth
+ return new UsernamePasswordCredentials.Builder()
+ .setCredentialsProvider(credentialsProvider)
+ .build();
+
+ default:
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Returns whether user impersonation is enabled.
+ */
+ @JsonIgnore
+ public boolean isUserImpersonationEnabled() {
+ return authMode == AuthMode.USER_IMPERSONATION;
+ }
+
+ /**
+ * Returns whether user translation is enabled.
+ */
+ @JsonIgnore
+ public boolean isUserTranslationEnabled() {
+ return authMode == AuthMode.USER_TRANSLATION;
+ }
+
+ /**
+ * Returns whether Kerberos authentication is configured.
+ */
+ @JsonIgnore
+ public boolean isKerberosEnabled() {
+ return authenticationType == AccumuloAuthType.KERBEROS;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ AccumuloStoragePluginConfig that = (AccumuloStoragePluginConfig) o;
+ return useDelegationTokens == that.useDelegationTokens
+ && Objects.equals(zookeeperQuorum, that.zookeeperQuorum)
+ && Objects.equals(instanceName, that.instanceName)
+ && Objects.equals(username, that.username)
+ && Objects.equals(password, that.password)
+ && authenticationType == that.authenticationType
+ && Objects.equals(principal, that.principal)
+ && Objects.equals(keytabPath, that.keytabPath)
+ && Objects.equals(saslQop, that.saslQop)
+ && Objects.equals(accumuloServicePrimary, that.accumuloServicePrimary)
+ && Objects.equals(schemaMetadataTable, that.schemaMetadataTable)
+ && Objects.equals(clientTimeout, that.clientTimeout)
+ && Objects.equals(batchScannerThreads, that.batchScannerThreads)
+ && Objects.equals(authMode, that.authMode);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(zookeeperQuorum, instanceName, username, password,
+ authenticationType, principal, keytabPath, saslQop, accumuloServicePrimary,
+ useDelegationTokens, schemaMetadataTable, clientTimeout, batchScannerThreads,
+ authMode);
+ }
+
+ @Override
+ public String toString() {
+ return new PlanStringBuilder(this)
+ .field("zookeeperQuorum", zookeeperQuorum)
+ .field("instanceName", instanceName)
+ .field("authenticationType", authenticationType)
+ .field("authMode", authMode)
+ .field("username", username)
+ .maskedField("password", password)
+ .field("principal", principal)
+ .maskedField("keytabPath", keytabPath)
+ .field("saslQop", saslQop)
+ .field("accumuloServicePrimary", accumuloServicePrimary)
+ .field("useDelegationTokens", useDelegationTokens)
+ .field("schemaMetadataTable", schemaMetadataTable)
+ .field("clientTimeout", clientTimeout)
+ .field("batchScannerThreads", batchScannerThreads)
+ .toString();
+ }
+}
diff --git a/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/AccumuloSubScan.java b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/AccumuloSubScan.java
new file mode 100644
index 00000000000..f95bef0eee6
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/AccumuloSubScan.java
@@ -0,0 +1,174 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.drill.common.exceptions.ExecutionSetupException;
+import org.apache.drill.common.expression.SchemaPath;
+import org.apache.drill.exec.physical.base.AbstractBase;
+import org.apache.drill.exec.physical.base.PhysicalOperator;
+import org.apache.drill.exec.physical.base.PhysicalVisitor;
+import org.apache.drill.exec.physical.base.SubScan;
+import org.apache.drill.exec.store.StoragePluginRegistry;
+
+import com.fasterxml.jackson.annotation.JacksonInject;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import com.google.common.base.Preconditions;
+
+/**
+ * Accumulo sub-scan for a specific portion of an Accumulo table.
+ *
+ * In the future, this will represent a scan on specific tablets.
+ * For now, it represents a full table scan.
+ *
+ * For user impersonation mode, this class carries a delegation token
+ * that was generated during planning and is used at execution time to
+ * create an Accumulo client with the user's identity.
+ */
+@JsonTypeName("accumulo-sub-scan")
+public class AccumuloSubScan extends AbstractBase implements SubScan {
+
+ public static final String OPERATOR_TYPE = "ACCUMULO_SUB_SCAN";
+
+ private final AccumuloStoragePlugin storagePlugin;
+ private final AccumuloScanSpec scanSpec;
+ private final List columns;
+ private final int maxRecords;
+
+ /**
+ * Delegation token for user impersonation in distributed execution.
+ * When present, the record reader will use this token to create a
+ * user-impersonated Accumulo client.
+ */
+ private final DelegationTokenInfo delegationTokenInfo;
+
+ @JsonCreator
+ public AccumuloSubScan(
+ @JacksonInject StoragePluginRegistry registry,
+ @JsonProperty("userName") String userName,
+ @JsonProperty("storagePluginConfig") AccumuloStoragePluginConfig storagePluginConfig,
+ @JsonProperty("scanSpec") AccumuloScanSpec scanSpec,
+ @JsonProperty("columns") List columns,
+ @JsonProperty("maxRecords") int maxRecords,
+ @JsonProperty("delegationTokenInfo") DelegationTokenInfo delegationTokenInfo) throws ExecutionSetupException {
+ this(userName, registry.resolve(storagePluginConfig, AccumuloStoragePlugin.class),
+ scanSpec, columns, maxRecords, delegationTokenInfo);
+ }
+
+ public AccumuloSubScan(
+ String userName,
+ AccumuloStoragePlugin storagePlugin,
+ AccumuloScanSpec scanSpec,
+ List columns) {
+ this(userName, storagePlugin, scanSpec, columns, -1, null);
+ }
+
+ public AccumuloSubScan(
+ String userName,
+ AccumuloStoragePlugin storagePlugin,
+ AccumuloScanSpec scanSpec,
+ List columns,
+ int maxRecords) {
+ this(userName, storagePlugin, scanSpec, columns, maxRecords, null);
+ }
+
+ public AccumuloSubScan(
+ String userName,
+ AccumuloStoragePlugin storagePlugin,
+ AccumuloScanSpec scanSpec,
+ List columns,
+ int maxRecords,
+ DelegationTokenInfo delegationTokenInfo) {
+ super(userName);
+ this.storagePlugin = storagePlugin;
+ this.scanSpec = scanSpec;
+ this.columns = columns;
+ this.maxRecords = maxRecords;
+ this.delegationTokenInfo = delegationTokenInfo;
+ }
+
+ @JsonProperty("storagePluginConfig")
+ public AccumuloStoragePluginConfig getStoragePluginConfig() {
+ return storagePlugin.getConfig();
+ }
+
+ @JsonProperty("scanSpec")
+ public AccumuloScanSpec getScanSpec() {
+ return scanSpec;
+ }
+
+ @JsonProperty("columns")
+ public List getColumns() {
+ return columns;
+ }
+
+ @JsonProperty("maxRecords")
+ public int getMaxRecords() {
+ return maxRecords;
+ }
+
+ @JsonProperty("delegationTokenInfo")
+ public DelegationTokenInfo getDelegationTokenInfo() {
+ return delegationTokenInfo;
+ }
+
+ @JsonIgnore
+ public AccumuloStoragePlugin getStoragePlugin() {
+ return storagePlugin;
+ }
+
+ /**
+ * Returns true if this sub-scan has a delegation token for user impersonation.
+ */
+ @JsonIgnore
+ public boolean hasDelegationToken() {
+ return delegationTokenInfo != null;
+ }
+
+ @Override
+ public boolean isExecutable() {
+ return false;
+ }
+
+ @Override
+ public T accept(PhysicalVisitor physicalVisitor, X value) throws E {
+ return physicalVisitor.visitSubScan(this, value);
+ }
+
+ @Override
+ public PhysicalOperator getNewWithChildren(List children) {
+ Preconditions.checkArgument(children.isEmpty());
+ return new AccumuloSubScan(getUserName(), storagePlugin, scanSpec, columns, maxRecords, delegationTokenInfo);
+ }
+
+ @Override
+ public Iterator iterator() {
+ return Collections.emptyIterator();
+ }
+
+ @Override
+ public String getOperatorType() {
+ return OPERATOR_TYPE;
+ }
+}
diff --git a/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/AccumuloTypeConverter.java b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/AccumuloTypeConverter.java
new file mode 100644
index 00000000000..50f70050442
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/AccumuloTypeConverter.java
@@ -0,0 +1,341 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import java.math.BigDecimal;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeParseException;
+
+import org.apache.drill.exec.store.accumulo.schema.AccumuloColumnType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility class for converting Accumulo byte arrays to Java types.
+ *
+ * Accumulo stores all data as byte arrays. This class provides methods to
+ * convert those byte arrays to appropriate Java types based on the configured
+ * column type in the schema.
+ *
+ * Conversion strategies:
+ *
+ * - String types: UTF-8 decode the bytes
+ * - Numeric types: Try parsing string representation first, fall back to binary
+ * - Boolean: Check for "true"/"false" strings or binary 0/1
+ * - Temporal types: Parse ISO-8601 string or epoch milliseconds
+ *
+ */
+public final class AccumuloTypeConverter {
+ private static final Logger logger = LoggerFactory.getLogger(AccumuloTypeConverter.class);
+
+ private AccumuloTypeConverter() {
+ // Utility class
+ }
+
+ /**
+ * Converts Accumulo byte array to the appropriate Java type based on column type.
+ *
+ * @param value the byte array from Accumulo
+ * @param columnType the target type for conversion
+ * @return the converted Java object, or null if conversion fails
+ */
+ public static Object convert(byte[] value, AccumuloColumnType columnType) {
+ if (value == null || value.length == 0) {
+ return null;
+ }
+
+ switch (columnType) {
+ case VARCHAR:
+ return toVarchar(value);
+ case INT:
+ case INTEGER:
+ return toInteger(value);
+ case BIGINT:
+ case LONG:
+ return toLong(value);
+ case SMALLINT:
+ return toShort(value);
+ case TINYINT:
+ return toByte(value);
+ case FLOAT:
+ return toFloat(value);
+ case DOUBLE:
+ return toDouble(value);
+ case DECIMAL:
+ return toDecimal(value);
+ case BOOLEAN:
+ return toBoolean(value);
+ case DATE:
+ return toDate(value);
+ case TIME:
+ return toTime(value);
+ case TIMESTAMP:
+ return toTimestamp(value);
+ case VARBINARY:
+ return value;
+ case ANY:
+ default:
+ // For ANY type, return as string
+ return toVarchar(value);
+ }
+ }
+
+ /**
+ * Converts byte array to String using UTF-8 encoding.
+ */
+ public static String toVarchar(byte[] value) {
+ return new String(value, StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Converts byte array to Integer.
+ * Tries string parsing first, then binary interpretation.
+ */
+ public static Integer toInteger(byte[] value) {
+ // Try parsing as string first (more common)
+ try {
+ String strValue = new String(value, StandardCharsets.UTF_8).trim();
+ return Integer.parseInt(strValue);
+ } catch (NumberFormatException e) {
+ // Fall back to binary interpretation
+ if (value.length == 4) {
+ return ByteBuffer.wrap(value).getInt();
+ }
+ logger.debug("Failed to convert byte array to Integer");
+ return null;
+ }
+ }
+
+ /**
+ * Converts byte array to Long.
+ * Tries string parsing first, then binary interpretation.
+ */
+ public static Long toLong(byte[] value) {
+ try {
+ String strValue = new String(value, StandardCharsets.UTF_8).trim();
+ return Long.parseLong(strValue);
+ } catch (NumberFormatException e) {
+ if (value.length == 8) {
+ return ByteBuffer.wrap(value).getLong();
+ }
+ logger.debug("Failed to convert byte array to Long");
+ return null;
+ }
+ }
+
+ /**
+ * Converts byte array to Short.
+ */
+ public static Short toShort(byte[] value) {
+ try {
+ String strValue = new String(value, StandardCharsets.UTF_8).trim();
+ return Short.parseShort(strValue);
+ } catch (NumberFormatException e) {
+ if (value.length == 2) {
+ return ByteBuffer.wrap(value).getShort();
+ }
+ logger.debug("Failed to convert byte array to Short");
+ return null;
+ }
+ }
+
+ /**
+ * Converts byte array to Byte.
+ */
+ public static Byte toByte(byte[] value) {
+ try {
+ String strValue = new String(value, StandardCharsets.UTF_8).trim();
+ return Byte.parseByte(strValue);
+ } catch (NumberFormatException e) {
+ if (value.length == 1) {
+ return value[0];
+ }
+ logger.debug("Failed to convert byte array to Byte");
+ return null;
+ }
+ }
+
+ /**
+ * Converts byte array to Float.
+ */
+ public static Float toFloat(byte[] value) {
+ try {
+ String strValue = new String(value, StandardCharsets.UTF_8).trim();
+ return Float.parseFloat(strValue);
+ } catch (NumberFormatException e) {
+ if (value.length == 4) {
+ return ByteBuffer.wrap(value).getFloat();
+ }
+ logger.debug("Failed to convert byte array to Float");
+ return null;
+ }
+ }
+
+ /**
+ * Converts byte array to Double.
+ */
+ public static Double toDouble(byte[] value) {
+ try {
+ String strValue = new String(value, StandardCharsets.UTF_8).trim();
+ return Double.parseDouble(strValue);
+ } catch (NumberFormatException e) {
+ if (value.length == 8) {
+ return ByteBuffer.wrap(value).getDouble();
+ }
+ logger.debug("Failed to convert byte array to Double");
+ return null;
+ }
+ }
+
+ /**
+ * Converts byte array to BigDecimal.
+ */
+ public static BigDecimal toDecimal(byte[] value) {
+ try {
+ String strValue = new String(value, StandardCharsets.UTF_8).trim();
+ return new BigDecimal(strValue);
+ } catch (NumberFormatException e) {
+ logger.debug("Failed to convert byte array to BigDecimal");
+ return null;
+ }
+ }
+
+ /**
+ * Converts byte array to Boolean.
+ * Accepts "true"/"false" strings (case insensitive), "1"/"0", or binary 0/1.
+ */
+ public static Boolean toBoolean(byte[] value) {
+ // First try string parsing (handles "true", "false", "1", "0", etc.)
+ String strValue = new String(value, StandardCharsets.UTF_8).trim().toLowerCase();
+ if ("true".equals(strValue) || "1".equals(strValue) || "yes".equals(strValue)) {
+ return true;
+ } else if ("false".equals(strValue) || "0".equals(strValue) || "no".equals(strValue)) {
+ return false;
+ }
+
+ // Fall back to binary interpretation for single byte
+ if (value.length == 1) {
+ return value[0] != 0;
+ }
+
+ logger.debug("Failed to convert byte array to Boolean: {}", strValue);
+ return null;
+ }
+
+ /**
+ * Converts byte array to LocalDate.
+ * Tries ISO-8601 date format first, then epoch days as long.
+ *
+ * @return epoch milliseconds at start of day UTC, or null if conversion fails
+ */
+ public static Long toDate(byte[] value) {
+ String strValue = new String(value, StandardCharsets.UTF_8).trim();
+ try {
+ LocalDate date = LocalDate.parse(strValue);
+ return date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli();
+ } catch (DateTimeParseException e) {
+ // Try as epoch days
+ try {
+ long epochDays = Long.parseLong(strValue);
+ return LocalDate.ofEpochDay(epochDays).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli();
+ } catch (NumberFormatException e2) {
+ logger.debug("Failed to convert byte array to Date: {}", strValue);
+ return null;
+ }
+ }
+ }
+
+ /**
+ * Converts byte array to LocalTime.
+ * Tries ISO-8601 time format first.
+ *
+ * @return milliseconds since midnight, or null if conversion fails
+ */
+ public static Integer toTime(byte[] value) {
+ String strValue = new String(value, StandardCharsets.UTF_8).trim();
+ try {
+ LocalTime time = LocalTime.parse(strValue);
+ return (int) (time.toNanoOfDay() / 1_000_000);
+ } catch (DateTimeParseException e) {
+ // Try as milliseconds since midnight
+ try {
+ return Integer.parseInt(strValue);
+ } catch (NumberFormatException e2) {
+ logger.debug("Failed to convert byte array to Time: {}", strValue);
+ return null;
+ }
+ }
+ }
+
+ /**
+ * Converts byte array to Instant.
+ * Tries ISO-8601 timestamp format first, then epoch milliseconds.
+ *
+ * @return epoch milliseconds, or null if conversion fails
+ */
+ public static Long toTimestamp(byte[] value) {
+ String strValue = new String(value, StandardCharsets.UTF_8).trim();
+ try {
+ Instant instant = Instant.parse(strValue);
+ return instant.toEpochMilli();
+ } catch (DateTimeParseException e) {
+ // Try as epoch milliseconds
+ try {
+ return Long.parseLong(strValue);
+ } catch (NumberFormatException e2) {
+ logger.debug("Failed to convert byte array to Timestamp: {}", strValue);
+ return null;
+ }
+ }
+ }
+
+ /**
+ * Returns the string representation of the byte array for display/debugging.
+ */
+ public static String toDisplayString(byte[] value) {
+ if (value == null) {
+ return "null";
+ }
+ if (value.length == 0) {
+ return "";
+ }
+
+ // Try to interpret as UTF-8 string
+ String strValue = new String(value, StandardCharsets.UTF_8);
+
+ // Check if it looks like valid text (printable characters)
+ boolean isPrintable = strValue.chars().allMatch(c ->
+ !Character.isISOControl(c) || Character.isWhitespace(c));
+
+ if (isPrintable) {
+ return strValue;
+ }
+
+ // Return hex representation for binary data
+ StringBuilder hex = new StringBuilder("0x");
+ for (byte b : value) {
+ hex.append(String.format("%02X", b));
+ }
+ return hex.toString();
+ }
+}
diff --git a/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/DelegationTokenInfo.java b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/DelegationTokenInfo.java
new file mode 100644
index 00000000000..48cc2261527
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/DelegationTokenInfo.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.drill.exec.store.accumulo;
+
+import java.util.Base64;
+import java.util.Objects;
+
+import org.apache.accumulo.core.client.security.tokens.AuthenticationToken;
+import org.apache.accumulo.core.client.security.tokens.AuthenticationToken.AuthenticationTokenSerializer;
+import org.apache.accumulo.core.client.security.tokens.DelegationToken;
+import org.apache.drill.common.PlanStringBuilder;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Serializable wrapper for Accumulo delegation tokens.
+ *
+ * This class enables delegation tokens to be passed across Drill's distributed
+ * execution pipeline via JSON serialization. The token is stored as a Base64-encoded
+ * string for safe transport through JSON.
+ *
+ * Usage flow:
+ *
+ * - Service client obtains a delegation token for a user
+ * - Token is wrapped in DelegationTokenInfo and attached to AccumuloGroupScan
+ * - Token is serialized to JSON for distributed planning
+ * - At execution time, token is deserialized and used to create a client
+ *
+ */
+public class DelegationTokenInfo {
+
+ /**
+ * The username this delegation token was created for.
+ */
+ private final String userName;
+
+ /**
+ * Base64-encoded serialized delegation token.
+ * Using Base64 string instead of raw byte[] for safer JSON serialization.
+ */
+ private final String serializedToken;
+
+ /**
+ * The fully qualified class name of the token implementation.
+ * Needed for deserialization.
+ */
+ private final String tokenClassName;
+
+ /**
+ * Time when this delegation token was created (epoch millis).
+ * Used for cache eviction and token refresh decisions.
+ */
+ private final long creationTime;
+
+ @JsonCreator
+ public DelegationTokenInfo(
+ @JsonProperty("userName") String userName,
+ @JsonProperty("serializedToken") String serializedToken,
+ @JsonProperty("tokenClassName") String tokenClassName,
+ @JsonProperty("creationTime") long creationTime) {
+ this.userName = userName;
+ this.serializedToken = serializedToken;
+ this.tokenClassName = tokenClassName;
+ this.creationTime = creationTime;
+ }
+
+ /**
+ * Creates a DelegationTokenInfo from an Accumulo DelegationToken.
+ *
+ * @param userName the user this token is for
+ * @param token the Accumulo delegation token
+ * @return a new DelegationTokenInfo
+ */
+ public static DelegationTokenInfo fromDelegationToken(String userName, DelegationToken token) {
+ byte[] tokenBytes = AuthenticationTokenSerializer.serialize(token);
+ String serialized = Base64.getEncoder().encodeToString(tokenBytes);
+ String className = token.getClass().getName();
+ return new DelegationTokenInfo(userName, serialized, className, System.currentTimeMillis());
+ }
+
+ /**
+ * Converts this wrapper back to an Accumulo AuthenticationToken.
+ *
+ * Note: This returns an AuthenticationToken (the parent interface) rather than
+ * DelegationToken because the deserialization uses the stored class name.
+ *
+ * @return the deserialized AuthenticationToken
+ */
+ @SuppressWarnings("unchecked")
+ @JsonIgnore
+ public AuthenticationToken toAuthenticationToken() {
+ byte[] tokenBytes = Base64.getDecoder().decode(serializedToken);
+ try {
+ Class extends AuthenticationToken> tokenClass =
+ (Class extends AuthenticationToken>) Class.forName(tokenClassName);
+ return AuthenticationTokenSerializer.deserialize(tokenClass, tokenBytes);
+ } catch (ClassNotFoundException e) {
+ throw new RuntimeException("Failed to load token class: " + tokenClassName, e);
+ }
+ }
+
+ @JsonProperty("userName")
+ public String getUserName() {
+ return userName;
+ }
+
+ @JsonProperty("serializedToken")
+ public String getSerializedToken() {
+ return serializedToken;
+ }
+
+ @JsonProperty("tokenClassName")
+ public String getTokenClassName() {
+ return tokenClassName;
+ }
+
+ @JsonProperty("creationTime")
+ public long getCreationTime() {
+ return creationTime;
+ }
+
+ /**
+ * Returns the age of this token in milliseconds.
+ */
+ @JsonIgnore
+ public long getAgeMillis() {
+ return System.currentTimeMillis() - creationTime;
+ }
+
+ /**
+ * Checks if this token is older than the specified age.
+ *
+ * @param maxAgeMillis maximum acceptable age in milliseconds
+ * @return true if the token is older than maxAgeMillis
+ */
+ @JsonIgnore
+ public boolean isOlderThan(long maxAgeMillis) {
+ return getAgeMillis() > maxAgeMillis;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ DelegationTokenInfo that = (DelegationTokenInfo) o;
+ return creationTime == that.creationTime
+ && Objects.equals(userName, that.userName)
+ && Objects.equals(serializedToken, that.serializedToken)
+ && Objects.equals(tokenClassName, that.tokenClassName);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(userName, serializedToken, tokenClassName, creationTime);
+ }
+
+ @Override
+ public String toString() {
+ return new PlanStringBuilder(this)
+ .field("userName", userName)
+ .field("tokenClassName", tokenClassName)
+ .field("creationTime", creationTime)
+ .field("tokenLength", serializedToken != null ? serializedToken.length() : 0)
+ .toString();
+ }
+}
diff --git a/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/DrillAccumuloConstants.java b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/DrillAccumuloConstants.java
new file mode 100644
index 00000000000..bfde8b6a2b5
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/DrillAccumuloConstants.java
@@ -0,0 +1,64 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import org.apache.drill.common.expression.SchemaPath;
+import org.apache.drill.common.types.TypeProtos.MajorType;
+import org.apache.drill.common.types.TypeProtos.MinorType;
+import org.apache.drill.common.types.Types;
+
+/**
+ * Constants used by the Accumulo storage plugin.
+ */
+public interface DrillAccumuloConstants {
+
+ /**
+ * Name of the row key column in Drill queries.
+ */
+ String ROW_KEY = "row_key";
+
+ /**
+ * Schema path for the row key.
+ */
+ SchemaPath ROW_KEY_PATH = SchemaPath.getSimplePath(ROW_KEY);
+
+ /**
+ * Type for the row key column (required VARBINARY).
+ */
+ MajorType ROW_KEY_TYPE = Types.required(MinorType.VARBINARY);
+
+ /**
+ * Type for column family maps (required MAP).
+ */
+ MajorType COLUMN_FAMILY_TYPE = Types.required(MinorType.MAP);
+
+ /**
+ * Type for individual columns within a family (optional VARBINARY).
+ */
+ MajorType COLUMN_TYPE = Types.optional(MinorType.VARBINARY);
+
+ /**
+ * Separator between column family and qualifier in Accumulo keys.
+ */
+ String COLUMN_SEPARATOR = ":";
+
+ /**
+ * Default batch size for scanner caching.
+ */
+ int DEFAULT_BATCH_SIZE = 4000;
+}
diff --git a/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/DrillAccumuloTable.java b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/DrillAccumuloTable.java
new file mode 100644
index 00000000000..5f136d512fa
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/DrillAccumuloTable.java
@@ -0,0 +1,190 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.accumulo.core.client.Scanner;
+import org.apache.accumulo.core.data.Key;
+import org.apache.accumulo.core.data.Value;
+import org.apache.accumulo.core.security.Authorizations;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.drill.exec.planner.logical.DrillTable;
+import org.apache.drill.exec.store.accumulo.schema.ColumnDef;
+import org.apache.drill.exec.store.accumulo.schema.TableSchema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Represents an Accumulo table in Drill's query planner.
+ *
+ * This class provides the row type (schema) for Accumulo tables to Drill's
+ * Calcite-based query planner. It uses the configured schema provider to
+ * discover column definitions.
+ *
+ * Schema resolution follows this order:
+ *
+ * - If explicit schema is defined in the metadata table, use that
+ * - Otherwise, expose row_key plus one map column per Accumulo column family,
+ * with the families inferred from a bounded sample of the table's data
+ *
+ */
+public class DrillAccumuloTable extends DrillTable {
+ private static final Logger logger = LoggerFactory.getLogger(DrillAccumuloTable.class);
+
+ public static final String ROW_KEY_COLUMN = "row_key";
+
+ private final AccumuloStoragePlugin plugin;
+ private final AccumuloScanSpec scanSpec;
+
+ /**
+ * Maximum number of Accumulo entries examined when inferring which column families
+ * a table contains. Accumulo has no catalog of column families, so they have to be
+ * read off the data itself; the cap keeps planning cheap on large tables.
+ */
+ private static final int COLUMN_FAMILY_SAMPLE_SIZE = 1000;
+
+ private TableSchema tableSchema;
+ private Set columnFamilies;
+
+ public DrillAccumuloTable(
+ AccumuloStoragePlugin plugin,
+ String storageEngineName,
+ AccumuloScanSpec scanSpec) {
+ super(storageEngineName, plugin, scanSpec);
+ this.plugin = plugin;
+ this.scanSpec = scanSpec;
+ }
+
+ @Override
+ public RelDataType getRowType(RelDataTypeFactory typeFactory) {
+ TableSchema schema = getTableSchema();
+
+ ArrayList typeList = new ArrayList<>();
+ ArrayList fieldNameList = new ArrayList<>();
+
+ // Always include row_key as first column
+ fieldNameList.add(ROW_KEY_COLUMN);
+ typeList.add(typeFactory.createTypeWithNullability(
+ typeFactory.createSqlType(schema.getRowKeyType().getSqlTypeName()),
+ false));
+
+ if (schema.hasExplicitColumns()) {
+ // Use explicit schema from metadata table
+ for (ColumnDef column : schema.getColumns()) {
+ fieldNameList.add(column.getName());
+ RelDataType columnType = createColumnType(typeFactory, column);
+ typeList.add(typeFactory.createTypeWithNullability(columnType, column.isNullable()));
+ }
+ logger.debug("Using explicit schema for table '{}' with {} columns",
+ scanSpec.getTableName(), schema.getColumnCount());
+ } else {
+ // No explicit schema: expose one map column per Accumulo column family, which
+ // is the shape the record reader produces (row_key plus a MapVector per family).
+ for (String family : getColumnFamilies()) {
+ fieldNameList.add(family);
+ typeList.add(typeFactory.createMapType(
+ typeFactory.createSqlType(SqlTypeName.VARCHAR),
+ typeFactory.createSqlType(SqlTypeName.ANY)));
+ }
+ logger.debug("Using inferred schema for table '{}' with column families {}",
+ scanSpec.getTableName(), fieldNameList);
+ }
+
+ return typeFactory.createStructType(typeList, fieldNameList);
+ }
+
+ /**
+ * Creates a RelDataType for the given column definition.
+ */
+ private RelDataType createColumnType(RelDataTypeFactory typeFactory, ColumnDef column) {
+ SqlTypeName sqlType = column.getSqlTypeName();
+
+ switch (sqlType) {
+ case VARCHAR:
+ case CHAR:
+ // Use default precision for string types
+ return typeFactory.createSqlType(sqlType, 65535);
+ case DECIMAL:
+ // Use default precision and scale for decimal
+ return typeFactory.createSqlType(sqlType, 38, 10);
+ default:
+ return typeFactory.createSqlType(sqlType);
+ }
+ }
+
+ /**
+ * Returns the column families present in the table, inferring them from a bounded
+ * sample of the table's data.
+ *
+ * Unlike HBase, Accumulo does not declare its column families up front, so they
+ * are read from the first {@value #COLUMN_FAMILY_SAMPLE_SIZE} entries. A family that
+ * appears only beyond that point will not be visible to the planner; define an
+ * explicit schema in the metadata table for tables where that matters.
+ */
+ private Set getColumnFamilies() {
+ if (columnFamilies == null) {
+ Set families = new LinkedHashSet<>();
+ try (Scanner scanner = plugin.getClient()
+ .createScanner(scanSpec.getTableName(), Authorizations.EMPTY)) {
+ int examined = 0;
+ for (Map.Entry entry : scanner) {
+ families.add(entry.getKey().getColumnFamily().toString());
+ if (++examined >= COLUMN_FAMILY_SAMPLE_SIZE) {
+ break;
+ }
+ }
+ } catch (Exception e) {
+ logger.warn("Failed to infer column families for table '{}'",
+ scanSpec.getTableName(), e);
+ }
+ columnFamilies = families;
+ }
+ return columnFamilies;
+ }
+
+ /**
+ * Returns the table schema, loading it from the schema provider if needed.
+ */
+ public TableSchema getTableSchema() {
+ if (tableSchema == null) {
+ try {
+ tableSchema = plugin.getSchemaProvider()
+ .getTableSchema(plugin.getClient(), scanSpec.getTableName());
+ } catch (Exception e) {
+ logger.warn("Failed to load schema for table '{}', using dynamic schema",
+ scanSpec.getTableName(), e);
+ tableSchema = TableSchema.dynamic(scanSpec.getTableName());
+ }
+ }
+ return tableSchema;
+ }
+
+ public AccumuloScanSpec getScanSpec() {
+ return scanSpec;
+ }
+
+ public AccumuloStoragePlugin getPlugin() {
+ return plugin;
+ }
+}
diff --git a/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/AccumuloColumnType.java b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/AccumuloColumnType.java
new file mode 100644
index 00000000000..d317c0980a8
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/AccumuloColumnType.java
@@ -0,0 +1,210 @@
+/*
+ * 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.drill.exec.store.accumulo.schema;
+
+import org.apache.calcite.sql.type.SqlTypeName;
+
+/**
+ * Supported column types for Accumulo tables in Drill.
+ *
+ * Accumulo is schema-less and stores all data as byte arrays.
+ * This enum defines the logical types that Drill will use to interpret
+ * the byte data when reading from Accumulo.
+ */
+public enum AccumuloColumnType {
+
+ /**
+ * Variable-length string (default type).
+ */
+ VARCHAR(SqlTypeName.VARCHAR),
+
+ /**
+ * Fixed-length string.
+ */
+ CHAR(SqlTypeName.CHAR),
+
+ /**
+ * 32-bit signed integer.
+ */
+ INT(SqlTypeName.INTEGER),
+
+ /**
+ * Alias for INT.
+ */
+ INTEGER(SqlTypeName.INTEGER),
+
+ /**
+ * 64-bit signed integer.
+ */
+ BIGINT(SqlTypeName.BIGINT),
+
+ /**
+ * Alias for BIGINT.
+ */
+ LONG(SqlTypeName.BIGINT),
+
+ /**
+ * 16-bit signed integer.
+ */
+ SMALLINT(SqlTypeName.SMALLINT),
+
+ /**
+ * 8-bit signed integer.
+ */
+ TINYINT(SqlTypeName.TINYINT),
+
+ /**
+ * Single-precision floating point.
+ */
+ FLOAT(SqlTypeName.FLOAT),
+
+ /**
+ * Double-precision floating point.
+ */
+ DOUBLE(SqlTypeName.DOUBLE),
+
+ /**
+ * Exact numeric with configurable precision and scale.
+ */
+ DECIMAL(SqlTypeName.DECIMAL),
+
+ /**
+ * Boolean value.
+ */
+ BOOLEAN(SqlTypeName.BOOLEAN),
+
+ /**
+ * Date without time component.
+ */
+ DATE(SqlTypeName.DATE),
+
+ /**
+ * Time without date component.
+ */
+ TIME(SqlTypeName.TIME),
+
+ /**
+ * Date and time.
+ */
+ TIMESTAMP(SqlTypeName.TIMESTAMP),
+
+ /**
+ * Binary data (raw bytes).
+ */
+ VARBINARY(SqlTypeName.VARBINARY),
+
+ /**
+ * Dynamic type (determined at runtime).
+ */
+ ANY(SqlTypeName.ANY);
+
+ private final SqlTypeName sqlTypeName;
+
+ AccumuloColumnType(SqlTypeName sqlTypeName) {
+ this.sqlTypeName = sqlTypeName;
+ }
+
+ /**
+ * Returns the corresponding Calcite SQL type name.
+ */
+ public SqlTypeName getSqlTypeName() {
+ return sqlTypeName;
+ }
+
+ /**
+ * Parses a type string to an AccumuloColumnType.
+ *
+ * Case-insensitive matching. Returns VARCHAR if the type is not recognized.
+ *
+ * @param typeString the type string to parse
+ * @return the corresponding AccumuloColumnType
+ */
+ public static AccumuloColumnType fromString(String typeString) {
+ if (typeString == null || typeString.trim().isEmpty()) {
+ return VARCHAR;
+ }
+
+ String normalized = typeString.trim().toUpperCase();
+
+ // Handle common aliases
+ switch (normalized) {
+ case "STRING":
+ case "TEXT":
+ return VARCHAR;
+ case "INT":
+ case "INTEGER":
+ return INTEGER;
+ case "LONG":
+ case "BIGINT":
+ return BIGINT;
+ case "FLOAT":
+ case "REAL":
+ return FLOAT;
+ case "DOUBLE":
+ case "DOUBLE PRECISION":
+ return DOUBLE;
+ case "BOOL":
+ case "BOOLEAN":
+ return BOOLEAN;
+ case "BYTES":
+ case "BINARY":
+ case "VARBINARY":
+ return VARBINARY;
+ default:
+ try {
+ return valueOf(normalized);
+ } catch (IllegalArgumentException e) {
+ return VARCHAR;
+ }
+ }
+ }
+
+ /**
+ * Returns true if this type is a numeric type.
+ */
+ public boolean isNumeric() {
+ switch (this) {
+ case INT:
+ case INTEGER:
+ case BIGINT:
+ case LONG:
+ case SMALLINT:
+ case TINYINT:
+ case FLOAT:
+ case DOUBLE:
+ case DECIMAL:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Returns true if this type is a temporal type.
+ */
+ public boolean isTemporal() {
+ switch (this) {
+ case DATE:
+ case TIME:
+ case TIMESTAMP:
+ return true;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/AccumuloSchemaProvider.java b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/AccumuloSchemaProvider.java
new file mode 100644
index 00000000000..d775723eaf5
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/AccumuloSchemaProvider.java
@@ -0,0 +1,89 @@
+/*
+ * 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.drill.exec.store.accumulo.schema;
+
+import java.util.Set;
+
+import org.apache.accumulo.core.client.AccumuloClient;
+
+/**
+ * Interface for schema discovery strategies in the Accumulo storage plugin.
+ *
+ * This interface abstracts how table schemas are discovered from Accumulo.
+ * Different implementations can provide schema information from different sources:
+ *
+ * - {@code MetadataTableSchemaProvider} - Reads schema from a dedicated Accumulo metadata table
+ * - {@code ScanSamplingSchemaProvider} - Infers schema by sampling table data (future)
+ * - {@code ConfigFileSchemaProvider} - Reads schema from external configuration files (future)
+ *
+ *
+ * This is the extension point for Option B (advanced mode) where custom schema
+ * providers could expose Accumulo-specific features like iterators.
+ */
+public interface AccumuloSchemaProvider {
+
+ /**
+ * Returns the schema for the specified Accumulo table.
+ *
+ * If the schema is not found or cannot be determined, implementations should
+ * return a dynamic schema (via {@link TableSchema#dynamic(String)}) rather than
+ * throwing an exception.
+ *
+ * @param client the Accumulo client
+ * @param tableName the name of the table
+ * @return the table schema, never null
+ */
+ TableSchema getTableSchema(AccumuloClient client, String tableName);
+
+ /**
+ * Discovers all table names available in the Accumulo instance.
+ *
+ * Implementations should filter out system tables (e.g., tables starting with "accumulo.")
+ * unless specifically configured to include them.
+ *
+ * @param client the Accumulo client
+ * @return set of table names, never null (may be empty)
+ */
+ Set discoverTableNames(AccumuloClient client);
+
+ /**
+ * Returns true if this provider has schema information for the specified table.
+ *
+ * This can be used to check if explicit schema metadata exists before
+ * falling back to dynamic schema discovery.
+ *
+ * @param client the Accumulo client
+ * @param tableName the name of the table
+ * @return true if schema information is available
+ */
+ boolean hasSchema(AccumuloClient client, String tableName);
+
+ /**
+ * Clears any cached schema information.
+ *
+ * Called when schema metadata may have changed and needs to be refreshed.
+ */
+ void clearCache();
+
+ /**
+ * Clears cached schema information for a specific table.
+ *
+ * @param tableName the table to clear from cache
+ */
+ void clearCache(String tableName);
+}
diff --git a/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/ColumnDef.java b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/ColumnDef.java
new file mode 100644
index 00000000000..ab6b1c238d6
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/ColumnDef.java
@@ -0,0 +1,146 @@
+/*
+ * 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.drill.exec.store.accumulo.schema;
+
+import java.util.Objects;
+
+import org.apache.calcite.sql.type.SqlTypeName;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Represents a column definition for an Accumulo table in Drill.
+ *
+ * Maps an Accumulo column family:qualifier pair to a Drill column with
+ * a specific SQL type.
+ */
+public class ColumnDef {
+
+ private final String name;
+ private final String columnFamily;
+ private final String columnQualifier;
+ private final AccumuloColumnType type;
+ private final boolean nullable;
+
+ @JsonCreator
+ public ColumnDef(
+ @JsonProperty("name") String name,
+ @JsonProperty("columnFamily") String columnFamily,
+ @JsonProperty("columnQualifier") String columnQualifier,
+ @JsonProperty("type") AccumuloColumnType type,
+ @JsonProperty("nullable") Boolean nullable) {
+ this.name = name;
+ this.columnFamily = columnFamily;
+ this.columnQualifier = columnQualifier;
+ this.type = type != null ? type : AccumuloColumnType.VARCHAR;
+ this.nullable = nullable != null ? nullable : true;
+ }
+
+ /**
+ * Convenience constructor for creating a column definition.
+ */
+ public static ColumnDef create(String name, String columnFamily, String columnQualifier,
+ AccumuloColumnType type) {
+ return new ColumnDef(name, columnFamily, columnQualifier, type, true);
+ }
+
+ /**
+ * Convenience constructor for VARCHAR columns.
+ */
+ public static ColumnDef varchar(String name, String columnFamily, String columnQualifier) {
+ return new ColumnDef(name, columnFamily, columnQualifier, AccumuloColumnType.VARCHAR, true);
+ }
+
+ @JsonProperty("name")
+ public String getName() {
+ return name;
+ }
+
+ @JsonProperty("columnFamily")
+ public String getColumnFamily() {
+ return columnFamily;
+ }
+
+ @JsonProperty("columnQualifier")
+ public String getColumnQualifier() {
+ return columnQualifier;
+ }
+
+ @JsonProperty("type")
+ public AccumuloColumnType getType() {
+ return type;
+ }
+
+ @JsonProperty("nullable")
+ public boolean isNullable() {
+ return nullable;
+ }
+
+ /**
+ * Returns the SQL type name for this column.
+ */
+ @JsonIgnore
+ public SqlTypeName getSqlTypeName() {
+ return type.getSqlTypeName();
+ }
+
+ /**
+ * Returns the full Accumulo column identifier (family:qualifier).
+ */
+ @JsonIgnore
+ public String getFullColumnName() {
+ if (columnQualifier == null || columnQualifier.isEmpty()) {
+ return columnFamily;
+ }
+ return columnFamily + ":" + columnQualifier;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ ColumnDef columnDef = (ColumnDef) o;
+ return nullable == columnDef.nullable
+ && Objects.equals(name, columnDef.name)
+ && Objects.equals(columnFamily, columnDef.columnFamily)
+ && Objects.equals(columnQualifier, columnDef.columnQualifier)
+ && type == columnDef.type;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, columnFamily, columnQualifier, type, nullable);
+ }
+
+ @Override
+ public String toString() {
+ return "ColumnDef{" +
+ "name='" + name + '\'' +
+ ", columnFamily='" + columnFamily + '\'' +
+ ", columnQualifier='" + columnQualifier + '\'' +
+ ", type=" + type +
+ ", nullable=" + nullable +
+ '}';
+ }
+}
diff --git a/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/MetadataTableSchemaProvider.java b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/MetadataTableSchemaProvider.java
new file mode 100644
index 00000000000..b6b555f7d19
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/MetadataTableSchemaProvider.java
@@ -0,0 +1,293 @@
+/*
+ * 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.drill.exec.store.accumulo.schema;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.accumulo.core.client.AccumuloClient;
+import org.apache.accumulo.core.client.Scanner;
+import org.apache.accumulo.core.client.TableNotFoundException;
+import org.apache.accumulo.core.data.Key;
+import org.apache.accumulo.core.data.Range;
+import org.apache.accumulo.core.data.Value;
+import org.apache.accumulo.core.security.Authorizations;
+import org.apache.hadoop.io.Text;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Schema provider that reads table schema from an Accumulo metadata table.
+ *
+ * The metadata table stores schema information in the following format:
+ *
+ * Row Key: {table_name}
+ * Column Family: "schema"
+ * Column Qualifiers:
+ * - "row_key_type": The type of the row key (e.g., "VARCHAR", "VARBINARY")
+ * - "columns": JSON array of column definitions
+ *
+ * Example:
+ * Row: "users"
+ * schema:row_key_type = "VARCHAR"
+ * schema:columns = [
+ * {"name":"name","columnFamily":"cf1","columnQualifier":"name","type":"VARCHAR","nullable":true},
+ * {"name":"age","columnFamily":"cf1","columnQualifier":"age","type":"INT","nullable":true}
+ * ]
+ *
+ *
+ * This provider includes a configurable cache to reduce Accumulo metadata table lookups.
+ */
+public class MetadataTableSchemaProvider implements AccumuloSchemaProvider {
+ private static final Logger logger = LoggerFactory.getLogger(MetadataTableSchemaProvider.class);
+
+ private static final String SCHEMA_COLUMN_FAMILY = "schema";
+ private static final String ROW_KEY_TYPE_QUALIFIER = "row_key_type";
+ private static final String COLUMNS_QUALIFIER = "columns";
+
+ private static final long DEFAULT_CACHE_TTL_MS = TimeUnit.MINUTES.toMillis(5);
+
+ private final String metadataTableName;
+ private final ObjectMapper objectMapper;
+ private final Map schemaCache;
+ private final long cacheTtlMs;
+
+ /**
+ * Creates a new MetadataTableSchemaProvider.
+ *
+ * @param metadataTableName the name of the Accumulo table storing schema metadata
+ */
+ public MetadataTableSchemaProvider(String metadataTableName) {
+ this(metadataTableName, DEFAULT_CACHE_TTL_MS);
+ }
+
+ /**
+ * Creates a new MetadataTableSchemaProvider with a custom cache TTL.
+ *
+ * @param metadataTableName the name of the Accumulo table storing schema metadata
+ * @param cacheTtlMs cache time-to-live in milliseconds
+ */
+ public MetadataTableSchemaProvider(String metadataTableName, long cacheTtlMs) {
+ this.metadataTableName = metadataTableName;
+ this.objectMapper = new ObjectMapper();
+ this.schemaCache = new ConcurrentHashMap<>();
+ this.cacheTtlMs = cacheTtlMs;
+ }
+
+ @Override
+ public TableSchema getTableSchema(AccumuloClient client, String tableName) {
+ // Check cache first
+ CachedSchema cached = schemaCache.get(tableName);
+ if (cached != null && !cached.isExpired()) {
+ logger.debug("Returning cached schema for table: {}", tableName);
+ return cached.schema;
+ }
+
+ // Try to load from metadata table
+ TableSchema schema = loadSchemaFromMetadata(client, tableName);
+
+ // Cache the result (even if dynamic)
+ schemaCache.put(tableName, new CachedSchema(schema));
+
+ return schema;
+ }
+
+ @Override
+ public Set discoverTableNames(AccumuloClient client) {
+ Set tableNames = new HashSet<>();
+ try {
+ for (String name : client.tableOperations().list()) {
+ // Filter out system tables and the metadata table itself
+ if (!name.startsWith("accumulo.") && !name.equals(metadataTableName)) {
+ tableNames.add(name);
+ }
+ }
+ } catch (Exception e) {
+ logger.warn("Failed to discover table names", e);
+ }
+ return tableNames;
+ }
+
+ @Override
+ public boolean hasSchema(AccumuloClient client, String tableName) {
+ // Check cache first
+ CachedSchema cached = schemaCache.get(tableName);
+ if (cached != null && !cached.isExpired()) {
+ return cached.schema.hasExplicitColumns();
+ }
+
+ // Check metadata table
+ if (!metadataTableExists(client)) {
+ return false;
+ }
+
+ try (Scanner scanner = client.createScanner(metadataTableName, Authorizations.EMPTY)) {
+ scanner.setRange(Range.exact(tableName));
+ scanner.fetchColumn(new Text(SCHEMA_COLUMN_FAMILY), new Text(COLUMNS_QUALIFIER));
+ return scanner.iterator().hasNext();
+ } catch (TableNotFoundException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public void clearCache() {
+ schemaCache.clear();
+ logger.debug("Cleared all cached schemas");
+ }
+
+ @Override
+ public void clearCache(String tableName) {
+ schemaCache.remove(tableName);
+ logger.debug("Cleared cached schema for table: {}", tableName);
+ }
+
+ /**
+ * Loads schema from the metadata table.
+ */
+ private TableSchema loadSchemaFromMetadata(AccumuloClient client, String tableName) {
+ if (!metadataTableExists(client)) {
+ logger.debug("Metadata table '{}' does not exist, using dynamic schema for: {}",
+ metadataTableName, tableName);
+ return TableSchema.dynamic(tableName);
+ }
+
+ try (Scanner scanner = client.createScanner(metadataTableName, Authorizations.EMPTY)) {
+ scanner.setRange(Range.exact(tableName));
+ scanner.fetchColumnFamily(new Text(SCHEMA_COLUMN_FAMILY));
+
+ AccumuloColumnType rowKeyType = AccumuloColumnType.VARBINARY;
+ List columns = null;
+
+ for (Map.Entry entry : scanner) {
+ String qualifier = entry.getKey().getColumnQualifier().toString();
+ String value = entry.getValue().toString();
+
+ if (ROW_KEY_TYPE_QUALIFIER.equals(qualifier)) {
+ rowKeyType = AccumuloColumnType.fromString(value);
+ } else if (COLUMNS_QUALIFIER.equals(qualifier)) {
+ columns = parseColumnsJson(value);
+ }
+ }
+
+ if (columns == null || columns.isEmpty()) {
+ logger.debug("No explicit schema found for table: {}, using dynamic schema", tableName);
+ return TableSchema.dynamic(tableName);
+ }
+
+ logger.debug("Loaded schema for table '{}' with {} columns", tableName, columns.size());
+ return new TableSchema(tableName, rowKeyType, columns);
+
+ } catch (TableNotFoundException e) {
+ logger.debug("Metadata table not found, using dynamic schema for: {}", tableName);
+ return TableSchema.dynamic(tableName);
+ } catch (Exception e) {
+ logger.warn("Error loading schema for table '{}', using dynamic schema", tableName, e);
+ return TableSchema.dynamic(tableName);
+ }
+ }
+
+ /**
+ * Parses the JSON column definitions.
+ */
+ private List parseColumnsJson(String json) {
+ try {
+ return objectMapper.readValue(json, new TypeReference>() {});
+ } catch (Exception e) {
+ logger.warn("Failed to parse column definitions JSON: {}", e.getMessage());
+ return new ArrayList<>();
+ }
+ }
+
+ /**
+ * Checks if the metadata table exists.
+ */
+ private boolean metadataTableExists(AccumuloClient client) {
+ return client.tableOperations().exists(metadataTableName);
+ }
+
+ /**
+ * Returns the name of the metadata table.
+ */
+ public String getMetadataTableName() {
+ return metadataTableName;
+ }
+
+ /**
+ * Cached schema entry with expiration tracking.
+ */
+ private class CachedSchema {
+ final TableSchema schema;
+ final long timestamp;
+
+ CachedSchema(TableSchema schema) {
+ this.schema = schema;
+ this.timestamp = System.currentTimeMillis();
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() - timestamp > cacheTtlMs;
+ }
+ }
+
+ /**
+ * Writes schema metadata for a table to the metadata table.
+ *
+ * This is a utility method for setting up schema metadata.
+ * It creates the metadata table if it doesn't exist.
+ *
+ * @param client the Accumulo client
+ * @param schema the table schema to write
+ * @throws Exception if the write fails
+ */
+ public void writeSchema(AccumuloClient client, TableSchema schema) throws Exception {
+ // Create metadata table if it doesn't exist
+ if (!client.tableOperations().exists(metadataTableName)) {
+ client.tableOperations().create(metadataTableName);
+ logger.info("Created metadata table: {}", metadataTableName);
+ }
+
+ // Write schema to metadata table
+ try (var writer = client.createBatchWriter(metadataTableName)) {
+ org.apache.accumulo.core.data.Mutation mutation =
+ new org.apache.accumulo.core.data.Mutation(schema.getTableName());
+
+ // Write row key type
+ mutation.put(SCHEMA_COLUMN_FAMILY, ROW_KEY_TYPE_QUALIFIER,
+ schema.getRowKeyType().name());
+
+ // Write columns as JSON
+ String columnsJson = objectMapper.writeValueAsString(schema.getColumns());
+ mutation.put(SCHEMA_COLUMN_FAMILY, COLUMNS_QUALIFIER, columnsJson);
+
+ writer.addMutation(mutation);
+ }
+
+ // Clear cache for this table
+ clearCache(schema.getTableName());
+ logger.info("Wrote schema for table: {}", schema.getTableName());
+ }
+}
diff --git a/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/TableSchema.java b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/TableSchema.java
new file mode 100644
index 00000000000..a2503b4f867
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/java/org/apache/drill/exec/store/accumulo/schema/TableSchema.java
@@ -0,0 +1,193 @@
+/*
+ * 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.drill.exec.store.accumulo.schema;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Represents the schema of an Accumulo table for Drill.
+ *
+ * Contains the table name, row key type, and column definitions.
+ * The schema is used by Drill's query planner to understand the structure
+ * of Accumulo tables.
+ */
+public class TableSchema {
+
+ public static final String ROW_KEY_COLUMN = "row_key";
+
+ private final String tableName;
+ private final AccumuloColumnType rowKeyType;
+ private final List columns;
+ private final Map columnsByName;
+ private final Map columnsByAccumuloKey;
+
+ @JsonCreator
+ public TableSchema(
+ @JsonProperty("tableName") String tableName,
+ @JsonProperty("rowKeyType") AccumuloColumnType rowKeyType,
+ @JsonProperty("columns") List columns) {
+ this.tableName = tableName;
+ this.rowKeyType = rowKeyType != null ? rowKeyType : AccumuloColumnType.VARBINARY;
+ this.columns = columns != null ? new ArrayList<>(columns) : new ArrayList<>();
+
+ // Build lookup maps
+ this.columnsByName = new LinkedHashMap<>();
+ this.columnsByAccumuloKey = new LinkedHashMap<>();
+ for (ColumnDef col : this.columns) {
+ columnsByName.put(col.getName().toLowerCase(), col);
+ columnsByAccumuloKey.put(col.getFullColumnName(), col);
+ }
+ }
+
+ /**
+ * Creates a builder for constructing a TableSchema.
+ */
+ public static Builder builder(String tableName) {
+ return new Builder(tableName);
+ }
+
+ /**
+ * Creates a dynamic schema with just the row key and a wildcard columns map.
+ * Used when no explicit schema is defined.
+ */
+ public static TableSchema dynamic(String tableName) {
+ return new TableSchema(tableName, AccumuloColumnType.VARBINARY, Collections.emptyList());
+ }
+
+ @JsonProperty("tableName")
+ public String getTableName() {
+ return tableName;
+ }
+
+ @JsonProperty("rowKeyType")
+ public AccumuloColumnType getRowKeyType() {
+ return rowKeyType;
+ }
+
+ @JsonProperty("columns")
+ public List getColumns() {
+ return Collections.unmodifiableList(columns);
+ }
+
+ /**
+ * Returns the column definition by Drill column name.
+ */
+ @JsonIgnore
+ public ColumnDef getColumnByName(String name) {
+ return columnsByName.get(name.toLowerCase());
+ }
+
+ /**
+ * Returns the column definition by Accumulo column key (family:qualifier).
+ */
+ @JsonIgnore
+ public ColumnDef getColumnByAccumuloKey(String accumuloKey) {
+ return columnsByAccumuloKey.get(accumuloKey);
+ }
+
+ /**
+ * Returns true if this schema has explicit column definitions.
+ * If false, the schema is dynamic and will discover columns at runtime.
+ */
+ @JsonIgnore
+ public boolean hasExplicitColumns() {
+ return !columns.isEmpty();
+ }
+
+ /**
+ * Returns the number of columns (excluding row_key).
+ */
+ @JsonIgnore
+ public int getColumnCount() {
+ return columns.size();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ TableSchema that = (TableSchema) o;
+ return Objects.equals(tableName, that.tableName)
+ && rowKeyType == that.rowKeyType
+ && Objects.equals(columns, that.columns);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(tableName, rowKeyType, columns);
+ }
+
+ @Override
+ public String toString() {
+ return "TableSchema{" +
+ "tableName='" + tableName + '\'' +
+ ", rowKeyType=" + rowKeyType +
+ ", columnCount=" + columns.size() +
+ '}';
+ }
+
+ /**
+ * Builder for constructing TableSchema instances.
+ */
+ public static class Builder {
+ private final String tableName;
+ private AccumuloColumnType rowKeyType = AccumuloColumnType.VARBINARY;
+ private final List columns = new ArrayList<>();
+
+ private Builder(String tableName) {
+ this.tableName = tableName;
+ }
+
+ public Builder rowKeyType(AccumuloColumnType type) {
+ this.rowKeyType = type;
+ return this;
+ }
+
+ public Builder addColumn(ColumnDef column) {
+ this.columns.add(column);
+ return this;
+ }
+
+ public Builder addColumn(String name, String family, String qualifier, AccumuloColumnType type) {
+ this.columns.add(ColumnDef.create(name, family, qualifier, type));
+ return this;
+ }
+
+ public Builder addVarcharColumn(String name, String family, String qualifier) {
+ this.columns.add(ColumnDef.varchar(name, family, qualifier));
+ return this;
+ }
+
+ public TableSchema build() {
+ return new TableSchema(tableName, rowKeyType, columns);
+ }
+ }
+}
diff --git a/contrib/storage-accumulo/src/main/resources/bootstrap-storage-plugins.json b/contrib/storage-accumulo/src/main/resources/bootstrap-storage-plugins.json
new file mode 100644
index 00000000000..ec1556289d0
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/resources/bootstrap-storage-plugins.json
@@ -0,0 +1,12 @@
+{
+ "storage":{
+ "accumulo" : {
+ "type": "accumulo",
+ "zookeeperQuorum": "localhost:2181",
+ "instanceName": "accumulo",
+ "username": "root",
+ "password": "secret",
+ "enabled": false
+ }
+ }
+}
diff --git a/contrib/storage-accumulo/src/main/resources/drill-module.conf b/contrib/storage-accumulo/src/main/resources/drill-module.conf
new file mode 100644
index 00000000000..2b005a2e33c
--- /dev/null
+++ b/contrib/storage-accumulo/src/main/resources/drill-module.conf
@@ -0,0 +1,36 @@
+#
+# 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.
+#
+
+# This file tells Drill to consider this module when class path scanning.
+# This file can also include any supplementary configuration information.
+# This file is in HOCON format, see https://github.com/typesafehub/config/blob/master/HOCON.md for more information.
+
+drill: {
+ classpath.scanning: {
+ packages += "org.apache.drill.exec.store.accumulo"
+ }
+
+ exec: {
+ accumulo.scan: {
+ # Number of rows to sample for schema inference
+ samplerows.count: 100,
+ # Default batch size for scanner
+ batch.size: 4000
+ }
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloBasicQueryTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloBasicQueryTest.java
new file mode 100644
index 00000000000..50764b851d1
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloBasicQueryTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import org.junit.Test;
+
+/**
+ * Basic query integration tests for Accumulo storage plugin.
+ *
+ * These tests verify basic SELECT queries work correctly against
+ * real Accumulo tables via MiniAccumuloCluster.
+ */
+public class AccumuloBasicQueryTest extends BaseAccumuloTest {
+
+ @Test
+ public void testSelectStarFromTable1() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t";
+ runAccumuloSQLVerifyCount(sql, 10);
+ }
+
+ @Test
+ public void testSelectSpecificColumnsFromTable1() throws Exception {
+ String sql = "SELECT row_key, t.cf.name, t.cf.age FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t";
+ runAccumuloSQLVerifyCount(sql, 10);
+ }
+
+ @Test
+ public void testSelectRowKeyOnly() throws Exception {
+ String sql = "SELECT row_key FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t";
+ runAccumuloSQLVerifyCount(sql, 10);
+ }
+
+ @Test
+ public void testSelectFromUsersTable() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_USERS) + " t";
+ runAccumuloSQLVerifyCount(sql, 20);
+ }
+
+ @Test
+ public void testSelectMultipleColumnFamilies() throws Exception {
+ String sql = "SELECT row_key, t.personal.first_name, t.personal.last_name, t.employment.company " +
+ "FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_USERS) + " t";
+ runAccumuloSQLVerifyCount(sql, 20);
+ }
+
+ @Test
+ public void testSelectFromLargeTable() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_LARGE) + " t";
+ runAccumuloSQLVerifyCount(sql, 1000);
+ }
+
+ @Test
+ public void testCountStar() throws Exception {
+ String sql = "SELECT COUNT(*) FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t";
+ runAccumuloSQLVerifyCount(sql, 1);
+ }
+
+ @Test
+ public void testCountStarUsersTable() throws Exception {
+ String sql = "SELECT COUNT(*) FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_USERS) + " t";
+ runAccumuloSQLVerifyCount(sql, 1);
+ }
+
+ @Test
+ public void testDistinctCompany() throws Exception {
+ String sql = "SELECT DISTINCT t.employment.company FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_USERS) + " t";
+ // Should have 3 distinct companies: Acme Corp, TechCo, DataInc
+ runAccumuloSQLVerifyCount(sql, 3);
+ }
+
+ @Test
+ public void testGroupByCompany() throws Exception {
+ String sql = "SELECT t.employment.company, COUNT(*) as cnt " +
+ "FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_USERS) + " t" +
+ " GROUP BY t.employment.company";
+ runAccumuloSQLVerifyCount(sql, 3);
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloFilterBuilderTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloFilterBuilderTest.java
new file mode 100644
index 00000000000..515c98ad481
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloFilterBuilderTest.java
@@ -0,0 +1,207 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+
+import org.apache.drill.common.FunctionNames;
+import org.apache.drill.common.expression.FunctionCall;
+import org.apache.drill.common.expression.SchemaPath;
+import org.apache.drill.common.expression.ValueExpressions;
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+
+import com.google.common.collect.ImmutableList;
+
+/**
+ * Unit tests for AccumuloCompareFunctionsProcessor and AccumuloFilterBuilder.
+ */
+public class AccumuloFilterBuilderTest extends BaseTest {
+
+ @Test
+ public void testIsCompareFunction() {
+ assertTrue(AccumuloCompareFunctionsProcessor.isCompareFunction(FunctionNames.EQ));
+ assertTrue(AccumuloCompareFunctionsProcessor.isCompareFunction(FunctionNames.NE));
+ assertTrue(AccumuloCompareFunctionsProcessor.isCompareFunction(FunctionNames.LT));
+ assertTrue(AccumuloCompareFunctionsProcessor.isCompareFunction(FunctionNames.LE));
+ assertTrue(AccumuloCompareFunctionsProcessor.isCompareFunction(FunctionNames.GT));
+ assertTrue(AccumuloCompareFunctionsProcessor.isCompareFunction(FunctionNames.GE));
+ assertTrue(AccumuloCompareFunctionsProcessor.isCompareFunction(FunctionNames.IS_NULL));
+ assertTrue(AccumuloCompareFunctionsProcessor.isCompareFunction(FunctionNames.IS_NOT_NULL));
+
+ assertFalse(AccumuloCompareFunctionsProcessor.isCompareFunction("unknown"));
+ assertFalse(AccumuloCompareFunctionsProcessor.isCompareFunction(FunctionNames.AND));
+ assertFalse(AccumuloCompareFunctionsProcessor.isCompareFunction(FunctionNames.OR));
+ }
+
+ @Test
+ public void testProcessEqualFunction() {
+ // row_key = 'test'
+ SchemaPath path = SchemaPath.getSimplePath("row_key");
+ ValueExpressions.QuotedString value = new ValueExpressions.QuotedString("test", 0, null);
+
+ FunctionCall call = new FunctionCall(
+ FunctionNames.EQ,
+ ImmutableList.of(path, value),
+ null);
+
+ AccumuloCompareFunctionsProcessor processor =
+ AccumuloCompareFunctionsProcessor.createFunctionsProcessorInstance(call);
+
+ assertTrue(processor.isSuccess());
+ assertEquals("row_key", processor.getPath().getRootSegmentPath());
+ assertEquals("test", new String(processor.getValue(), StandardCharsets.UTF_8));
+ assertEquals(FunctionNames.EQ, processor.getFunctionName());
+ }
+
+ @Test
+ public void testProcessGreaterThanFunction() {
+ // row_key > 'start'
+ SchemaPath path = SchemaPath.getSimplePath("row_key");
+ ValueExpressions.QuotedString value = new ValueExpressions.QuotedString("start", 0, null);
+
+ FunctionCall call = new FunctionCall(
+ FunctionNames.GT,
+ ImmutableList.of(path, value),
+ null);
+
+ AccumuloCompareFunctionsProcessor processor =
+ AccumuloCompareFunctionsProcessor.createFunctionsProcessorInstance(call);
+
+ assertTrue(processor.isSuccess());
+ assertEquals("row_key", processor.getPath().getRootSegmentPath());
+ assertEquals("start", new String(processor.getValue(), StandardCharsets.UTF_8));
+ assertEquals(FunctionNames.GT, processor.getFunctionName());
+ }
+
+ @Test
+ public void testProcessLessThanFunction() {
+ // row_key < 'end'
+ SchemaPath path = SchemaPath.getSimplePath("row_key");
+ ValueExpressions.QuotedString value = new ValueExpressions.QuotedString("end", 0, null);
+
+ FunctionCall call = new FunctionCall(
+ FunctionNames.LT,
+ ImmutableList.of(path, value),
+ null);
+
+ AccumuloCompareFunctionsProcessor processor =
+ AccumuloCompareFunctionsProcessor.createFunctionsProcessorInstance(call);
+
+ assertTrue(processor.isSuccess());
+ assertEquals(FunctionNames.LT, processor.getFunctionName());
+ }
+
+ @Test
+ public void testProcessSwappedOperands() {
+ // 'test' = row_key (value on left)
+ SchemaPath path = SchemaPath.getSimplePath("row_key");
+ ValueExpressions.QuotedString value = new ValueExpressions.QuotedString("test", 0, null);
+
+ FunctionCall call = new FunctionCall(
+ FunctionNames.EQ,
+ ImmutableList.of(value, path),
+ null);
+
+ AccumuloCompareFunctionsProcessor processor =
+ AccumuloCompareFunctionsProcessor.createFunctionsProcessorInstance(call);
+
+ assertTrue(processor.isSuccess());
+ assertEquals("row_key", processor.getPath().getRootSegmentPath());
+ assertEquals("test", new String(processor.getValue(), StandardCharsets.UTF_8));
+ // Function should remain EQ since it's symmetric
+ assertEquals(FunctionNames.EQ, processor.getFunctionName());
+ }
+
+ @Test
+ public void testProcessSwappedGreaterThan() {
+ // 'value' > row_key should become row_key < 'value'
+ SchemaPath path = SchemaPath.getSimplePath("row_key");
+ ValueExpressions.QuotedString value = new ValueExpressions.QuotedString("value", 0, null);
+
+ FunctionCall call = new FunctionCall(
+ FunctionNames.GT,
+ ImmutableList.of(value, path),
+ null);
+
+ AccumuloCompareFunctionsProcessor processor =
+ AccumuloCompareFunctionsProcessor.createFunctionsProcessorInstance(call);
+
+ assertTrue(processor.isSuccess());
+ assertEquals("row_key", processor.getPath().getRootSegmentPath());
+ // GT transposes to LT when operands are swapped
+ assertEquals(FunctionNames.LT, processor.getFunctionName());
+ }
+
+ @Test
+ public void testProcessIntegerValue() {
+ // row_key = 123
+ SchemaPath path = SchemaPath.getSimplePath("row_key");
+ ValueExpressions.IntExpression value = new ValueExpressions.IntExpression(123, null);
+
+ FunctionCall call = new FunctionCall(
+ FunctionNames.EQ,
+ ImmutableList.of(path, value),
+ null);
+
+ AccumuloCompareFunctionsProcessor processor =
+ AccumuloCompareFunctionsProcessor.createFunctionsProcessorInstance(call);
+
+ assertTrue(processor.isSuccess());
+ assertEquals("123", new String(processor.getValue(), StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void testProcessLongValue() {
+ // row_key = 9999999999
+ SchemaPath path = SchemaPath.getSimplePath("row_key");
+ ValueExpressions.LongExpression value = new ValueExpressions.LongExpression(9999999999L, null);
+
+ FunctionCall call = new FunctionCall(
+ FunctionNames.EQ,
+ ImmutableList.of(path, value),
+ null);
+
+ AccumuloCompareFunctionsProcessor processor =
+ AccumuloCompareFunctionsProcessor.createFunctionsProcessorInstance(call);
+
+ assertTrue(processor.isSuccess());
+ assertEquals("9999999999", new String(processor.getValue(), StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void testCompareTransposeMap() {
+ // Verify the transpose map entries
+ assertEquals(FunctionNames.LE,
+ AccumuloCompareFunctionsProcessor.COMPARE_FUNCTIONS_TRANSPOSE_MAP.get(FunctionNames.GE));
+ assertEquals(FunctionNames.LT,
+ AccumuloCompareFunctionsProcessor.COMPARE_FUNCTIONS_TRANSPOSE_MAP.get(FunctionNames.GT));
+ assertEquals(FunctionNames.GE,
+ AccumuloCompareFunctionsProcessor.COMPARE_FUNCTIONS_TRANSPOSE_MAP.get(FunctionNames.LE));
+ assertEquals(FunctionNames.GT,
+ AccumuloCompareFunctionsProcessor.COMPARE_FUNCTIONS_TRANSPOSE_MAP.get(FunctionNames.LT));
+ assertEquals(FunctionNames.EQ,
+ AccumuloCompareFunctionsProcessor.COMPARE_FUNCTIONS_TRANSPOSE_MAP.get(FunctionNames.EQ));
+ assertEquals(FunctionNames.NE,
+ AccumuloCompareFunctionsProcessor.COMPARE_FUNCTIONS_TRANSPOSE_MAP.get(FunctionNames.NE));
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloIntegrationTestsSuite.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloIntegrationTestsSuite.java
new file mode 100644
index 00000000000..b50ea4141b7
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloIntegrationTestsSuite.java
@@ -0,0 +1,235 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.accumulo.core.client.AccumuloClient;
+import org.apache.accumulo.core.client.security.tokens.PasswordToken;
+import org.apache.accumulo.minicluster.MiniAccumuloCluster;
+import org.apache.accumulo.minicluster.MiniAccumuloConfig;
+import org.apache.drill.test.BaseTest;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Integration test suite for Accumulo storage plugin.
+ *
+ * This suite manages the lifecycle of a MiniAccumuloCluster and runs
+ * all integration tests that require a real Accumulo instance.
+ *
+ * Run with: {@code mvn test -Dtest=AccumuloIntegrationTestsSuite}
+ */
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+ AccumuloBasicQueryTest.class,
+ AccumuloPushdownIntegrationTest.class,
+ AccumuloResultVerificationTest.class,
+ AccumuloSerDeTest.class
+})
+public class AccumuloIntegrationTestsSuite extends BaseTest {
+ private static final Logger logger = LoggerFactory.getLogger(AccumuloIntegrationTestsSuite.class);
+
+ public static final String ROOT_USER = "root";
+ public static final String ROOT_PASSWORD = "drilltest";
+ public static final String INSTANCE_NAME = "drill-accumulo-test";
+
+ private static MiniAccumuloCluster miniCluster;
+ private static AccumuloClient client;
+ private static File tempDir;
+ private static volatile AtomicInteger initCount = new AtomicInteger(0);
+ private static boolean clusterStarted = false;
+ private static boolean tablesCreated = false;
+
+ /**
+ * Whether to manage the MiniAccumuloCluster (start/stop).
+ * Set to false to use an external Accumulo instance.
+ */
+ private static boolean manageMiniCluster = Boolean.parseBoolean(
+ System.getProperty("drill.accumulo.tests.managed", "true"));
+
+ /**
+ * Whether to create test tables.
+ */
+ private static boolean createTables = Boolean.parseBoolean(
+ System.getProperty("drill.accumulo.tests.createTables", "true"));
+
+ @BeforeClass
+ public static void initCluster() throws Exception {
+ if (initCount.get() == 0) {
+ synchronized (AccumuloIntegrationTestsSuite.class) {
+ if (initCount.get() == 0) {
+ if (manageMiniCluster) {
+ startMiniCluster();
+ } else {
+ connectToExternalCluster();
+ }
+
+ if (createTables) {
+ createTestTables();
+ }
+
+ initCount.incrementAndGet();
+ return;
+ }
+ }
+ }
+ initCount.incrementAndGet();
+ }
+
+ @AfterClass
+ public static void tearDownCluster() throws Exception {
+ synchronized (AccumuloIntegrationTestsSuite.class) {
+ if (initCount.decrementAndGet() == 0) {
+ if (createTables && tablesCreated) {
+ cleanupTestTables();
+ }
+
+ if (client != null) {
+ client.close();
+ client = null;
+ }
+
+ if (clusterStarted && miniCluster != null) {
+ logger.info("Stopping MiniAccumuloCluster...");
+ miniCluster.stop();
+ miniCluster = null;
+ logger.info("MiniAccumuloCluster stopped.");
+ }
+
+ // Clean up temp directory
+ if (tempDir != null && tempDir.exists()) {
+ deleteDirectory(tempDir);
+ }
+ }
+ }
+ }
+
+ private static void startMiniCluster() throws Exception {
+ logger.info("Starting MiniAccumuloCluster...");
+
+ // Create temp directory for cluster data
+ tempDir = new File(System.getProperty("accumulo.test.root",
+ System.getProperty("java.io.tmpdir")), "mini-accumulo-" + System.currentTimeMillis());
+ if (!tempDir.mkdirs()) {
+ throw new IOException("Failed to create temp directory: " + tempDir);
+ }
+
+ MiniAccumuloConfig config = new MiniAccumuloConfig(tempDir, ROOT_PASSWORD);
+ config.setInstanceName(INSTANCE_NAME);
+ config.setNumTservers(1);
+
+ miniCluster = new MiniAccumuloCluster(config);
+ miniCluster.start();
+ clusterStarted = true;
+
+ // Create client
+ client = miniCluster.createAccumuloClient(ROOT_USER, new PasswordToken(ROOT_PASSWORD));
+
+ logger.info("MiniAccumuloCluster started. Instance: {}, ZooKeepers: {}",
+ miniCluster.getInstanceName(), miniCluster.getZooKeepers());
+ }
+
+ private static void connectToExternalCluster() throws Exception {
+ String zookeepers = System.getProperty("drill.accumulo.zookeepers", "localhost:2181");
+ String instanceName = System.getProperty("drill.accumulo.instance", "accumulo");
+ String user = System.getProperty("drill.accumulo.user", "root");
+ String password = System.getProperty("drill.accumulo.password", "secret");
+
+ logger.info("Connecting to external Accumulo instance: {} at {}", instanceName, zookeepers);
+
+ client = org.apache.accumulo.core.client.Accumulo.newClient()
+ .to(instanceName, zookeepers)
+ .as(user, password)
+ .build();
+ }
+
+ private static void createTestTables() throws Exception {
+ logger.info("Creating test tables...");
+ AccumuloTestUtils.createAllTestTables(client);
+ tablesCreated = true;
+ logger.info("Test tables created.");
+ }
+
+ private static void cleanupTestTables() {
+ try {
+ logger.info("Cleaning up test tables...");
+ AccumuloTestUtils.deleteAllTestTables(client);
+ logger.info("Test tables cleaned up.");
+ } catch (Exception e) {
+ logger.warn("Error cleaning up test tables", e);
+ }
+ }
+
+ private static void deleteDirectory(File dir) {
+ File[] files = dir.listFiles();
+ if (files != null) {
+ for (File file : files) {
+ if (file.isDirectory()) {
+ deleteDirectory(file);
+ } else {
+ file.delete();
+ }
+ }
+ }
+ dir.delete();
+ }
+
+ // Public accessors for test classes
+
+ public static MiniAccumuloCluster getMiniCluster() {
+ return miniCluster;
+ }
+
+ public static AccumuloClient getClient() {
+ return client;
+ }
+
+ public static String getZooKeepers() {
+ if (miniCluster != null) {
+ return miniCluster.getZooKeepers();
+ }
+ return System.getProperty("drill.accumulo.zookeepers", "localhost:2181");
+ }
+
+ public static String getInstanceName() {
+ if (miniCluster != null) {
+ return miniCluster.getInstanceName();
+ }
+ return System.getProperty("drill.accumulo.instance", "accumulo");
+ }
+
+ public static String getRootUser() {
+ return ROOT_USER;
+ }
+
+ public static String getRootPassword() {
+ return ROOT_PASSWORD;
+ }
+
+ public static void configure(boolean manageMiniCluster, boolean createTables) {
+ AccumuloIntegrationTestsSuite.manageMiniCluster = manageMiniCluster;
+ AccumuloIntegrationTestsSuite.createTables = createTables;
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloKerberosConfigTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloKerberosConfigTest.java
new file mode 100644
index 00000000000..f0178f9bd1d
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloKerberosConfigTest.java
@@ -0,0 +1,263 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.drill.common.logical.StoragePluginConfig.AuthMode;
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Unit tests for Kerberos-specific configuration in AccumuloStoragePluginConfig.
+ */
+public class AccumuloKerberosConfigTest extends BaseTest {
+
+ @Test
+ public void testAuthTypeEnum() {
+ assertEquals(AccumuloAuthType.PASSWORD, AccumuloAuthType.parseOrDefault(null, AccumuloAuthType.PASSWORD));
+ assertEquals(AccumuloAuthType.PASSWORD, AccumuloAuthType.parseOrDefault("", AccumuloAuthType.PASSWORD));
+ assertEquals(AccumuloAuthType.PASSWORD, AccumuloAuthType.parseOrDefault("PASSWORD", AccumuloAuthType.KERBEROS));
+ assertEquals(AccumuloAuthType.KERBEROS, AccumuloAuthType.parseOrDefault("KERBEROS", AccumuloAuthType.PASSWORD));
+ assertEquals(AccumuloAuthType.KERBEROS, AccumuloAuthType.parseOrDefault("kerberos", AccumuloAuthType.PASSWORD));
+ assertEquals(AccumuloAuthType.KERBEROS, AccumuloAuthType.parseOrDefault("Kerberos", AccumuloAuthType.PASSWORD));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testAuthTypeEnumInvalidValue() {
+ AccumuloAuthType.parseOrDefault("INVALID", AccumuloAuthType.PASSWORD);
+ }
+
+ @Test
+ public void testKerberosSharedUserConfig() {
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "zk:2181",
+ "accumulo",
+ null,
+ null,
+ "KERBEROS",
+ "drill/host@REALM",
+ "/etc/security/keytabs/drill.keytab",
+ "auth",
+ "accumulo",
+ false,
+ "SHARED_USER",
+ null,
+ null,
+ null,
+ null
+ );
+
+ assertTrue(config.isKerberosEnabled());
+ assertFalse(config.isUserImpersonationEnabled());
+ assertFalse(config.isUseDelegationTokens());
+ assertEquals(AuthMode.SHARED_USER, config.getAuthMode());
+ }
+
+ @Test
+ public void testKerberosUserImpersonationConfig() {
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "zk:2181",
+ "accumulo",
+ null,
+ null,
+ "KERBEROS",
+ "drill/host@REALM",
+ "/etc/security/keytabs/drill.keytab",
+ "auth-conf",
+ "accumulo",
+ true,
+ "USER_IMPERSONATION",
+ null,
+ null,
+ null,
+ null
+ );
+
+ assertTrue(config.isKerberosEnabled());
+ assertTrue(config.isUserImpersonationEnabled());
+ assertTrue(config.isUseDelegationTokens());
+ assertEquals(AuthMode.USER_IMPERSONATION, config.getAuthMode());
+ assertEquals("auth-conf", config.getSaslQop());
+ }
+
+ @Test
+ public void testSaslQopValues() {
+ // Test auth
+ AccumuloStoragePluginConfig authConfig = new AccumuloStoragePluginConfig(
+ "zk:2181", "accumulo", null, null,
+ "KERBEROS", "drill@REALM", "/keytab", "auth", null, null, null, null, null, null, null
+ );
+ assertEquals("auth", authConfig.getSaslQop());
+
+ // Test auth-int
+ AccumuloStoragePluginConfig authIntConfig = new AccumuloStoragePluginConfig(
+ "zk:2181", "accumulo", null, null,
+ "KERBEROS", "drill@REALM", "/keytab", "auth-int", null, null, null, null, null, null, null
+ );
+ assertEquals("auth-int", authIntConfig.getSaslQop());
+
+ // Test auth-conf
+ AccumuloStoragePluginConfig authConfConfig = new AccumuloStoragePluginConfig(
+ "zk:2181", "accumulo", null, null,
+ "KERBEROS", "drill@REALM", "/keytab", "auth-conf", null, null, null, null, null, null, null
+ );
+ assertEquals("auth-conf", authConfConfig.getSaslQop());
+ }
+
+ @Test
+ public void testBackwardCompatibilityPasswordAuth() {
+ // Old-style configuration without any Kerberos fields should still work
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "localhost:2181",
+ "accumulo",
+ "root",
+ "secret"
+ );
+
+ assertEquals(AccumuloAuthType.PASSWORD, config.getAuthenticationType());
+ assertFalse(config.isKerberosEnabled());
+ assertFalse(config.isUserImpersonationEnabled());
+ assertEquals(AuthMode.SHARED_USER, config.getAuthMode());
+ assertEquals("root", config.getUsername());
+ assertEquals("secret", config.getPassword());
+ }
+
+ @Test
+ public void testJsonSerializationFullKerberosConfig() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "zk1:2181,zk2:2181",
+ "accumulo_prod",
+ null,
+ null,
+ "KERBEROS",
+ "drill/drillserver.example.com@EXAMPLE.COM",
+ "/etc/security/keytabs/drill.service.keytab",
+ "auth-conf",
+ "accumulo",
+ true,
+ "USER_IMPERSONATION",
+ null,
+ "_drill_schema",
+ 30000,
+ 10
+ );
+
+ String json = mapper.writeValueAsString(config);
+ assertNotNull(json);
+
+ // Verify JSON contains expected fields
+ assertTrue(json.contains("zk1:2181,zk2:2181"));
+ assertTrue(json.contains("accumulo_prod"));
+ assertTrue(json.contains("KERBEROS"));
+ assertTrue(json.contains("drill/drillserver.example.com@EXAMPLE.COM"));
+ assertTrue(json.contains("auth-conf"));
+ assertTrue(json.contains("\"useDelegationTokens\":true"));
+
+ // Deserialize and verify
+ AccumuloStoragePluginConfig deserialized = mapper.readValue(json, AccumuloStoragePluginConfig.class);
+ assertEquals(config.getZookeeperQuorum(), deserialized.getZookeeperQuorum());
+ assertEquals(config.getInstanceName(), deserialized.getInstanceName());
+ assertEquals(config.getAuthenticationType(), deserialized.getAuthenticationType());
+ assertEquals(config.getPrincipal(), deserialized.getPrincipal());
+ assertEquals(config.getKeytabPath(), deserialized.getKeytabPath());
+ assertEquals(config.getSaslQop(), deserialized.getSaslQop());
+ assertEquals(config.getAccumuloServicePrimary(), deserialized.getAccumuloServicePrimary());
+ assertEquals(config.isUseDelegationTokens(), deserialized.isUseDelegationTokens());
+ }
+
+ @Test
+ public void testMixedAuthConfigInvalid() {
+ // Config with both password creds and Kerberos settings
+ // This should be allowed for migration scenarios
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "zk:2181",
+ "accumulo",
+ "fallback_user", // password username
+ "fallback_pass", // password
+ "KERBEROS", // but auth type is Kerberos
+ "drill@REALM",
+ "/keytab",
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null
+ );
+
+ // When KERBEROS auth type is set, it should use Kerberos
+ assertEquals(AccumuloAuthType.KERBEROS, config.getAuthenticationType());
+ assertTrue(config.isKerberosEnabled());
+ // But password fields are still accessible if needed
+ assertEquals("fallback_user", config.getUsername());
+ }
+
+ @Test
+ public void testUserTranslationConfig() {
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "zk:2181",
+ "accumulo",
+ "service_user", // service account for fallback
+ "service_pass",
+ "PASSWORD",
+ null,
+ null,
+ null,
+ null,
+ false,
+ "USER_TRANSLATION",
+ null,
+ null,
+ null,
+ null
+ );
+
+ assertFalse(config.isKerberosEnabled());
+ assertFalse(config.isUserImpersonationEnabled());
+ assertTrue(config.isUserTranslationEnabled());
+ assertEquals(AuthMode.USER_TRANSLATION, config.getAuthMode());
+ }
+
+ @Test
+ public void testPrincipalFormats() {
+ // Test simple principal (just user@REALM)
+ AccumuloStoragePluginConfig simpleConfig = new AccumuloStoragePluginConfig(
+ "zk:2181", "accumulo", null, null,
+ "KERBEROS", "drill@EXAMPLE.COM", "/keytab", null, null, null, null, null, null, null, null
+ );
+ assertEquals("drill@EXAMPLE.COM", simpleConfig.getPrincipal());
+
+ // Test service principal (primary/instance@REALM)
+ AccumuloStoragePluginConfig serviceConfig = new AccumuloStoragePluginConfig(
+ "zk:2181", "accumulo", null, null,
+ "KERBEROS", "drill/drillserver.example.com@EXAMPLE.COM", "/keytab",
+ null, null, null, null, null, null, null, null
+ );
+ assertEquals("drill/drillserver.example.com@EXAMPLE.COM", serviceConfig.getPrincipal());
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloLimitPushdownTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloLimitPushdownTest.java
new file mode 100644
index 00000000000..b7c7d1744d9
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloLimitPushdownTest.java
@@ -0,0 +1,252 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.drill.common.expression.SchemaPath;
+import org.apache.drill.exec.physical.base.GroupScan;
+import org.apache.drill.exec.physical.base.ScanStats;
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+/**
+ * Unit tests for limit pushdown in AccumuloGroupScan.
+ */
+public class AccumuloLimitPushdownTest extends BaseTest {
+
+ /**
+ * Creates a mock AccumuloGroupScan for testing.
+ */
+ private AccumuloGroupScan createTestGroupScan() {
+ AccumuloStoragePlugin mockPlugin = Mockito.mock(AccumuloStoragePlugin.class);
+ AccumuloStoragePluginConfig mockConfig = Mockito.mock(AccumuloStoragePluginConfig.class);
+ Mockito.when(mockPlugin.getConfig()).thenReturn(mockConfig);
+
+ AccumuloScanSpec scanSpec = new AccumuloScanSpec("test_table");
+ return new AccumuloGroupScan("testUser", mockPlugin, scanSpec, null, -1);
+ }
+
+ @Test
+ public void testSupportsLimitPushdown() {
+ AccumuloGroupScan scan = createTestGroupScan();
+ assertTrue("AccumuloGroupScan should support limit pushdown", scan.supportsLimitPushdown());
+ }
+
+ @Test
+ public void testApplyLimitReturnsNewScan() {
+ AccumuloGroupScan original = createTestGroupScan();
+
+ GroupScan newScan = original.applyLimit(100);
+
+ assertNotNull("applyLimit should return a new scan", newScan);
+ assertNotSame("applyLimit should return a different instance", original, newScan);
+ assertTrue(newScan instanceof AccumuloGroupScan);
+ }
+
+ @Test
+ public void testApplyLimitSetsMaxRecords() {
+ AccumuloGroupScan original = createTestGroupScan();
+
+ AccumuloGroupScan newScan = (AccumuloGroupScan) original.applyLimit(100);
+
+ assertEquals(100, newScan.getMaxRecords());
+ assertTrue("Limit should be marked as pushed down", newScan.isLimitPushedDown());
+ }
+
+ @Test
+ public void testApplyLimitDoesNotModifyOriginal() {
+ AccumuloGroupScan original = createTestGroupScan();
+ int originalMaxRecords = original.getMaxRecords();
+
+ original.applyLimit(100);
+
+ assertEquals("Original maxRecords should be unchanged", originalMaxRecords, original.getMaxRecords());
+ assertFalse("Original should not have limit pushed down", original.isLimitPushedDown());
+ }
+
+ @Test
+ public void testApplyLimitWithMoreRestrictiveExisting() {
+ AccumuloStoragePlugin mockPlugin = Mockito.mock(AccumuloStoragePlugin.class);
+ AccumuloStoragePluginConfig mockConfig = Mockito.mock(AccumuloStoragePluginConfig.class);
+ Mockito.when(mockPlugin.getConfig()).thenReturn(mockConfig);
+
+ AccumuloScanSpec scanSpec = new AccumuloScanSpec("test_table");
+ // Create scan with limit already set to 50
+ AccumuloGroupScan original = new AccumuloGroupScan("testUser", mockPlugin, scanSpec, null, 50);
+
+ // Try to apply a higher limit
+ GroupScan newScan = original.applyLimit(100);
+
+ // Should return null because existing limit is more restrictive
+ assertNull("Should return null when existing limit is more restrictive", newScan);
+ }
+
+ @Test
+ public void testApplyLimitWithLessRestrictiveExisting() {
+ AccumuloStoragePlugin mockPlugin = Mockito.mock(AccumuloStoragePlugin.class);
+ AccumuloStoragePluginConfig mockConfig = Mockito.mock(AccumuloStoragePluginConfig.class);
+ Mockito.when(mockPlugin.getConfig()).thenReturn(mockConfig);
+
+ AccumuloScanSpec scanSpec = new AccumuloScanSpec("test_table");
+ // Create scan with limit already set to 100
+ AccumuloGroupScan original = new AccumuloGroupScan("testUser", mockPlugin, scanSpec, null, 100);
+
+ // Try to apply a lower limit
+ AccumuloGroupScan newScan = (AccumuloGroupScan) original.applyLimit(50);
+
+ // Should return new scan with lower limit
+ assertNotNull("Should return new scan with more restrictive limit", newScan);
+ assertEquals(50, newScan.getMaxRecords());
+ }
+
+ @Test
+ public void testApplyLimitIsNotReappliedToItsOwnResult() {
+ // The planner rule keeps firing as long as applyLimit hands back a new scan, so
+ // re-applying the same limit must return null or planning never terminates.
+ AccumuloGroupScan original = createTestGroupScan();
+
+ AccumuloGroupScan limited = (AccumuloGroupScan) original.applyLimit(100);
+
+ assertNull("Re-applying the same limit should return null", limited.applyLimit(100));
+ }
+
+ @Test
+ public void testApplyLimitZeroIsNotReapplied() {
+ // LIMIT 0 is the case that regressed: a zero limit must still be recognised as
+ // already pushed down.
+ AccumuloGroupScan original = createTestGroupScan();
+
+ AccumuloGroupScan limited = (AccumuloGroupScan) original.applyLimit(0);
+
+ assertNotNull("A zero limit should still be pushed down", limited);
+ assertEquals(0, limited.getMaxRecords());
+ assertNull("Re-applying a zero limit should return null", limited.applyLimit(0));
+ }
+
+ @Test
+ public void testApplyLimitPreservesOtherPushdowns() {
+ AccumuloGroupScan original = createTestGroupScan();
+ original.setFilterPushedDown(true);
+ original.setProjectionPushedDown(true);
+ original.setSortPushedDown(true);
+
+ AccumuloGroupScan newScan = (AccumuloGroupScan) original.applyLimit(100);
+
+ assertTrue("Filter pushdown should be preserved", newScan.isFilterPushedDown());
+ assertTrue("Projection pushdown should be preserved", newScan.isProjectionPushedDown());
+ assertTrue("Sort pushdown should be preserved", newScan.isSortPushedDown());
+ assertTrue("Limit should be marked as pushed down", newScan.isLimitPushedDown());
+ }
+
+ @Test
+ public void testApplyLimitPreservesScanSpec() {
+ AccumuloGroupScan original = createTestGroupScan();
+
+ AccumuloGroupScan newScan = (AccumuloGroupScan) original.applyLimit(100);
+
+ assertNotNull("ScanSpec should be preserved", newScan.getScanSpec());
+ assertEquals("test_table", newScan.getTableName());
+ }
+
+ @Test
+ public void testApplyLimitPreservesColumns() {
+ AccumuloGroupScan original = createTestGroupScan();
+ List columns = Arrays.asList(
+ SchemaPath.getSimplePath("row_key"),
+ SchemaPath.getSimplePath("col1")
+ );
+ AccumuloGroupScan withColumns = (AccumuloGroupScan) original.clone(columns);
+
+ AccumuloGroupScan newScan = (AccumuloGroupScan) withColumns.applyLimit(100);
+
+ assertEquals(columns, newScan.getColumns());
+ }
+
+ @Test
+ public void testScanStatsWithLimitPushdown() {
+ AccumuloGroupScan scan = createTestGroupScan();
+ ScanStats baseStats = scan.getScanStats();
+
+ AccumuloGroupScan limitedScan = (AccumuloGroupScan) scan.applyLimit(50);
+ ScanStats limitedStats = limitedScan.getScanStats();
+
+ // With a limit of 50, row count should be capped at 50
+ assertTrue("Row count should be reduced with limit pushdown",
+ limitedStats.getRecordCount() <= 50);
+ assertTrue("CPU cost should be lower with limit pushdown",
+ limitedStats.getCpuCost() < baseStats.getCpuCost());
+ }
+
+ @Test
+ public void testScanStatsWithFilterAndLimitPushdown() {
+ AccumuloGroupScan scan = createTestGroupScan();
+ scan.setFilterPushedDown(true);
+
+ AccumuloGroupScan limitedScan = (AccumuloGroupScan) scan.applyLimit(100);
+ limitedScan.setFilterPushedDown(true);
+ ScanStats combinedStats = limitedScan.getScanStats();
+
+ // Combined pushdowns should result in efficient stats
+ assertTrue("Row count should be bounded by limit",
+ combinedStats.getRecordCount() <= 100);
+ }
+
+ @Test
+ public void testToStringIncludesLimitInfo() {
+ AccumuloGroupScan scan = createTestGroupScan();
+ AccumuloGroupScan limitedScan = (AccumuloGroupScan) scan.applyLimit(100);
+
+ String toString = limitedScan.toString();
+ assertTrue("toString should include maxRecords", toString.contains("maxRecords=100"));
+ assertTrue("toString should include limitPushedDown=true", toString.contains("limitPushedDown=true"));
+ }
+
+ @Test
+ public void testSubScanContainsMaxRecords() {
+ AccumuloStoragePlugin mockPlugin = Mockito.mock(AccumuloStoragePlugin.class);
+ AccumuloStoragePluginConfig mockConfig = Mockito.mock(AccumuloStoragePluginConfig.class);
+ Mockito.when(mockPlugin.getConfig()).thenReturn(mockConfig);
+
+ AccumuloScanSpec scanSpec = new AccumuloScanSpec("test_table");
+ AccumuloGroupScan groupScan = new AccumuloGroupScan("testUser", mockPlugin, scanSpec, null, 100);
+ groupScan.setLimitPushedDown(true);
+
+ AccumuloSubScan subScan = groupScan.getSpecificScan(0);
+
+ assertEquals("SubScan should have maxRecords from GroupScan", 100, subScan.getMaxRecords());
+ }
+
+ @Test
+ public void testSubScanWithNoLimit() {
+ AccumuloGroupScan scan = createTestGroupScan();
+
+ AccumuloSubScan subScan = scan.getSpecificScan(0);
+
+ assertEquals("SubScan should have -1 for no limit", -1, subScan.getMaxRecords());
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloProjectionPushdownTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloProjectionPushdownTest.java
new file mode 100644
index 00000000000..3d8c4586e07
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloProjectionPushdownTest.java
@@ -0,0 +1,228 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.drill.common.expression.SchemaPath;
+import org.apache.drill.exec.physical.base.GroupScan;
+import org.apache.drill.exec.physical.base.ScanStats;
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+/**
+ * Unit tests for projection pushdown in AccumuloGroupScan.
+ */
+public class AccumuloProjectionPushdownTest extends BaseTest {
+
+ /**
+ * Creates a mock AccumuloGroupScan for testing.
+ */
+ private AccumuloGroupScan createTestGroupScan() {
+ AccumuloStoragePlugin mockPlugin = Mockito.mock(AccumuloStoragePlugin.class);
+ AccumuloStoragePluginConfig mockConfig = Mockito.mock(AccumuloStoragePluginConfig.class);
+ Mockito.when(mockPlugin.getConfig()).thenReturn(mockConfig);
+
+ AccumuloScanSpec scanSpec = new AccumuloScanSpec("test_table");
+ return new AccumuloGroupScan("testUser", mockPlugin, scanSpec, null, -1);
+ }
+
+ @Test
+ public void testCloneWithAllColumns() {
+ AccumuloGroupScan original = createTestGroupScan();
+
+ // Clone with ALL_COLUMNS should not mark projection as pushed down
+ GroupScan cloned = original.clone(GroupScan.ALL_COLUMNS);
+
+ assertTrue(cloned instanceof AccumuloGroupScan);
+ AccumuloGroupScan accumuloCloned = (AccumuloGroupScan) cloned;
+
+ assertNotSame(original, cloned);
+ assertFalse("Projection should not be marked as pushed down for ALL_COLUMNS",
+ accumuloCloned.isProjectionPushedDown());
+ }
+
+ @Test
+ public void testCloneWithSpecificColumns() {
+ AccumuloGroupScan original = createTestGroupScan();
+
+ List projectedColumns = Arrays.asList(
+ SchemaPath.getSimplePath("row_key"),
+ SchemaPath.getSimplePath("name"),
+ SchemaPath.getSimplePath("age")
+ );
+
+ GroupScan cloned = original.clone(projectedColumns);
+
+ assertTrue(cloned instanceof AccumuloGroupScan);
+ AccumuloGroupScan accumuloCloned = (AccumuloGroupScan) cloned;
+
+ assertNotSame(original, cloned);
+ assertTrue("Projection should be marked as pushed down for specific columns",
+ accumuloCloned.isProjectionPushedDown());
+ assertEquals(projectedColumns, accumuloCloned.getColumns());
+ }
+
+ @Test
+ public void testCloneWithSingleColumn() {
+ AccumuloGroupScan original = createTestGroupScan();
+
+ List projectedColumns = Arrays.asList(
+ SchemaPath.getSimplePath("row_key")
+ );
+
+ GroupScan cloned = original.clone(projectedColumns);
+
+ assertTrue(cloned instanceof AccumuloGroupScan);
+ AccumuloGroupScan accumuloCloned = (AccumuloGroupScan) cloned;
+
+ assertTrue("Projection should be marked as pushed down for single column",
+ accumuloCloned.isProjectionPushedDown());
+ assertEquals(1, accumuloCloned.getColumns().size());
+ }
+
+ @Test
+ public void testClonePreservesOtherPushdownFlags() {
+ AccumuloGroupScan original = createTestGroupScan();
+ original.setFilterPushedDown(true);
+ original.setSortPushedDown(true);
+ original.setLimitPushedDown(true);
+
+ List projectedColumns = Arrays.asList(
+ SchemaPath.getSimplePath("col1"),
+ SchemaPath.getSimplePath("col2")
+ );
+
+ GroupScan cloned = original.clone(projectedColumns);
+ AccumuloGroupScan accumuloCloned = (AccumuloGroupScan) cloned;
+
+ // All flags should be preserved
+ assertTrue("Filter pushdown flag should be preserved", accumuloCloned.isFilterPushedDown());
+ assertTrue("Sort pushdown flag should be preserved", accumuloCloned.isSortPushedDown());
+ assertTrue("Limit pushdown flag should be preserved", accumuloCloned.isLimitPushedDown());
+ assertTrue("Projection should be marked as pushed down", accumuloCloned.isProjectionPushedDown());
+ }
+
+ @Test
+ public void testClonePreservesScanSpec() {
+ AccumuloGroupScan original = createTestGroupScan();
+
+ List projectedColumns = Arrays.asList(
+ SchemaPath.getSimplePath("col1")
+ );
+
+ GroupScan cloned = original.clone(projectedColumns);
+ AccumuloGroupScan accumuloCloned = (AccumuloGroupScan) cloned;
+
+ assertNotNull("ScanSpec should be preserved", accumuloCloned.getScanSpec());
+ assertEquals("test_table", accumuloCloned.getTableName());
+ }
+
+ @Test
+ public void testScanStatsWithProjectionPushdown() {
+ AccumuloGroupScan scan = createTestGroupScan();
+ ScanStats statsWithoutProjection = scan.getScanStats();
+
+ // Clone with specific columns (triggers projection pushdown)
+ List projectedColumns = Arrays.asList(
+ SchemaPath.getSimplePath("col1"),
+ SchemaPath.getSimplePath("col2")
+ );
+ AccumuloGroupScan projectedScan = (AccumuloGroupScan) scan.clone(projectedColumns);
+ ScanStats statsWithProjection = projectedScan.getScanStats();
+
+ // CPU cost should be reduced with projection pushdown
+ assertTrue("CPU cost should be lower with projection pushdown",
+ statsWithProjection.getCpuCost() < statsWithoutProjection.getCpuCost());
+ }
+
+ @Test
+ public void testScanStatsWithFilterAndProjectionPushdown() {
+ AccumuloGroupScan scan = createTestGroupScan();
+ ScanStats baseStats = scan.getScanStats();
+
+ // Apply both filter and projection pushdowns
+ scan.setFilterPushedDown(true);
+ List projectedColumns = Arrays.asList(
+ SchemaPath.getSimplePath("col1")
+ );
+ AccumuloGroupScan projectedScan = (AccumuloGroupScan) scan.clone(projectedColumns);
+ projectedScan.setFilterPushedDown(true);
+ ScanStats combinedStats = projectedScan.getScanStats();
+
+ // Combined pushdowns should result in even lower cost
+ assertTrue("Combined pushdowns should reduce CPU cost significantly",
+ combinedStats.getCpuCost() < baseStats.getCpuCost());
+ assertTrue("Combined pushdowns should reduce row count estimate",
+ combinedStats.getRecordCount() < baseStats.getRecordCount());
+ }
+
+ @Test
+ public void testOriginalNotModifiedByClone() {
+ AccumuloGroupScan original = createTestGroupScan();
+ assertFalse("Original should not have projection pushed down initially",
+ original.isProjectionPushedDown());
+
+ List projectedColumns = Arrays.asList(
+ SchemaPath.getSimplePath("col1")
+ );
+
+ // Clone with projection
+ original.clone(projectedColumns);
+
+ // Original should remain unchanged
+ assertFalse("Original should still not have projection pushed down",
+ original.isProjectionPushedDown());
+ }
+
+ @Test
+ public void testCloneWithNullColumns() {
+ AccumuloGroupScan original = createTestGroupScan();
+
+ GroupScan cloned = original.clone(null);
+
+ assertTrue(cloned instanceof AccumuloGroupScan);
+ AccumuloGroupScan accumuloCloned = (AccumuloGroupScan) cloned;
+
+ // Null columns should be treated as ALL_COLUMNS
+ assertFalse("Projection should not be marked as pushed down for null columns",
+ accumuloCloned.isProjectionPushedDown());
+ }
+
+ @Test
+ public void testToStringIncludesProjectionFlag() {
+ AccumuloGroupScan scan = createTestGroupScan();
+
+ List projectedColumns = Arrays.asList(
+ SchemaPath.getSimplePath("col1")
+ );
+ AccumuloGroupScan projectedScan = (AccumuloGroupScan) scan.clone(projectedColumns);
+
+ String toString = projectedScan.toString();
+ assertTrue("toString should include projectionPushedDown=true",
+ toString.contains("projectionPushedDown=true"));
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloPushdownIntegrationTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloPushdownIntegrationTest.java
new file mode 100644
index 00000000000..a2e6e8244d4
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloPushdownIntegrationTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import org.junit.Test;
+
+/**
+ * Integration tests for Accumulo pushdown capabilities.
+ *
+ * These tests verify that filter, projection, limit, and sort pushdowns
+ * work correctly with real Accumulo tables.
+ */
+public class AccumuloPushdownIntegrationTest extends BaseAccumuloTest {
+
+ // =========================================================================
+ // Filter Pushdown Tests
+ // =========================================================================
+
+ @Test
+ public void testFilterOnRowKeyEquals() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " WHERE row_key = 'row_001'";
+ runAccumuloSQLVerifyCount(sql, 1);
+ }
+
+ @Test
+ public void testFilterOnRowKeyGreaterThan() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " WHERE row_key > 'row_005'";
+ runAccumuloSQLVerifyCount(sql, 5); // row_006 to row_010
+ }
+
+ @Test
+ public void testFilterOnRowKeyLessThan() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " WHERE row_key < 'row_004'";
+ runAccumuloSQLVerifyCount(sql, 3); // row_001 to row_003
+ }
+
+ @Test
+ public void testFilterOnRowKeyRange() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " WHERE row_key >= 'row_003' AND row_key <= 'row_007'";
+ runAccumuloSQLVerifyCount(sql, 5); // row_003 to row_007
+ }
+
+ @Test
+ public void testFilterOnRowKeyRangeLarge() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_LARGE) + " t" +
+ " WHERE row_key >= 'row_0100' AND row_key < 'row_0200'";
+ runAccumuloSQLVerifyCount(sql, 100); // row_0100 to row_0199
+ }
+
+ @Test
+ public void testFilterOnColumnValue() throws Exception {
+ // Note: column value filters may not be pushed down, but should still work
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_USERS) + " t" +
+ " WHERE t.employment.company = 'Acme Corp'";
+ runAccumuloSQLVerifyCount(sql, 7); // 7 users at Acme Corp
+ }
+
+ // =========================================================================
+ // Projection Pushdown Tests
+ // =========================================================================
+
+ @Test
+ public void testProjectionSingleColumn() throws Exception {
+ String sql = "SELECT t.cf.name FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t";
+ runAccumuloSQLVerifyCount(sql, 10);
+ }
+
+ @Test
+ public void testProjectionMultipleColumns() throws Exception {
+ String sql = "SELECT t.cf.name, t.cf.city FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t";
+ runAccumuloSQLVerifyCount(sql, 10);
+ }
+
+ @Test
+ public void testProjectionWithRowKey() throws Exception {
+ String sql = "SELECT row_key, t.personal.first_name FROM " +
+ fullTableName(AccumuloTestUtils.TEST_TABLE_USERS) + " t";
+ runAccumuloSQLVerifyCount(sql, 20);
+ }
+
+ @Test
+ public void testProjectionSingleColumnFamily() throws Exception {
+ String sql = "SELECT personal FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_USERS) + " t";
+ runAccumuloSQLVerifyCount(sql, 20);
+ }
+
+ // =========================================================================
+ // Limit Pushdown Tests
+ // =========================================================================
+
+ @Test
+ public void testLimitSmall() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" + " LIMIT 5";
+ runAccumuloSQLVerifyCount(sql, 5);
+ }
+
+ @Test
+ public void testLimitOnLargeTable() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_LARGE) + " t" + " LIMIT 50";
+ runAccumuloSQLVerifyCount(sql, 50);
+ }
+
+ @Test
+ public void testLimitOne() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" + " LIMIT 1";
+ runAccumuloSQLVerifyCount(sql, 1);
+ }
+
+ @Test
+ public void testLimitLargerThanTable() throws Exception {
+ // Limit larger than table size should return all rows
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" + " LIMIT 100";
+ runAccumuloSQLVerifyCount(sql, 10);
+ }
+
+ // =========================================================================
+ // Sort Pushdown Tests
+ // =========================================================================
+
+ @Test
+ public void testOrderByRowKeyAsc() throws Exception {
+ String sql = "SELECT row_key FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " ORDER BY row_key ASC";
+ runAccumuloSQLVerifyCount(sql, 10);
+ }
+
+ @Test
+ public void testOrderByRowKeyDesc() throws Exception {
+ String sql = "SELECT row_key FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " ORDER BY row_key DESC";
+ runAccumuloSQLVerifyCount(sql, 10);
+ }
+
+ @Test
+ public void testOrderByRowKeyWithLimit() throws Exception {
+ String sql = "SELECT row_key FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_LARGE) + " t" +
+ " ORDER BY row_key ASC LIMIT 10";
+ runAccumuloSQLVerifyCount(sql, 10);
+ }
+
+ // =========================================================================
+ // Combined Pushdown Tests
+ // =========================================================================
+
+ @Test
+ public void testFilterAndProjection() throws Exception {
+ String sql = "SELECT row_key, t.cf.name FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " WHERE row_key > 'row_005'";
+ runAccumuloSQLVerifyCount(sql, 5);
+ }
+
+ @Test
+ public void testFilterAndLimit() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " WHERE row_key > 'row_002' LIMIT 3";
+ runAccumuloSQLVerifyCount(sql, 3);
+ }
+
+ @Test
+ public void testProjectionAndLimit() throws Exception {
+ String sql = "SELECT row_key, t.cf.name FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " LIMIT 5";
+ runAccumuloSQLVerifyCount(sql, 5);
+ }
+
+ @Test
+ public void testFilterProjectionAndLimit() throws Exception {
+ String sql = "SELECT row_key, t.cf.name, t.cf.city FROM " +
+ fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " WHERE row_key >= 'row_003' LIMIT 4";
+ runAccumuloSQLVerifyCount(sql, 4);
+ }
+
+ @Test
+ public void testFilterAndSort() throws Exception {
+ String sql = "SELECT row_key FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " WHERE row_key < 'row_006' ORDER BY row_key ASC";
+ runAccumuloSQLVerifyCount(sql, 5);
+ }
+
+ @Test
+ public void testSortAndLimit() throws Exception {
+ String sql = "SELECT row_key FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " ORDER BY row_key ASC LIMIT 3";
+ runAccumuloSQLVerifyCount(sql, 3);
+ }
+
+ @Test
+ public void testAllPushdownsCombined() throws Exception {
+ String sql = "SELECT row_key, t.cf.name FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " WHERE row_key >= 'row_002' AND row_key <= 'row_009'" +
+ " ORDER BY row_key ASC LIMIT 5";
+ runAccumuloSQLVerifyCount(sql, 5);
+ }
+
+ // =========================================================================
+ // Edge Case Tests
+ // =========================================================================
+
+ @Test
+ public void testFilterNoResults() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " WHERE row_key = 'nonexistent'";
+ runAccumuloSQLVerifyCount(sql, 0);
+ }
+
+ @Test
+ public void testFilterOutOfRange() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" +
+ " WHERE row_key > 'zzz'";
+ runAccumuloSQLVerifyCount(sql, 0);
+ }
+
+ @Test
+ public void testLimitZero() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1) + " t" + " LIMIT 0";
+ runAccumuloSQLVerifyCount(sql, 0);
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloResultVerificationTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloResultVerificationTest.java
new file mode 100644
index 00000000000..b1dd39e7755
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloResultVerificationTest.java
@@ -0,0 +1,347 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Test;
+
+/**
+ * End-to-end tests that verify the actual values Drill returns from Accumulo,
+ * not just the number of rows.
+ *
+ * Accumulo row keys and values are surfaced to Drill as VARBINARY, so the queries
+ * here decode them with {@code CONVERT_FROM(..., 'UTF8')} before comparing against
+ * the data written by {@link AccumuloTestUtils}.
+ */
+public class AccumuloResultVerificationTest extends BaseAccumuloTest {
+
+ // =========================================================================
+ // Full table content
+ // =========================================================================
+
+ @Test
+ public void testAllRowsAndValuesFromTable1() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key") + ", "
+ + utf8("t.cf.name", "name") + ", "
+ + utf8("t.cf.age", "age") + ", "
+ + utf8("t.cf.city", "city")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_1)
+ + " ORDER BY row_key";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .ordered()
+ .baselineColumns("row_key", "name", "age", "city")
+ .baselineValues("row_001", "Alice", "30", "New York")
+ .baselineValues("row_002", "Bob", "25", "Los Angeles")
+ .baselineValues("row_003", "Charlie", "35", "Chicago")
+ .baselineValues("row_004", "Diana", "28", "Houston")
+ .baselineValues("row_005", "Eve", "32", "Phoenix")
+ .baselineValues("row_006", "Frank", "45", "Philadelphia")
+ .baselineValues("row_007", "Grace", "29", "San Antonio")
+ .baselineValues("row_008", "Henry", "38", "San Diego")
+ .baselineValues("row_009", "Ivy", "26", "Dallas")
+ .baselineValues("row_010", "Jack", "41", "San Jose")
+ .go();
+ }
+
+ @Test
+ public void testValuesAcrossMultipleColumnFamilies() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key") + ", "
+ + utf8("t.personal.first_name", "first_name") + ", "
+ + utf8("t.personal.last_name", "last_name") + ", "
+ + utf8("t.contact.email", "email") + ", "
+ + utf8("t.employment.company", "company") + ", "
+ + utf8("t.employment.salary", "salary")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_USERS)
+ + " WHERE row_key IN ('user_001', 'user_014', 'user_020')"
+ + " ORDER BY row_key";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .ordered()
+ .baselineColumns("row_key", "first_name", "last_name", "email", "company", "salary")
+ .baselineValues("user_001", "John", "Doe", "john.doe@email.com", "Acme Corp", "75000")
+ .baselineValues("user_014", "Laura", "White", "laura.w@email.com", "TechCo", "125000")
+ .baselineValues("user_020", "Rachel", "Clark", "rachel.c@email.com", "TechCo", "99000")
+ .go();
+ }
+
+ // =========================================================================
+ // Filter pushdown: verify the correct rows come back, not just the count
+ // =========================================================================
+
+ @Test
+ public void testRowKeyEqualsReturnsMatchingRow() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key") + ", " + utf8("t.cf.name", "name")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_1)
+ + " WHERE row_key = 'row_003'";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .unOrdered()
+ .baselineColumns("row_key", "name")
+ .baselineValues("row_003", "Charlie")
+ .go();
+ }
+
+ @Test
+ public void testRowKeyRangeReturnsExactRows() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key") + ", " + utf8("t.cf.city", "city")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_1)
+ + " WHERE row_key >= 'row_003' AND row_key <= 'row_005'"
+ + " ORDER BY row_key";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .ordered()
+ .baselineColumns("row_key", "city")
+ .baselineValues("row_003", "Chicago")
+ .baselineValues("row_004", "Houston")
+ .baselineValues("row_005", "Phoenix")
+ .go();
+ }
+
+ @Test
+ public void testRowKeyGreaterThanReturnsExactRows() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_1)
+ + " WHERE row_key > 'row_007'"
+ + " ORDER BY row_key";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .ordered()
+ .baselineColumns("row_key")
+ .baselineValues("row_008")
+ .baselineValues("row_009")
+ .baselineValues("row_010")
+ .go();
+ }
+
+ @Test
+ public void testValueFilterReturnsMatchingRows() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_USERS)
+ + " WHERE CONVERT_FROM(t.employment.title, 'UTF8') = 'Director'"
+ + " ORDER BY row_key";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .ordered()
+ .baselineColumns("row_key")
+ .baselineValues("user_004")
+ .baselineValues("user_014")
+ .go();
+ }
+
+ @Test
+ public void testRowKeyRangeOnLargeTableBoundaries() throws Exception {
+ // Exercises a range that spans many rows: verify both endpoints and the count.
+ String sql = "SELECT MIN(rk) AS min_rk, MAX(rk) AS max_rk, COUNT(*) AS cnt FROM ("
+ + " SELECT " + utf8("row_key", "rk")
+ + " " + fromTable(AccumuloTestUtils.TEST_TABLE_LARGE)
+ + " WHERE row_key >= 'row_0100' AND row_key < 'row_0200')";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .unOrdered()
+ .baselineColumns("min_rk", "max_rk", "cnt")
+ .baselineValues("row_0100", "row_0199", 100L)
+ .go();
+ }
+
+ // =========================================================================
+ // Sort and limit
+ // =========================================================================
+
+ @Test
+ public void testOrderByRowKeyDescReturnsRowsInOrder() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_1)
+ + " ORDER BY row_key DESC LIMIT 3";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .ordered()
+ .baselineColumns("row_key")
+ .baselineValues("row_010")
+ .baselineValues("row_009")
+ .baselineValues("row_008")
+ .go();
+ }
+
+ @Test
+ public void testOrderByWithLimitOnLargeTable() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key") + ", " + utf8("t.data.value", "value")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_LARGE)
+ + " ORDER BY row_key LIMIT 3";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .ordered()
+ .baselineColumns("row_key", "value")
+ .baselineValues("row_0001", "1")
+ .baselineValues("row_0002", "2")
+ .baselineValues("row_0003", "3")
+ .go();
+ }
+
+ @Test
+ public void testLimitReturnsDistinctRowsFromTheTable() throws Exception {
+ // A pushed-down limit must return whole, distinct rows rather than repeating or
+ // truncating them, so check the returned keys against the full key set.
+ String sql = "SELECT " + utf8("row_key", "row_key")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_LARGE) + " LIMIT 25";
+
+ List keys = new ArrayList<>();
+ for (List row : runAndReadStrings(sql)) {
+ keys.add(row.get(0));
+ }
+
+ assertEquals(25, keys.size());
+ assertEquals("Limit must not return duplicate rows", 25, keys.stream().distinct().count());
+ for (String key : keys) {
+ assertTrue("Unexpected row key returned: " + key, key.matches("row_0\\d{3}"));
+ int index = Integer.parseInt(key.substring(4));
+ assertTrue("Row key out of range: " + key, index >= 1 && index <= 1000);
+ }
+ }
+
+ // =========================================================================
+ // Aggregates over Accumulo data
+ // =========================================================================
+
+ @Test
+ public void testCountStarReturnsRowCount() throws Exception {
+ String sql = "SELECT COUNT(*) AS cnt FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_LARGE);
+ assertEquals(1000L, queryBuilder().sql(sql).singletonLong());
+ }
+
+ @Test
+ public void testSumOverConvertedValues() throws Exception {
+ // Sum of 1..1000
+ String sql = "SELECT SUM(CAST(CONVERT_FROM(t.data.value, 'UTF8') AS INT)) AS total"
+ + fromTable(AccumuloTestUtils.TEST_TABLE_LARGE);
+ assertEquals(500500L, queryBuilder().sql(sql).singletonLong());
+ }
+
+ @Test
+ public void testGroupByReturnsCorrectCounts() throws Exception {
+ String sql = "SELECT CONVERT_FROM(t.employment.company, 'UTF8') AS company, COUNT(*) AS cnt"
+ + fromTable(AccumuloTestUtils.TEST_TABLE_USERS)
+ + " GROUP BY CONVERT_FROM(t.employment.company, 'UTF8')"
+ + " ORDER BY company";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .ordered()
+ .baselineColumns("company", "cnt")
+ .baselineValues("Acme Corp", 7L)
+ .baselineValues("DataInc", 6L)
+ .baselineValues("TechCo", 7L)
+ .go();
+ }
+
+ // =========================================================================
+ // Sparse rows: missing qualifiers must read back as NULL
+ // =========================================================================
+
+ @Test
+ public void testMissingQualifiersReadBackAsNull() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key") + ", "
+ + utf8("t.cf.a", "a") + ", " + utf8("t.cf.b", "b") + ", " + utf8("t.cf.c", "c")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_SPARSE)
+ + " ORDER BY row_key";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .ordered()
+ .baselineColumns("row_key", "a", "b", "c")
+ .baselineValues("sparse_001", "a1", "b1", "c1")
+ .baselineValues("sparse_002", "a2", null, null)
+ .baselineValues("sparse_003", null, "b3", null)
+ .baselineValues("sparse_004", null, null, "c4")
+ .baselineValues("sparse_005", "a5", null, "c5")
+ .go();
+ }
+
+ @Test
+ public void testSparseTableIsNotNullFilter() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_SPARSE)
+ + " WHERE t.cf.b IS NOT NULL"
+ + " ORDER BY row_key";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .ordered()
+ .baselineColumns("row_key")
+ .baselineValues("sparse_001")
+ .baselineValues("sparse_003")
+ .go();
+ }
+
+ // =========================================================================
+ // Projection: unprojected data must not leak into the result
+ // =========================================================================
+
+ @Test
+ public void testProjectedFamilyContainsAllQualifiers() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key") + ", "
+ + utf8("t.personal.first_name", "first_name") + ", "
+ + utf8("t.personal.last_name", "last_name")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_USERS)
+ + " WHERE row_key = 'user_007'";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .unOrdered()
+ .baselineColumns("row_key", "first_name", "last_name")
+ .baselineValues("user_007", "Edward", "Miller")
+ .go();
+ }
+
+ @Test
+ public void testRowKeyOnlyProjectionReturnsAllKeys() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_1)
+ + " ORDER BY row_key";
+
+ testBuilder()
+ .sqlQuery(sql)
+ .ordered()
+ .baselineColumns("row_key")
+ .baselineValues("row_001")
+ .baselineValues("row_002")
+ .baselineValues("row_003")
+ .baselineValues("row_004")
+ .baselineValues("row_005")
+ .baselineValues("row_006")
+ .baselineValues("row_007")
+ .baselineValues("row_008")
+ .baselineValues("row_009")
+ .baselineValues("row_010")
+ .go();
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloScanSpecTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloScanSpecTest.java
new file mode 100644
index 00000000000..75cf613e529
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloScanSpecTest.java
@@ -0,0 +1,196 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.drill.exec.store.accumulo.AccumuloScanSpec.AccumuloColumnSpec;
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Unit tests for AccumuloScanSpec.
+ */
+public class AccumuloScanSpecTest extends BaseTest {
+
+ @Test
+ public void testSimpleConstruction() {
+ AccumuloScanSpec spec = new AccumuloScanSpec("test_table");
+
+ assertEquals("test_table", spec.getTableName());
+ assertNull(spec.getStartRow());
+ assertNull(spec.getStopRow());
+ assertTrue(spec.isStartRowInclusive());
+ assertFalse(spec.isStopRowInclusive());
+ assertNull(spec.getColumns());
+ assertNull(spec.getFilterExpression());
+ assertNull(spec.getLimit());
+ assertFalse(spec.isUseSortedScanner());
+ }
+
+ @Test
+ public void testFullConstruction() {
+ byte[] startRow = "row_001".getBytes();
+ byte[] stopRow = "row_999".getBytes();
+ List columns = Arrays.asList(
+ new AccumuloColumnSpec("cf1", "name", "name"),
+ new AccumuloColumnSpec("cf1", "age", "age")
+ );
+
+ AccumuloScanSpec spec = new AccumuloScanSpec(
+ "test_table",
+ startRow,
+ stopRow,
+ true,
+ false,
+ columns,
+ "age > 30",
+ 100,
+ true,
+ false
+ );
+
+ assertEquals("test_table", spec.getTableName());
+ assertArrayEquals(startRow, spec.getStartRow());
+ assertArrayEquals(stopRow, spec.getStopRow());
+ assertTrue(spec.isStartRowInclusive());
+ assertFalse(spec.isStopRowInclusive());
+ assertEquals(2, spec.getColumns().size());
+ assertEquals("age > 30", spec.getFilterExpression());
+ assertEquals(Integer.valueOf(100), spec.getLimit());
+ assertTrue(spec.isUseSortedScanner());
+ assertFalse(spec.isSortDescending());
+ }
+
+ @Test
+ public void testHelperMethods() {
+ AccumuloScanSpec specNoExtras = new AccumuloScanSpec("table1");
+ assertFalse(specNoExtras.hasFilter());
+ assertFalse(specNoExtras.hasLimit());
+ assertFalse(specNoExtras.hasRowRange());
+
+ AccumuloScanSpec specWithFilter = new AccumuloScanSpec(
+ "table2", null, null, true, false, null, "col = 'value'", null, false, false);
+ assertTrue(specWithFilter.hasFilter());
+ assertFalse(specWithFilter.hasLimit());
+ assertFalse(specWithFilter.hasRowRange());
+
+ AccumuloScanSpec specWithLimit = new AccumuloScanSpec(
+ "table3", null, null, true, false, null, null, 50, false, false);
+ assertFalse(specWithLimit.hasFilter());
+ assertTrue(specWithLimit.hasLimit());
+ assertFalse(specWithLimit.hasRowRange());
+
+ AccumuloScanSpec specWithRange = new AccumuloScanSpec(
+ "table4", "start".getBytes(), "stop".getBytes(), true, false, null, null, null, false, false);
+ assertFalse(specWithRange.hasFilter());
+ assertFalse(specWithRange.hasLimit());
+ assertTrue(specWithRange.hasRowRange());
+ }
+
+ @Test
+ public void testWithMethods() {
+ AccumuloScanSpec original = new AccumuloScanSpec("test_table");
+
+ AccumuloScanSpec withFilter = original.withFilter("status = 'active'");
+ assertEquals("status = 'active'", withFilter.getFilterExpression());
+ assertNull(original.getFilterExpression()); // Original unchanged
+
+ AccumuloScanSpec withLimit = original.withLimit(100);
+ assertEquals(Integer.valueOf(100), withLimit.getLimit());
+ assertNull(original.getLimit()); // Original unchanged
+
+ AccumuloScanSpec withSorted = original.withSortedScanner(true);
+ assertTrue(withSorted.isUseSortedScanner());
+ assertFalse(original.isUseSortedScanner()); // Original unchanged
+ }
+
+ @Test
+ public void testEquality() {
+ AccumuloScanSpec spec1 = new AccumuloScanSpec("table1");
+ AccumuloScanSpec spec2 = new AccumuloScanSpec("table1");
+ AccumuloScanSpec spec3 = new AccumuloScanSpec("table2");
+
+ assertEquals(spec1, spec2);
+ assertEquals(spec1.hashCode(), spec2.hashCode());
+ assertNotEquals(spec1, spec3);
+ }
+
+ @Test
+ public void testJsonSerialization() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+
+ AccumuloScanSpec spec = new AccumuloScanSpec(
+ "test_table",
+ "start".getBytes(),
+ "stop".getBytes(),
+ true,
+ false,
+ Arrays.asList(new AccumuloColumnSpec("cf1", "col1", "col1")),
+ "col1 > 10",
+ 50,
+ true,
+ true
+ );
+
+ String json = mapper.writeValueAsString(spec);
+ assertNotNull(json);
+ assertTrue(json.contains("test_table"));
+ assertTrue(json.contains("col1 > 10"));
+
+ AccumuloScanSpec deserialized = mapper.readValue(json, AccumuloScanSpec.class);
+ assertEquals(spec.getTableName(), deserialized.getTableName());
+ assertEquals(spec.getFilterExpression(), deserialized.getFilterExpression());
+ assertEquals(spec.getLimit(), deserialized.getLimit());
+ assertEquals(spec.isUseSortedScanner(), deserialized.isUseSortedScanner());
+ assertEquals(spec.isSortDescending(), deserialized.isSortDescending());
+ }
+
+ @Test
+ public void testColumnSpec() {
+ AccumuloColumnSpec colSpec = new AccumuloColumnSpec("family1", "qualifier1", "drill_column");
+
+ assertEquals("family1", colSpec.getColumnFamily());
+ assertEquals("qualifier1", colSpec.getColumnQualifier());
+ assertEquals("drill_column", colSpec.getDrillColumnName());
+
+ AccumuloColumnSpec colSpec2 = new AccumuloColumnSpec("family1", "qualifier1", "drill_column");
+ assertEquals(colSpec, colSpec2);
+ assertEquals(colSpec.hashCode(), colSpec2.hashCode());
+ }
+
+ @Test
+ public void testDigest() {
+ AccumuloScanSpec spec = new AccumuloScanSpec("my_table");
+ String digest = spec.digest();
+
+ assertNotNull(digest);
+ assertTrue(digest.contains("my_table"));
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloSerDeTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloSerDeTest.java
new file mode 100644
index 00000000000..7d5a76f1c71
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloSerDeTest.java
@@ -0,0 +1,247 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.drill.common.expression.SchemaPath;
+import org.apache.drill.exec.ExecConstants;
+import org.apache.drill.exec.physical.base.FragmentLeaf;
+import org.apache.drill.exec.planner.PhysicalPlanReader;
+import org.apache.drill.exec.store.accumulo.AccumuloScanSpec.AccumuloColumnSpec;
+import org.junit.Test;
+
+/**
+ * Serialization/deserialization tests for the Accumulo physical operators.
+ *
+ * Drill serializes physical operators to JSON when it distributes fragments to
+ * other Drillbits, so anything that survives planning must survive a JSON round trip.
+ * These tests cover both the whole-plan path (plan the query, serialize it, then submit
+ * the serialized plan and check the results) and a direct round trip of
+ * {@link AccumuloSubScan} through Drill's {@link PhysicalPlanReader}.
+ */
+public class AccumuloSerDeTest extends BaseAccumuloTest {
+
+ // =========================================================================
+ // Whole-plan round trip: plan -> JSON -> execute -> verify results
+ // =========================================================================
+
+ @Test
+ public void testSerDeSelectStar() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_1);
+ String plan = queryBuilder().sql(sql).explainJson();
+
+ assertTrue("Plan should contain the Accumulo scan", plan.contains("accumulo-scan"));
+ assertEquals(10, queryBuilder().physical(plan).run().recordCount());
+ }
+
+ @Test
+ public void testSerDePreservesValues() throws Exception {
+ String sql = "SELECT " + utf8("row_key", "row_key") + ", " + utf8("t.cf.name", "name")
+ + fromTable(AccumuloTestUtils.TEST_TABLE_1)
+ + " WHERE row_key >= 'row_008' ORDER BY row_key";
+ String plan = queryBuilder().sql(sql).explainJson();
+
+ assertEquals(
+ Arrays.asList(
+ Arrays.asList("row_008", "Henry"),
+ Arrays.asList("row_009", "Ivy"),
+ Arrays.asList("row_010", "Jack")),
+ readStringsFromPlan(plan));
+ }
+
+ @Test
+ public void testSerDePreservesRowRangePushdown() throws Exception {
+ String sql = "SELECT row_key FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_LARGE)
+ + " WHERE row_key >= 'row_0100' AND row_key < 'row_0200'";
+ String plan = queryBuilder().sql(sql).explainJson();
+
+ // The deserialized plan must scan the same range, not the whole table.
+ assertEquals(100, queryBuilder().physical(plan).run().recordCount());
+ }
+
+ @Test
+ public void testSerDePreservesLimitPushdown() throws Exception {
+ String sql = "SELECT * FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_LARGE) + " LIMIT 17";
+ String plan = queryBuilder().sql(sql).explainJson();
+
+ assertEquals(17, queryBuilder().physical(plan).run().recordCount());
+ }
+
+ @Test
+ public void testSerDeAggregate() throws Exception {
+ String sql = "SELECT COUNT(*) FROM " + fullTableName(AccumuloTestUtils.TEST_TABLE_USERS);
+ String plan = queryBuilder().sql(sql).explainJson();
+
+ assertEquals(20L, queryBuilder().physical(plan).singletonLong());
+ }
+
+ @Test
+ public void testFragmentSerDe() throws Exception {
+ // A slice target of 1 forces the plan to be split into fragments, which are
+ // serialized individually before being handed to the executor.
+ client.alterSession(ExecConstants.SLICE_TARGET, 1);
+ try {
+ String sql = "SELECT CONVERT_FROM(t.employment.company, 'UTF8') AS company, COUNT(*) AS cnt"
+ + fromTable(AccumuloTestUtils.TEST_TABLE_USERS)
+ + " GROUP BY CONVERT_FROM(t.employment.company, 'UTF8')";
+ String plan = queryBuilder().sql(sql).explainJson();
+
+ List> rows = readStringsFromPlan(plan);
+ rows.sort(Comparator.comparing(row -> row.get(0)));
+ assertEquals(
+ Arrays.asList(
+ Arrays.asList("Acme Corp", "7"),
+ Arrays.asList("DataInc", "6"),
+ Arrays.asList("TechCo", "7")),
+ rows);
+ } finally {
+ client.resetSession(ExecConstants.SLICE_TARGET);
+ }
+ }
+
+ // =========================================================================
+ // Direct operator round trip
+ // =========================================================================
+
+ @Test
+ public void testSubScanSerDeRoundTrip() throws Exception {
+ AccumuloScanSpec scanSpec = new AccumuloScanSpec(
+ AccumuloTestUtils.TEST_TABLE_1,
+ "row_002".getBytes(StandardCharsets.UTF_8),
+ "row_008".getBytes(StandardCharsets.UTF_8),
+ true,
+ false,
+ Collections.singletonList(new AccumuloColumnSpec("cf", "name", "cf.name")),
+ "cf.age > 30",
+ 25,
+ true,
+ true);
+
+ List columns = Arrays.asList(
+ SchemaPath.getSimplePath("row_key"),
+ SchemaPath.getCompoundPath("cf", "name"));
+
+ AccumuloSubScan subScan = new AccumuloSubScan(
+ "testUser", storagePlugin, scanSpec, columns, 25, null);
+
+ AccumuloSubScan deserialized = roundTrip(subScan);
+
+ assertEquals("testUser", deserialized.getUserName());
+ assertEquals(25, deserialized.getMaxRecords());
+ assertEquals(columns, deserialized.getColumns());
+ assertEquals(storagePluginConfig, deserialized.getStoragePluginConfig());
+ assertNull(deserialized.getDelegationTokenInfo());
+
+ AccumuloScanSpec deserializedSpec = deserialized.getScanSpec();
+ assertEquals(scanSpec, deserializedSpec);
+ assertEquals(AccumuloTestUtils.TEST_TABLE_1, deserializedSpec.getTableName());
+ assertArrayEquals("row_002".getBytes(StandardCharsets.UTF_8), deserializedSpec.getStartRow());
+ assertArrayEquals("row_008".getBytes(StandardCharsets.UTF_8), deserializedSpec.getStopRow());
+ assertTrue(deserializedSpec.isStartRowInclusive());
+ assertFalse(deserializedSpec.isStopRowInclusive());
+ assertEquals("cf.age > 30", deserializedSpec.getFilterExpression());
+ assertEquals(Integer.valueOf(25), deserializedSpec.getLimit());
+ assertTrue(deserializedSpec.isUseSortedScanner());
+ assertTrue(deserializedSpec.isSortDescending());
+ assertEquals(1, deserializedSpec.getColumns().size());
+ assertEquals("cf", deserializedSpec.getColumns().get(0).getColumnFamily());
+ assertEquals("name", deserializedSpec.getColumns().get(0).getColumnQualifier());
+ }
+
+ @Test
+ public void testSubScanSerDeRoundTripWithDefaults() throws Exception {
+ AccumuloSubScan subScan = new AccumuloSubScan(
+ "testUser",
+ storagePlugin,
+ new AccumuloScanSpec(AccumuloTestUtils.TEST_TABLE_USERS),
+ null);
+
+ AccumuloSubScan deserialized = roundTrip(subScan);
+
+ assertEquals(-1, deserialized.getMaxRecords());
+ assertNull(deserialized.getColumns());
+ assertNull(deserialized.getDelegationTokenInfo());
+ assertEquals(AccumuloTestUtils.TEST_TABLE_USERS, deserialized.getScanSpec().getTableName());
+ assertNull(deserialized.getScanSpec().getStartRow());
+ assertNull(deserialized.getScanSpec().getStopRow());
+ }
+
+ @Test
+ public void testSubScanSerDeRoundTripWithDelegationToken() throws Exception {
+ String serializedToken = Base64.getEncoder()
+ .encodeToString("fake-token-bytes".getBytes(StandardCharsets.UTF_8));
+ long creationTime = System.currentTimeMillis();
+ DelegationTokenInfo tokenInfo = new DelegationTokenInfo(
+ "alice",
+ serializedToken,
+ "org.apache.accumulo.core.client.security.tokens.DelegationTokenImpl",
+ creationTime);
+
+ AccumuloSubScan subScan = new AccumuloSubScan(
+ "alice",
+ storagePlugin,
+ new AccumuloScanSpec(AccumuloTestUtils.TEST_TABLE_1),
+ null,
+ -1,
+ tokenInfo);
+
+ AccumuloSubScan deserialized = roundTrip(subScan);
+
+ assertTrue(deserialized.hasDelegationToken());
+ DelegationTokenInfo deserializedToken = deserialized.getDelegationTokenInfo();
+ assertNotNull(deserializedToken);
+ assertEquals("alice", deserializedToken.getUserName());
+ assertEquals(serializedToken, deserializedToken.getSerializedToken());
+ assertEquals("org.apache.accumulo.core.client.security.tokens.DelegationTokenImpl",
+ deserializedToken.getTokenClassName());
+ assertEquals(creationTime, deserializedToken.getCreationTime());
+ }
+
+ /**
+ * Submits a serialized physical plan and returns the results as rows of strings.
+ */
+ private List> readStringsFromPlan(String plan) throws Exception {
+ return readStrings(queryBuilder().physical(plan).rowSetIterator());
+ }
+
+ /**
+ * Writes the operator to JSON with Drill's physical plan mapper and reads it back,
+ * which is exactly what happens when a fragment is shipped to another Drillbit.
+ */
+ private AccumuloSubScan roundTrip(AccumuloSubScan subScan) throws Exception {
+ PhysicalPlanReader reader = cluster.drillbit().getContext().getPlanReader();
+ String json = reader.writeJson(subScan);
+ FragmentLeaf leaf = reader.readFragmentLeaf(json);
+ assertTrue("Expected an AccumuloSubScan, got " + leaf.getClass().getName(),
+ leaf instanceof AccumuloSubScan);
+ return (AccumuloSubScan) leaf;
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloSortPushdownTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloSortPushdownTest.java
new file mode 100644
index 00000000000..5e41b67aa0c
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloSortPushdownTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.drill.exec.physical.base.ScanStats;
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+/**
+ * Unit tests for sort pushdown in AccumuloGroupScan.
+ */
+public class AccumuloSortPushdownTest extends BaseTest {
+
+ /**
+ * Creates a mock AccumuloGroupScan for testing.
+ */
+ private AccumuloGroupScan createTestGroupScan() {
+ AccumuloStoragePlugin mockPlugin = Mockito.mock(AccumuloStoragePlugin.class);
+ AccumuloStoragePluginConfig mockConfig = Mockito.mock(AccumuloStoragePluginConfig.class);
+ Mockito.when(mockPlugin.getConfig()).thenReturn(mockConfig);
+
+ AccumuloScanSpec scanSpec = new AccumuloScanSpec("test_table");
+ return new AccumuloGroupScan("testUser", mockPlugin, scanSpec, null, -1);
+ }
+
+ @Test
+ public void testScanSpecWithSortOrderAscending() {
+ AccumuloScanSpec original = new AccumuloScanSpec("test_table");
+ assertFalse("Default should not use sorted scanner", original.isUseSortedScanner());
+ assertFalse("Default should not be descending", original.isSortDescending());
+
+ AccumuloScanSpec withSort = original.withSortOrder(false);
+
+ assertTrue("Should use sorted scanner after withSortOrder", withSort.isUseSortedScanner());
+ assertFalse("Should be ascending", withSort.isSortDescending());
+ assertEquals("test_table", withSort.getTableName());
+ }
+
+ @Test
+ public void testScanSpecWithSortOrderDescending() {
+ AccumuloScanSpec original = new AccumuloScanSpec("test_table");
+
+ AccumuloScanSpec withSort = original.withSortOrder(true);
+
+ assertTrue("Should use sorted scanner after withSortOrder", withSort.isUseSortedScanner());
+ assertTrue("Should be descending", withSort.isSortDescending());
+ assertEquals("test_table", withSort.getTableName());
+ }
+
+ @Test
+ public void testGroupScanSortPushedDown() {
+ AccumuloGroupScan scan = createTestGroupScan();
+ assertFalse("Sort should not be pushed down initially", scan.isSortPushedDown());
+
+ scan.setSortPushedDown(true);
+ assertTrue("Sort should be pushed down after setting", scan.isSortPushedDown());
+ }
+
+ @Test
+ public void testCloneWithNewScanSpecPreservesSortFlag() {
+ AccumuloGroupScan original = createTestGroupScan();
+ original.setSortPushedDown(true);
+
+ AccumuloScanSpec newSpec = original.getScanSpec().withSortOrder(false);
+ AccumuloGroupScan cloned = original.cloneWithNewScanSpec(newSpec);
+
+ assertTrue("Sort pushdown flag should be preserved", cloned.isSortPushedDown());
+ assertTrue("New scan spec should use sorted scanner", cloned.getScanSpec().isUseSortedScanner());
+ }
+
+ @Test
+ public void testScanStatsWithSortPushdown() {
+ AccumuloGroupScan scan = createTestGroupScan();
+ ScanStats baseStats = scan.getScanStats();
+
+ scan.setSortPushedDown(true);
+ ScanStats sortedStats = scan.getScanStats();
+
+ // Sort pushdown has a slight cost penalty because we use Scanner instead of BatchScanner
+ assertTrue("Sort pushdown should slightly increase CPU cost due to Scanner vs BatchScanner",
+ sortedStats.getCpuCost() >= baseStats.getCpuCost());
+ }
+
+ @Test
+ public void testToStringIncludesSortFlag() {
+ AccumuloGroupScan scan = createTestGroupScan();
+ scan.setSortPushedDown(true);
+
+ String toString = scan.toString();
+ assertTrue("toString should include sortPushedDown=true",
+ toString.contains("sortPushedDown=true"));
+ }
+
+ @Test
+ public void testScanSpecToStringIncludesSortInfo() {
+ AccumuloScanSpec spec = new AccumuloScanSpec("test_table").withSortOrder(true);
+
+ String toString = spec.toString();
+ assertTrue("toString should include useSortedScanner=true",
+ toString.contains("useSortedScanner=true"));
+ assertTrue("toString should include sortDescending=true",
+ toString.contains("sortDescending=true"));
+ }
+
+ @Test
+ public void testScanSpecEquality() {
+ AccumuloScanSpec spec1 = new AccumuloScanSpec("test_table").withSortOrder(false);
+ AccumuloScanSpec spec2 = new AccumuloScanSpec("test_table").withSortOrder(false);
+ AccumuloScanSpec spec3 = new AccumuloScanSpec("test_table").withSortOrder(true);
+
+ assertEquals("Same sort order specs should be equal", spec1, spec2);
+ assertFalse("Different sort order specs should not be equal", spec1.equals(spec3));
+ }
+
+ @Test
+ public void testScanSpecHashCode() {
+ AccumuloScanSpec spec1 = new AccumuloScanSpec("test_table").withSortOrder(false);
+ AccumuloScanSpec spec2 = new AccumuloScanSpec("test_table").withSortOrder(false);
+ AccumuloScanSpec spec3 = new AccumuloScanSpec("test_table").withSortOrder(true);
+
+ assertEquals("Same sort order specs should have equal hash codes",
+ spec1.hashCode(), spec2.hashCode());
+ // Different specs may have different hash codes (not guaranteed, but likely)
+ assertFalse("Different sort order specs likely have different hash codes",
+ spec1.hashCode() == spec3.hashCode());
+ }
+
+ @Test
+ public void testScanSpecWithMultiplePushdowns() {
+ AccumuloScanSpec original = new AccumuloScanSpec("test_table");
+
+ AccumuloScanSpec withAll = original
+ .withFilter("row_key > 'a'")
+ .withLimit(100)
+ .withSortOrder(true);
+
+ assertEquals("test_table", withAll.getTableName());
+ assertEquals("row_key > 'a'", withAll.getFilterExpression());
+ assertEquals(Integer.valueOf(100), withAll.getLimit());
+ assertTrue(withAll.isUseSortedScanner());
+ assertTrue(withAll.isSortDescending());
+ }
+
+ @Test
+ public void testGroupScanCopyPreservesAllFlags() {
+ AccumuloGroupScan original = createTestGroupScan();
+ original.setFilterPushedDown(true);
+ original.setProjectionPushedDown(true);
+ original.setLimitPushedDown(true);
+ original.setSortPushedDown(true);
+
+ AccumuloScanSpec newSpec = original.getScanSpec().withSortOrder(false);
+ AccumuloGroupScan cloned = original.cloneWithNewScanSpec(newSpec);
+
+ assertTrue("Filter pushdown should be preserved", cloned.isFilterPushedDown());
+ assertTrue("Projection pushdown should be preserved", cloned.isProjectionPushedDown());
+ assertTrue("Limit pushdown should be preserved", cloned.isLimitPushedDown());
+ assertTrue("Sort pushdown should be preserved", cloned.isSortPushedDown());
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloStoragePluginConfigTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloStoragePluginConfigTest.java
new file mode 100644
index 00000000000..3e7055e1852
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloStoragePluginConfigTest.java
@@ -0,0 +1,350 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.drill.common.logical.StoragePluginConfig;
+import org.apache.drill.common.logical.StoragePluginConfig.AuthMode;
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Unit tests for AccumuloStoragePluginConfig.
+ */
+public class AccumuloStoragePluginConfigTest extends BaseTest {
+
+ @Test
+ public void testConfigCreation() {
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "localhost:2181",
+ "accumulo",
+ "root",
+ "secret",
+ null, null, null, null, null, null, null, null, null, null, null
+ );
+
+ assertEquals("localhost:2181", config.getZookeeperQuorum());
+ assertEquals("accumulo", config.getInstanceName());
+ assertEquals("root", config.getUsername());
+ assertEquals("secret", config.getPassword());
+ assertEquals("_drill_schema", config.getSchemaMetadataTable());
+ assertEquals(Integer.valueOf(30000), config.getClientTimeout());
+ assertEquals(Integer.valueOf(10), config.getBatchScannerThreads());
+ }
+
+ @Test
+ public void testConfigWithCustomValues() {
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "zk1:2181,zk2:2181,zk3:2181",
+ "myinstance",
+ "admin",
+ "password123",
+ null, null, null, null, null, null, null, null,
+ "my_schema_table",
+ 60000,
+ 20
+ );
+
+ assertEquals("zk1:2181,zk2:2181,zk3:2181", config.getZookeeperQuorum());
+ assertEquals("myinstance", config.getInstanceName());
+ assertEquals("admin", config.getUsername());
+ assertEquals("password123", config.getPassword());
+ assertEquals("my_schema_table", config.getSchemaMetadataTable());
+ assertEquals(Integer.valueOf(60000), config.getClientTimeout());
+ assertEquals(Integer.valueOf(20), config.getBatchScannerThreads());
+ }
+
+ @Test
+ public void testSimplifiedConstructorBackwardCompatibility() {
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "localhost:2181",
+ "accumulo",
+ "root",
+ "secret"
+ );
+
+ assertEquals("localhost:2181", config.getZookeeperQuorum());
+ assertEquals("accumulo", config.getInstanceName());
+ assertEquals("root", config.getUsername());
+ assertEquals("secret", config.getPassword());
+ assertEquals(AccumuloAuthType.PASSWORD, config.getAuthenticationType());
+ assertEquals(AuthMode.SHARED_USER, config.getAuthMode());
+ assertFalse(config.isUseDelegationTokens());
+ }
+
+ @Test
+ public void testKerberosConfigCreation() {
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "zk1:2181,zk2:2181",
+ "accumulo",
+ null, // no username for Kerberos
+ null, // no password for Kerberos
+ "KERBEROS",
+ "drill/hostname@REALM",
+ "/etc/security/keytabs/drill.keytab",
+ "auth-conf",
+ "accumulo",
+ true, // useDelegationTokens
+ "USER_IMPERSONATION",
+ null,
+ null,
+ null,
+ null
+ );
+
+ assertEquals("zk1:2181,zk2:2181", config.getZookeeperQuorum());
+ assertEquals("accumulo", config.getInstanceName());
+ assertEquals(AccumuloAuthType.KERBEROS, config.getAuthenticationType());
+ assertEquals("drill/hostname@REALM", config.getPrincipal());
+ assertEquals("/etc/security/keytabs/drill.keytab", config.getKeytabPath());
+ assertEquals("auth-conf", config.getSaslQop());
+ assertEquals("accumulo", config.getAccumuloServicePrimary());
+ assertTrue(config.isUseDelegationTokens());
+ assertEquals(AuthMode.USER_IMPERSONATION, config.getAuthMode());
+ assertTrue(config.isKerberosEnabled());
+ assertTrue(config.isUserImpersonationEnabled());
+ }
+
+ @Test
+ public void testKerberosDefaults() {
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "localhost:2181",
+ "accumulo",
+ null,
+ null,
+ "KERBEROS",
+ "drill/host@REALM",
+ "/path/to/keytab",
+ null, // saslQop - should default to "auth"
+ null, // accumuloServicePrimary - should default to "accumulo"
+ null, // useDelegationTokens - should default to false
+ null,
+ null,
+ null,
+ null,
+ null
+ );
+
+ assertEquals(AccumuloAuthType.KERBEROS, config.getAuthenticationType());
+ assertEquals("auth", config.getSaslQop()); // default
+ assertEquals("accumulo", config.getAccumuloServicePrimary()); // default
+ assertFalse(config.isUseDelegationTokens()); // default
+ assertEquals(AuthMode.SHARED_USER, config.getAuthMode()); // default
+ }
+
+ @Test
+ public void testAuthTypeParsingCaseInsensitive() {
+ // Test lowercase
+ AccumuloStoragePluginConfig config1 = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", null, null,
+ "kerberos", "p", "k", null, null, null, null, null, null, null, null
+ );
+ assertEquals(AccumuloAuthType.KERBEROS, config1.getAuthenticationType());
+
+ // Test mixed case
+ AccumuloStoragePluginConfig config2 = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", null, null,
+ "Kerberos", "p", "k", null, null, null, null, null, null, null, null
+ );
+ assertEquals(AccumuloAuthType.KERBEROS, config2.getAuthenticationType());
+
+ // Test password (default)
+ AccumuloStoragePluginConfig config3 = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", "user", "pass",
+ "password", null, null, null, null, null, null, null, null, null, null
+ );
+ assertEquals(AccumuloAuthType.PASSWORD, config3.getAuthenticationType());
+ }
+
+ @Test
+ public void testConfigEquality() {
+ AccumuloStoragePluginConfig config1 = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", "root", "secret",
+ null, null, null, null, null, null, null, null, null, null, null
+ );
+
+ AccumuloStoragePluginConfig config2 = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", "root", "secret",
+ null, null, null, null, null, null, null, null, null, null, null
+ );
+
+ AccumuloStoragePluginConfig config3 = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", "different_user", "secret",
+ null, null, null, null, null, null, null, null, null, null, null
+ );
+
+ assertEquals(config1, config2);
+ assertEquals(config1.hashCode(), config2.hashCode());
+ assertNotEquals(config1, config3);
+ }
+
+ @Test
+ public void testKerberosConfigEquality() {
+ AccumuloStoragePluginConfig config1 = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", null, null,
+ "KERBEROS", "drill@REALM", "/keytab", "auth", "accumulo", true,
+ "USER_IMPERSONATION", null, null, null, null
+ );
+
+ AccumuloStoragePluginConfig config2 = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", null, null,
+ "KERBEROS", "drill@REALM", "/keytab", "auth", "accumulo", true,
+ "USER_IMPERSONATION", null, null, null, null
+ );
+
+ AccumuloStoragePluginConfig config3 = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", null, null,
+ "KERBEROS", "different@REALM", "/keytab", "auth", "accumulo", true,
+ "USER_IMPERSONATION", null, null, null, null
+ );
+
+ assertEquals(config1, config2);
+ assertEquals(config1.hashCode(), config2.hashCode());
+ assertNotEquals(config1, config3);
+ }
+
+ @Test
+ public void testJsonSerializationPasswordAuth() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", "root", "secret",
+ null, null, null, null, null, null, null, null,
+ "my_schema", 45000, 15
+ );
+
+ String json = mapper.writeValueAsString(config);
+ assertNotNull(json);
+ assertTrue(json.contains("localhost:2181"));
+ assertTrue(json.contains("accumulo"));
+ assertTrue(json.contains("root"));
+ assertTrue(json.contains("my_schema"));
+ assertTrue(json.contains("PASSWORD") || json.contains("\"authenticationType\":null"));
+
+ // Deserialize back
+ AccumuloStoragePluginConfig deserialized = mapper.readValue(json, AccumuloStoragePluginConfig.class);
+ assertEquals(config.getZookeeperQuorum(), deserialized.getZookeeperQuorum());
+ assertEquals(config.getInstanceName(), deserialized.getInstanceName());
+ assertEquals(config.getUsername(), deserialized.getUsername());
+ assertEquals(config.getPassword(), deserialized.getPassword());
+ }
+
+ @Test
+ public void testJsonSerializationKerberosAuth() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "zk:2181", "accumulo", null, null,
+ "KERBEROS", "drill/host@REALM", "/etc/keytab", "auth-conf", "accumulo", true,
+ "USER_IMPERSONATION", null, null, null, null
+ );
+
+ String json = mapper.writeValueAsString(config);
+ assertNotNull(json);
+ assertTrue(json.contains("KERBEROS"));
+ assertTrue(json.contains("drill/host@REALM"));
+ assertTrue(json.contains("/etc/keytab"));
+ assertTrue(json.contains("auth-conf"));
+ assertTrue(json.contains("useDelegationTokens"));
+
+ // Deserialize back
+ AccumuloStoragePluginConfig deserialized = mapper.readValue(json, AccumuloStoragePluginConfig.class);
+ assertEquals(AccumuloAuthType.KERBEROS, deserialized.getAuthenticationType());
+ assertEquals("drill/host@REALM", deserialized.getPrincipal());
+ assertEquals("/etc/keytab", deserialized.getKeytabPath());
+ assertEquals("auth-conf", deserialized.getSaslQop());
+ assertTrue(deserialized.isUseDelegationTokens());
+ }
+
+ @Test
+ public void testToStringMasksPassword() {
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", "root", "supersecret",
+ null, null, null, null, null, null, null, null, null, null, null
+ );
+
+ String toString = config.toString();
+ assertTrue(toString.contains("localhost:2181"));
+ assertTrue(toString.contains("accumulo"));
+ assertTrue(toString.contains("root"));
+ // Password should be masked
+ assertTrue(!toString.contains("supersecret") || toString.contains("*"));
+ }
+
+ @Test
+ public void testToStringMasksKeytabPath() {
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", null, null,
+ "KERBEROS", "drill@REALM", "/secure/path/to/keytab", null, null, null,
+ null, null, null, null, null
+ );
+
+ String toString = config.toString();
+ assertTrue(toString.contains("drill@REALM"));
+ // Keytab path should be masked for security
+ assertTrue(!toString.contains("/secure/path/to/keytab") || toString.contains("*"));
+ }
+
+ @Test
+ public void testExtendsStoragePluginConfig() {
+ AccumuloStoragePluginConfig config = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", "root", "secret",
+ null, null, null, null, null, null, null, null, null, null, null
+ );
+
+ assertTrue(config instanceof StoragePluginConfig);
+ }
+
+ @Test
+ public void testIsKerberosEnabled() {
+ AccumuloStoragePluginConfig passwordConfig = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", "root", "secret",
+ "PASSWORD", null, null, null, null, null, null, null, null, null, null
+ );
+ assertFalse(passwordConfig.isKerberosEnabled());
+
+ AccumuloStoragePluginConfig kerberosConfig = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", null, null,
+ "KERBEROS", "drill@REALM", "/keytab", null, null, null, null, null, null, null, null
+ );
+ assertTrue(kerberosConfig.isKerberosEnabled());
+ }
+
+ @Test
+ public void testIsUserImpersonationEnabled() {
+ AccumuloStoragePluginConfig sharedUserConfig = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", "root", "secret",
+ null, null, null, null, null, null, "SHARED_USER", null, null, null, null
+ );
+ assertFalse(sharedUserConfig.isUserImpersonationEnabled());
+
+ AccumuloStoragePluginConfig impersonationConfig = new AccumuloStoragePluginConfig(
+ "localhost:2181", "accumulo", null, null,
+ "KERBEROS", "drill@REALM", "/keytab", null, null, true,
+ "USER_IMPERSONATION", null, null, null, null
+ );
+ assertTrue(impersonationConfig.isUserImpersonationEnabled());
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloTestUtils.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloTestUtils.java
new file mode 100644
index 00000000000..5d0e2077808
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloTestUtils.java
@@ -0,0 +1,270 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import java.nio.charset.StandardCharsets;
+
+import org.apache.accumulo.core.client.AccumuloClient;
+import org.apache.accumulo.core.client.AccumuloException;
+import org.apache.accumulo.core.client.AccumuloSecurityException;
+import org.apache.accumulo.core.client.BatchWriter;
+import org.apache.accumulo.core.client.BatchWriterConfig;
+import org.apache.accumulo.core.client.TableExistsException;
+import org.apache.accumulo.core.client.TableNotFoundException;
+import org.apache.accumulo.core.data.Mutation;
+import org.apache.accumulo.core.data.Value;
+import org.apache.hadoop.io.Text;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility class for creating test tables and data in Accumulo.
+ */
+public class AccumuloTestUtils {
+ private static final Logger logger = LoggerFactory.getLogger(AccumuloTestUtils.class);
+
+ public static final String TEST_TABLE_1 = "drill_test_table_1";
+ public static final String TEST_TABLE_USERS = "drill_test_users";
+ public static final String TEST_TABLE_LARGE = "drill_test_large";
+ public static final String TEST_TABLE_SPARSE = "drill_test_sparse";
+
+ /**
+ * Creates a simple test table with basic key-value data.
+ *
+ * Table structure:
+ *
+ * - row_key: row_001 to row_010
+ * - cf:name - string names
+ * - cf:age - integer ages as strings
+ * - cf:city - city names
+ *
+ */
+ public static void createTestTable1(AccumuloClient client) throws Exception {
+ String tableName = TEST_TABLE_1;
+ createTableIfNotExists(client, tableName);
+
+ try (BatchWriter writer = client.createBatchWriter(tableName, new BatchWriterConfig())) {
+ String[][] data = {
+ {"row_001", "Alice", "30", "New York"},
+ {"row_002", "Bob", "25", "Los Angeles"},
+ {"row_003", "Charlie", "35", "Chicago"},
+ {"row_004", "Diana", "28", "Houston"},
+ {"row_005", "Eve", "32", "Phoenix"},
+ {"row_006", "Frank", "45", "Philadelphia"},
+ {"row_007", "Grace", "29", "San Antonio"},
+ {"row_008", "Henry", "38", "San Diego"},
+ {"row_009", "Ivy", "26", "Dallas"},
+ {"row_010", "Jack", "41", "San Jose"}
+ };
+
+ for (String[] row : data) {
+ Mutation m = new Mutation(new Text(row[0]));
+ m.put(new Text("cf"), new Text("name"), new Value(row[1].getBytes(StandardCharsets.UTF_8)));
+ m.put(new Text("cf"), new Text("age"), new Value(row[2].getBytes(StandardCharsets.UTF_8)));
+ m.put(new Text("cf"), new Text("city"), new Value(row[3].getBytes(StandardCharsets.UTF_8)));
+ writer.addMutation(m);
+ }
+ }
+
+ logger.info("Created test table: {} with 10 rows", tableName);
+ }
+
+ /**
+ * Creates a test table with user data and multiple column families.
+ *
+ * Table structure:
+ *
+ * - row_key: user_001 to user_020
+ * - personal:first_name, personal:last_name
+ * - contact:email, contact:phone
+ * - employment:company, employment:title, employment:salary
+ *
+ */
+ public static void createTestTableUsers(AccumuloClient client) throws Exception {
+ String tableName = TEST_TABLE_USERS;
+ createTableIfNotExists(client, tableName);
+
+ try (BatchWriter writer = client.createBatchWriter(tableName, new BatchWriterConfig())) {
+ String[][] data = {
+ {"user_001", "John", "Doe", "john.doe@email.com", "555-0101", "Acme Corp", "Engineer", "75000"},
+ {"user_002", "Jane", "Smith", "jane.smith@email.com", "555-0102", "TechCo", "Manager", "95000"},
+ {"user_003", "Bob", "Johnson", "bob.j@email.com", "555-0103", "DataInc", "Analyst", "65000"},
+ {"user_004", "Alice", "Williams", "alice.w@email.com", "555-0104", "Acme Corp", "Director", "120000"},
+ {"user_005", "Charlie", "Brown", "charlie.b@email.com", "555-0105", "TechCo", "Developer", "80000"},
+ {"user_006", "Diana", "Davis", "diana.d@email.com", "555-0106", "DataInc", "Scientist", "90000"},
+ {"user_007", "Edward", "Miller", "edward.m@email.com", "555-0107", "Acme Corp", "Engineer", "78000"},
+ {"user_008", "Fiona", "Wilson", "fiona.w@email.com", "555-0108", "TechCo", "Designer", "72000"},
+ {"user_009", "George", "Moore", "george.m@email.com", "555-0109", "DataInc", "Manager", "98000"},
+ {"user_010", "Hannah", "Taylor", "hannah.t@email.com", "555-0110", "Acme Corp", "Analyst", "68000"},
+ {"user_011", "Ivan", "Anderson", "ivan.a@email.com", "555-0111", "TechCo", "Developer", "82000"},
+ {"user_012", "Julia", "Thomas", "julia.t@email.com", "555-0112", "DataInc", "Engineer", "77000"},
+ {"user_013", "Kevin", "Jackson", "kevin.j@email.com", "555-0113", "Acme Corp", "Manager", "105000"},
+ {"user_014", "Laura", "White", "laura.w@email.com", "555-0114", "TechCo", "Director", "125000"},
+ {"user_015", "Michael", "Harris", "michael.h@email.com", "555-0115", "DataInc", "Analyst", "67000"},
+ {"user_016", "Nancy", "Martin", "nancy.m@email.com", "555-0116", "Acme Corp", "Developer", "79000"},
+ {"user_017", "Oscar", "Garcia", "oscar.g@email.com", "555-0117", "TechCo", "Scientist", "92000"},
+ {"user_018", "Patricia", "Martinez", "patricia.m@email.com", "555-0118", "DataInc", "Designer", "71000"},
+ {"user_019", "Quincy", "Robinson", "quincy.r@email.com", "555-0119", "Acme Corp", "Engineer", "76000"},
+ {"user_020", "Rachel", "Clark", "rachel.c@email.com", "555-0120", "TechCo", "Manager", "99000"}
+ };
+
+ for (String[] row : data) {
+ Mutation m = new Mutation(new Text(row[0]));
+ // personal column family
+ m.put(new Text("personal"), new Text("first_name"), new Value(row[1].getBytes(StandardCharsets.UTF_8)));
+ m.put(new Text("personal"), new Text("last_name"), new Value(row[2].getBytes(StandardCharsets.UTF_8)));
+ // contact column family
+ m.put(new Text("contact"), new Text("email"), new Value(row[3].getBytes(StandardCharsets.UTF_8)));
+ m.put(new Text("contact"), new Text("phone"), new Value(row[4].getBytes(StandardCharsets.UTF_8)));
+ // employment column family
+ m.put(new Text("employment"), new Text("company"), new Value(row[5].getBytes(StandardCharsets.UTF_8)));
+ m.put(new Text("employment"), new Text("title"), new Value(row[6].getBytes(StandardCharsets.UTF_8)));
+ m.put(new Text("employment"), new Text("salary"), new Value(row[7].getBytes(StandardCharsets.UTF_8)));
+ writer.addMutation(m);
+ }
+ }
+
+ logger.info("Created test table: {} with 20 rows", tableName);
+ }
+
+ /**
+ * Creates a larger test table for testing limit and pagination.
+ *
+ * Table structure:
+ *
+ * - row_key: row_0001 to row_1000
+ * - data:value - sequential integer values
+ * - data:description - description string
+ *
+ */
+ public static void createTestTableLarge(AccumuloClient client) throws Exception {
+ String tableName = TEST_TABLE_LARGE;
+ createTableIfNotExists(client, tableName);
+
+ try (BatchWriter writer = client.createBatchWriter(tableName, new BatchWriterConfig())) {
+ for (int i = 1; i <= 1000; i++) {
+ String rowKey = String.format("row_%04d", i);
+ Mutation m = new Mutation(new Text(rowKey));
+ m.put(new Text("data"), new Text("value"), new Value(String.valueOf(i).getBytes(StandardCharsets.UTF_8)));
+ m.put(new Text("data"), new Text("description"), new Value(("Item number " + i).getBytes(StandardCharsets.UTF_8)));
+ writer.addMutation(m);
+ }
+ }
+
+ logger.info("Created test table: {} with 1000 rows", tableName);
+ }
+
+ /**
+ * Creates a test table where rows have different sets of column qualifiers.
+ *
+ * This exercises the record reader's handling of columns that are absent from
+ * some rows: every missing qualifier must come back as NULL rather than shifting
+ * values between rows.
+ *
+ * Table structure:
+ *
+ * - sparse_001: cf:a, cf:b, cf:c
+ * - sparse_002: cf:a only
+ * - sparse_003: cf:b only
+ * - sparse_004: cf:c only
+ * - sparse_005: cf:a, cf:c
+ *
+ */
+ public static void createTestTableSparse(AccumuloClient client) throws Exception {
+ String tableName = TEST_TABLE_SPARSE;
+ createTableIfNotExists(client, tableName);
+
+ try (BatchWriter writer = client.createBatchWriter(tableName, new BatchWriterConfig())) {
+ // null means the qualifier is absent for that row
+ String[][] data = {
+ {"sparse_001", "a1", "b1", "c1"},
+ {"sparse_002", "a2", null, null},
+ {"sparse_003", null, "b3", null},
+ {"sparse_004", null, null, "c4"},
+ {"sparse_005", "a5", null, "c5"}
+ };
+
+ String[] qualifiers = {"a", "b", "c"};
+ for (String[] row : data) {
+ Mutation m = new Mutation(new Text(row[0]));
+ for (int i = 0; i < qualifiers.length; i++) {
+ String value = row[i + 1];
+ if (value != null) {
+ m.put(new Text("cf"), new Text(qualifiers[i]),
+ new Value(value.getBytes(StandardCharsets.UTF_8)));
+ }
+ }
+ writer.addMutation(m);
+ }
+ }
+
+ logger.info("Created test table: {} with 5 sparse rows", tableName);
+ }
+
+ /**
+ * Creates all test tables.
+ */
+ public static void createAllTestTables(AccumuloClient client) throws Exception {
+ createTestTable1(client);
+ createTestTableUsers(client);
+ createTestTableLarge(client);
+ createTestTableSparse(client);
+ }
+
+ /**
+ * Deletes all test tables.
+ */
+ public static void deleteAllTestTables(AccumuloClient client) throws Exception {
+ deleteTableIfExists(client, TEST_TABLE_1);
+ deleteTableIfExists(client, TEST_TABLE_USERS);
+ deleteTableIfExists(client, TEST_TABLE_LARGE);
+ deleteTableIfExists(client, TEST_TABLE_SPARSE);
+ }
+
+ /**
+ * Creates a table if it doesn't exist.
+ */
+ public static void createTableIfNotExists(AccumuloClient client, String tableName)
+ throws AccumuloException, AccumuloSecurityException {
+ try {
+ if (!client.tableOperations().exists(tableName)) {
+ client.tableOperations().create(tableName);
+ logger.debug("Created table: {}", tableName);
+ }
+ } catch (TableExistsException e) {
+ // Table was created by another thread, ignore
+ logger.debug("Table {} already exists", tableName);
+ }
+ }
+
+ /**
+ * Deletes a table if it exists.
+ */
+ public static void deleteTableIfExists(AccumuloClient client, String tableName)
+ throws AccumuloException, AccumuloSecurityException {
+ try {
+ if (client.tableOperations().exists(tableName)) {
+ client.tableOperations().delete(tableName);
+ logger.debug("Deleted table: {}", tableName);
+ }
+ } catch (TableNotFoundException e) {
+ // Table was deleted by another thread, ignore
+ logger.debug("Table {} not found for deletion", tableName);
+ }
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloTestsSuite.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloTestsSuite.java
new file mode 100644
index 00000000000..738f1b70b1f
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloTestsSuite.java
@@ -0,0 +1,56 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import org.apache.drill.exec.store.accumulo.schema.AccumuloColumnTypeTest;
+import org.apache.drill.exec.store.accumulo.schema.TableSchemaTest;
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+/**
+ * Test suite for Accumulo storage plugin.
+ *
+ * This suite includes all unit tests for the Accumulo plugin components.
+ * Integration tests requiring MiniAccumuloCluster will be added in later phases.
+ */
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+ // Phase 1: Core components
+ AccumuloStoragePluginConfigTest.class,
+ AccumuloScanSpecTest.class,
+ // Phase 2: Schema discovery
+ AccumuloColumnTypeTest.class,
+ TableSchemaTest.class,
+ // Phase 3: Basic scan capability
+ DrillAccumuloConstantsTest.class,
+ AccumuloTypeConverterTest.class,
+ // Phase 4: Filter pushdown
+ AccumuloFilterBuilderTest.class,
+ // Phase 5: Projection pushdown
+ AccumuloProjectionPushdownTest.class,
+ // Phase 6: Limit pushdown
+ AccumuloLimitPushdownTest.class,
+ // Phase 7: Sort pushdown
+ AccumuloSortPushdownTest.class,
+ // Phase 8: Kerberos authentication
+ AccumuloKerberosConfigTest.class,
+ DelegationTokenInfoTest.class
+})
+public class AccumuloTestsSuite {
+ // Test suite - no implementation needed
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloTypeConverterTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloTypeConverterTest.java
new file mode 100644
index 00000000000..0f1f3d34d9a
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/AccumuloTypeConverterTest.java
@@ -0,0 +1,270 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.math.BigDecimal;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.drill.exec.store.accumulo.schema.AccumuloColumnType;
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+
+/**
+ * Unit tests for AccumuloTypeConverter.
+ */
+public class AccumuloTypeConverterTest extends BaseTest {
+
+ @Test
+ public void testConvertVarchar() {
+ byte[] bytes = "hello world".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.VARCHAR);
+ assertEquals("hello world", result);
+ }
+
+ @Test
+ public void testConvertVarcharEmpty() {
+ byte[] bytes = new byte[0];
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.VARCHAR);
+ assertNull(result);
+ }
+
+ @Test
+ public void testConvertVarcharNull() {
+ Object result = AccumuloTypeConverter.convert(null, AccumuloColumnType.VARCHAR);
+ assertNull(result);
+ }
+
+ @Test
+ public void testConvertIntegerFromString() {
+ byte[] bytes = "42".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.INT);
+ assertEquals(42, result);
+ }
+
+ @Test
+ public void testConvertIntegerNegative() {
+ byte[] bytes = "-123".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.INTEGER);
+ assertEquals(-123, result);
+ }
+
+ @Test
+ public void testConvertIntegerFromBinary() {
+ // Use a value that produces non-ASCII bytes so it falls through to binary parsing
+ ByteBuffer buffer = ByteBuffer.allocate(4);
+ buffer.putInt(0x80000001); // Has high bit set, produces non-printable chars
+ Object result = AccumuloTypeConverter.convert(buffer.array(), AccumuloColumnType.INT);
+ assertEquals(0x80000001, result);
+ }
+
+ @Test
+ public void testConvertIntegerInvalid() {
+ byte[] bytes = "not a number".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.INT);
+ assertNull(result);
+ }
+
+ @Test
+ public void testConvertLongFromString() {
+ byte[] bytes = "9223372036854775807".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.BIGINT);
+ assertEquals(Long.MAX_VALUE, result);
+ }
+
+ @Test
+ public void testConvertLongFromBinary() {
+ ByteBuffer buffer = ByteBuffer.allocate(8);
+ buffer.putLong(123456789L);
+ Object result = AccumuloTypeConverter.convert(buffer.array(), AccumuloColumnType.LONG);
+ assertEquals(123456789L, result);
+ }
+
+ @Test
+ public void testConvertFloat() {
+ byte[] bytes = "3.14".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.FLOAT);
+ assertEquals(3.14f, (Float) result, 0.001);
+ }
+
+ @Test
+ public void testConvertFloatFromBinary() {
+ ByteBuffer buffer = ByteBuffer.allocate(4);
+ buffer.putFloat(3.14f);
+ Object result = AccumuloTypeConverter.convert(buffer.array(), AccumuloColumnType.FLOAT);
+ assertEquals(3.14f, (Float) result, 0.001);
+ }
+
+ @Test
+ public void testConvertDouble() {
+ byte[] bytes = "3.14159265359".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.DOUBLE);
+ assertEquals(3.14159265359, (Double) result, 0.00000000001);
+ }
+
+ @Test
+ public void testConvertDoubleFromBinary() {
+ ByteBuffer buffer = ByteBuffer.allocate(8);
+ buffer.putDouble(3.14159265359);
+ Object result = AccumuloTypeConverter.convert(buffer.array(), AccumuloColumnType.DOUBLE);
+ assertEquals(3.14159265359, (Double) result, 0.00000000001);
+ }
+
+ @Test
+ public void testConvertDecimal() {
+ byte[] bytes = "123456.789".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.DECIMAL);
+ assertEquals(new BigDecimal("123456.789"), result);
+ }
+
+ @Test
+ public void testConvertBooleanTrue() {
+ byte[] bytes = "true".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.BOOLEAN);
+ assertTrue((Boolean) result);
+ }
+
+ @Test
+ public void testConvertBooleanFalse() {
+ byte[] bytes = "false".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.BOOLEAN);
+ assertFalse((Boolean) result);
+ }
+
+ @Test
+ public void testConvertBooleanOne() {
+ byte[] bytes = "1".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.BOOLEAN);
+ assertTrue((Boolean) result);
+ }
+
+ @Test
+ public void testConvertBooleanZero() {
+ byte[] bytes = "0".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.BOOLEAN);
+ assertFalse((Boolean) result);
+ }
+
+ @Test
+ public void testConvertBooleanYes() {
+ byte[] bytes = "YES".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.BOOLEAN);
+ assertTrue((Boolean) result);
+ }
+
+ @Test
+ public void testConvertBooleanBinary() {
+ byte[] bytes = new byte[]{1};
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.BOOLEAN);
+ assertTrue((Boolean) result);
+
+ bytes = new byte[]{0};
+ result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.BOOLEAN);
+ assertFalse((Boolean) result);
+ }
+
+ @Test
+ public void testConvertDateIso() {
+ byte[] bytes = "2024-01-15".getBytes(StandardCharsets.UTF_8);
+ Long result = (Long) AccumuloTypeConverter.convert(bytes, AccumuloColumnType.DATE);
+ // 2024-01-15 00:00:00 UTC
+ assertEquals(1705276800000L, result.longValue());
+ }
+
+ @Test
+ public void testConvertTimeIso() {
+ byte[] bytes = "12:30:45".getBytes(StandardCharsets.UTF_8);
+ Integer result = (Integer) AccumuloTypeConverter.convert(bytes, AccumuloColumnType.TIME);
+ // 12:30:45 = 12*3600*1000 + 30*60*1000 + 45*1000 = 45045000 ms
+ assertEquals(45045000, result.intValue());
+ }
+
+ @Test
+ public void testConvertTimestampIso() {
+ byte[] bytes = "2024-01-15T12:30:45Z".getBytes(StandardCharsets.UTF_8);
+ Long result = (Long) AccumuloTypeConverter.convert(bytes, AccumuloColumnType.TIMESTAMP);
+ assertEquals(1705321845000L, result.longValue());
+ }
+
+ @Test
+ public void testConvertTimestampEpoch() {
+ byte[] bytes = "1705321845000".getBytes(StandardCharsets.UTF_8);
+ Long result = (Long) AccumuloTypeConverter.convert(bytes, AccumuloColumnType.TIMESTAMP);
+ assertEquals(1705321845000L, result.longValue());
+ }
+
+ @Test
+ public void testConvertVarbinary() {
+ byte[] bytes = new byte[]{0x01, 0x02, 0x03, (byte) 0xFF};
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.VARBINARY);
+ assertArrayEquals(bytes, (byte[]) result);
+ }
+
+ @Test
+ public void testConvertAny() {
+ byte[] bytes = "some value".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.ANY);
+ assertEquals("some value", result);
+ }
+
+ @Test
+ public void testToDisplayStringPrintable() {
+ byte[] bytes = "Hello World".getBytes(StandardCharsets.UTF_8);
+ String result = AccumuloTypeConverter.toDisplayString(bytes);
+ assertEquals("Hello World", result);
+ }
+
+ @Test
+ public void testToDisplayStringBinary() {
+ byte[] bytes = new byte[]{0x01, 0x02, 0x03};
+ String result = AccumuloTypeConverter.toDisplayString(bytes);
+ assertEquals("0x010203", result);
+ }
+
+ @Test
+ public void testToDisplayStringNull() {
+ String result = AccumuloTypeConverter.toDisplayString(null);
+ assertEquals("null", result);
+ }
+
+ @Test
+ public void testToDisplayStringEmpty() {
+ String result = AccumuloTypeConverter.toDisplayString(new byte[0]);
+ assertEquals("", result);
+ }
+
+ @Test
+ public void testConvertShort() {
+ byte[] bytes = "32767".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.SMALLINT);
+ assertEquals((short) 32767, result);
+ }
+
+ @Test
+ public void testConvertByte() {
+ byte[] bytes = "127".getBytes(StandardCharsets.UTF_8);
+ Object result = AccumuloTypeConverter.convert(bytes, AccumuloColumnType.TINYINT);
+ assertEquals((byte) 127, result);
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/BaseAccumuloTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/BaseAccumuloTest.java
new file mode 100644
index 00000000000..08c7f0644ab
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/BaseAccumuloTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.drill.exec.physical.rowSet.DirectRowSet;
+import org.apache.drill.exec.physical.rowSet.RowSetReader;
+import org.apache.drill.exec.store.StoragePluginRegistry;
+import org.apache.drill.exec.vector.accessor.ScalarReader;
+import org.apache.drill.test.ClusterFixture;
+import org.apache.drill.test.ClusterTest;
+import org.apache.drill.test.QueryRowSetIterator;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+/**
+ * Base class for Accumulo integration tests.
+ *
+ * This class sets up the Drill test cluster and registers the Accumulo storage plugin
+ * configured to connect to the MiniAccumuloCluster.
+ */
+public class BaseAccumuloTest extends ClusterTest {
+
+ public static final String ACCUMULO_STORAGE_PLUGIN_NAME = "accumulo";
+
+ protected static AccumuloStoragePlugin storagePlugin;
+ protected static AccumuloStoragePluginConfig storagePluginConfig;
+
+ @BeforeClass
+ public static void setupAccumuloTestCluster() throws Exception {
+ // Initialize the MiniAccumuloCluster
+ boolean isManaged = Boolean.parseBoolean(System.getProperty("drill.accumulo.tests.managed", "true"));
+ AccumuloIntegrationTestsSuite.configure(isManaged, true);
+ AccumuloIntegrationTestsSuite.initCluster();
+
+ // Start the Drill test cluster
+ startCluster(ClusterFixture.builder(dirTestWatcher));
+
+ // Register Accumulo storage plugin
+ StoragePluginRegistry pluginRegistry = cluster.drillbit().getContext().getStorage();
+ storagePluginConfig = new AccumuloStoragePluginConfig(
+ AccumuloIntegrationTestsSuite.getZooKeepers(),
+ AccumuloIntegrationTestsSuite.getInstanceName(),
+ AccumuloIntegrationTestsSuite.getRootUser(),
+ AccumuloIntegrationTestsSuite.getRootPassword()
+ );
+ storagePluginConfig.setEnabled(true);
+
+ pluginRegistry.put(ACCUMULO_STORAGE_PLUGIN_NAME, storagePluginConfig);
+ storagePlugin = (AccumuloStoragePlugin) pluginRegistry.getPlugin(ACCUMULO_STORAGE_PLUGIN_NAME);
+ }
+
+ @AfterClass
+ public static void tearDownAccumuloTestCluster() throws Exception {
+ AccumuloIntegrationTestsSuite.tearDownCluster();
+ }
+
+ /**
+ * Runs a SQL query and verifies the row count. Pass {@code -1} to skip the check.
+ */
+ protected void runAccumuloSQLVerifyCount(String sql, int expectedRowCount) throws Exception {
+ long rowCount = queryBuilder().sql(sql).run().recordCount();
+ if (expectedRowCount != -1) {
+ assertEquals(expectedRowCount, rowCount);
+ }
+ }
+
+ /**
+ * Returns the fully qualified table name for Drill queries.
+ *
+ * @param tableName the Accumulo table name
+ * @return the fully qualified name like "accumulo.`tableName`"
+ */
+ protected String fullTableName(String tableName) {
+ return ACCUMULO_STORAGE_PLUGIN_NAME + ".`" + tableName + "`";
+ }
+
+ /**
+ * Returns a {@code FROM} clause that aliases the table as {@code t}. Referring to a
+ * qualifier inside a column family requires the table alias ({@code t.cf.name}),
+ * the same as for the HBase plugin.
+ */
+ protected String fromTable(String tableName) {
+ return " FROM " + fullTableName(tableName) + " t";
+ }
+
+ /**
+ * Wraps a column reference in a {@code CONVERT_FROM(..., 'UTF8')} call. Accumulo row
+ * keys and values are surfaced to Drill as VARBINARY, so they must be decoded before
+ * they can be compared against string baselines.
+ *
+ * @param column the column reference, e.g. {@code row_key} or {@code cf.name}
+ * @param alias the alias to give the decoded column
+ */
+ protected static String utf8(String column, String alias) {
+ return "CONVERT_FROM(" + column + ", 'UTF8') AS " + alias;
+ }
+
+ /**
+ * Runs a query and returns its results as rows of strings, with {@code null} for
+ * NULL values. Reading the values back as strings keeps the assertions independent
+ * of whether a column comes back as required or nullable.
+ */
+ protected List> runAndReadStrings(String sql) throws Exception {
+ return readStrings(queryBuilder().sql(sql).rowSetIterator());
+ }
+
+ /**
+ * Drains a query's row sets into rows of strings, with {@code null} for NULL values.
+ *
+ * Every batch is read, so this stays correct for queries that return their results
+ * across several batches, and each row set is released as it is consumed.
+ */
+ protected static List> readStrings(QueryRowSetIterator batches) {
+ List> rows = new ArrayList<>();
+ for (DirectRowSet rowSet : batches) {
+ try {
+ int columnCount = rowSet.schema().size();
+ RowSetReader reader = rowSet.reader();
+ while (reader.next()) {
+ List row = new ArrayList<>(columnCount);
+ for (int i = 0; i < columnCount; i++) {
+ ScalarReader scalar = reader.scalar(i);
+ row.add(scalar.isNull() ? null : String.valueOf(scalar.getObject()));
+ }
+ rows.add(row);
+ }
+ } finally {
+ rowSet.clear();
+ }
+ }
+ return rows;
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/DelegationTokenInfoTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/DelegationTokenInfoTest.java
new file mode 100644
index 00000000000..a5323ccde00
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/DelegationTokenInfoTest.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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Base64;
+
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Unit tests for DelegationTokenInfo.
+ */
+public class DelegationTokenInfoTest extends BaseTest {
+
+ private static final String TEST_TOKEN_CLASS = "org.apache.accumulo.core.clientImpl.DelegationTokenImpl";
+
+ @Test
+ public void testBasicConstruction() {
+ long now = System.currentTimeMillis();
+ String serializedToken = Base64.getEncoder().encodeToString("test-token-data".getBytes());
+
+ DelegationTokenInfo tokenInfo = new DelegationTokenInfo(
+ "testUser", serializedToken, TEST_TOKEN_CLASS, now);
+
+ assertEquals("testUser", tokenInfo.getUserName());
+ assertEquals(serializedToken, tokenInfo.getSerializedToken());
+ assertEquals(TEST_TOKEN_CLASS, tokenInfo.getTokenClassName());
+ assertEquals(now, tokenInfo.getCreationTime());
+ }
+
+ @Test
+ public void testGetAgeMillis() throws InterruptedException {
+ long before = System.currentTimeMillis();
+ DelegationTokenInfo tokenInfo = new DelegationTokenInfo(
+ "user",
+ Base64.getEncoder().encodeToString("data".getBytes()),
+ TEST_TOKEN_CLASS,
+ before
+ );
+
+ // Sleep a bit to let time pass
+ Thread.sleep(50);
+
+ long age = tokenInfo.getAgeMillis();
+ assertTrue("Age should be at least 50ms", age >= 50);
+ }
+
+ @Test
+ public void testIsOlderThan() {
+ long now = System.currentTimeMillis();
+ DelegationTokenInfo tokenInfo = new DelegationTokenInfo(
+ "user",
+ Base64.getEncoder().encodeToString("data".getBytes()),
+ TEST_TOKEN_CLASS,
+ now - 5000 // Created 5 seconds ago
+ );
+
+ assertTrue("Token should be older than 1 second", tokenInfo.isOlderThan(1000));
+ assertFalse("Token should not be older than 1 hour", tokenInfo.isOlderThan(3600000));
+ }
+
+ @Test
+ public void testEquality() {
+ long time = System.currentTimeMillis();
+ String token = Base64.getEncoder().encodeToString("token".getBytes());
+
+ DelegationTokenInfo info1 = new DelegationTokenInfo("user", token, TEST_TOKEN_CLASS, time);
+ DelegationTokenInfo info2 = new DelegationTokenInfo("user", token, TEST_TOKEN_CLASS, time);
+ DelegationTokenInfo info3 = new DelegationTokenInfo("differentUser", token, TEST_TOKEN_CLASS, time);
+
+ assertEquals(info1, info2);
+ assertEquals(info1.hashCode(), info2.hashCode());
+ assertNotEquals(info1, info3);
+ }
+
+ @Test
+ public void testJsonSerialization() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+ long time = 1234567890123L;
+ String serializedToken = Base64.getEncoder().encodeToString("test-token".getBytes());
+
+ DelegationTokenInfo original = new DelegationTokenInfo(
+ "testUser", serializedToken, TEST_TOKEN_CLASS, time);
+
+ // Serialize to JSON
+ String json = mapper.writeValueAsString(original);
+ assertNotNull(json);
+ assertTrue(json.contains("testUser"));
+ assertTrue(json.contains(serializedToken));
+ assertTrue(json.contains("1234567890123"));
+ assertTrue(json.contains("tokenClassName"));
+
+ // Deserialize back
+ DelegationTokenInfo deserialized = mapper.readValue(json, DelegationTokenInfo.class);
+
+ assertEquals(original.getUserName(), deserialized.getUserName());
+ assertEquals(original.getSerializedToken(), deserialized.getSerializedToken());
+ assertEquals(original.getTokenClassName(), deserialized.getTokenClassName());
+ assertEquals(original.getCreationTime(), deserialized.getCreationTime());
+ assertEquals(original, deserialized);
+ }
+
+ @Test
+ public void testJsonRoundTrip() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+
+ // Create with current time
+ DelegationTokenInfo original = new DelegationTokenInfo(
+ "drillUser",
+ Base64.getEncoder().encodeToString("serialized-delegation-token".getBytes()),
+ TEST_TOKEN_CLASS,
+ System.currentTimeMillis()
+ );
+
+ // Round-trip through JSON
+ String json = mapper.writeValueAsString(original);
+ DelegationTokenInfo roundTripped = mapper.readValue(json, DelegationTokenInfo.class);
+
+ assertEquals(original, roundTripped);
+ }
+
+ @Test
+ public void testToString() {
+ String serializedToken = Base64.getEncoder().encodeToString("token-data".getBytes());
+ DelegationTokenInfo tokenInfo = new DelegationTokenInfo(
+ "user", serializedToken, TEST_TOKEN_CLASS, 1234567890L);
+
+ String toString = tokenInfo.toString();
+
+ assertTrue(toString.contains("user"));
+ assertTrue(toString.contains("1234567890"));
+ assertTrue(toString.contains("tokenClassName"));
+ // Should contain token length, not the actual token
+ assertTrue(toString.contains("tokenLength"));
+ // Should not contain the actual serialized token for security
+ assertFalse(toString.contains(serializedToken));
+ }
+
+ @Test
+ public void testTokenLengthInToString() {
+ String shortToken = Base64.getEncoder().encodeToString("short".getBytes());
+ String longToken = Base64.getEncoder().encodeToString("this-is-a-much-longer-token".getBytes());
+
+ DelegationTokenInfo shortInfo = new DelegationTokenInfo("user", shortToken, TEST_TOKEN_CLASS, 0);
+ DelegationTokenInfo longInfo = new DelegationTokenInfo("user", longToken, TEST_TOKEN_CLASS, 0);
+
+ // toString should show different token lengths
+ assertTrue(shortInfo.toString().contains(String.valueOf(shortToken.length())));
+ assertTrue(longInfo.toString().contains(String.valueOf(longToken.length())));
+ }
+
+ @Test
+ public void testDifferentTokenClassNames() {
+ String token = Base64.getEncoder().encodeToString("token".getBytes());
+ long time = System.currentTimeMillis();
+
+ DelegationTokenInfo info1 = new DelegationTokenInfo("user", token, "ClassA", time);
+ DelegationTokenInfo info2 = new DelegationTokenInfo("user", token, "ClassB", time);
+
+ // Different token class names should result in different objects
+ assertNotEquals(info1, info2);
+ assertNotEquals(info1.hashCode(), info2.hashCode());
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/DrillAccumuloConstantsTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/DrillAccumuloConstantsTest.java
new file mode 100644
index 00000000000..f98538b00e9
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/DrillAccumuloConstantsTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.drill.exec.store.accumulo;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import org.apache.drill.common.types.TypeProtos.DataMode;
+import org.apache.drill.common.types.TypeProtos.MinorType;
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+
+/**
+ * Unit tests for DrillAccumuloConstants.
+ */
+public class DrillAccumuloConstantsTest extends BaseTest {
+
+ @Test
+ public void testRowKeyConstant() {
+ assertEquals("row_key", DrillAccumuloConstants.ROW_KEY);
+ }
+
+ @Test
+ public void testRowKeyPath() {
+ assertNotNull(DrillAccumuloConstants.ROW_KEY_PATH);
+ assertEquals("row_key", DrillAccumuloConstants.ROW_KEY_PATH.getRootSegment().getPath());
+ }
+
+ @Test
+ public void testRowKeyType() {
+ assertNotNull(DrillAccumuloConstants.ROW_KEY_TYPE);
+ assertEquals(MinorType.VARBINARY, DrillAccumuloConstants.ROW_KEY_TYPE.getMinorType());
+ assertEquals(DataMode.REQUIRED, DrillAccumuloConstants.ROW_KEY_TYPE.getMode());
+ }
+
+ @Test
+ public void testColumnFamilyType() {
+ assertNotNull(DrillAccumuloConstants.COLUMN_FAMILY_TYPE);
+ assertEquals(MinorType.MAP, DrillAccumuloConstants.COLUMN_FAMILY_TYPE.getMinorType());
+ assertEquals(DataMode.REQUIRED, DrillAccumuloConstants.COLUMN_FAMILY_TYPE.getMode());
+ }
+
+ @Test
+ public void testColumnType() {
+ assertNotNull(DrillAccumuloConstants.COLUMN_TYPE);
+ assertEquals(MinorType.VARBINARY, DrillAccumuloConstants.COLUMN_TYPE.getMinorType());
+ assertEquals(DataMode.OPTIONAL, DrillAccumuloConstants.COLUMN_TYPE.getMode());
+ }
+
+ @Test
+ public void testColumnSeparator() {
+ assertEquals(":", DrillAccumuloConstants.COLUMN_SEPARATOR);
+ }
+
+ @Test
+ public void testDefaultBatchSize() {
+ assertEquals(4000, DrillAccumuloConstants.DEFAULT_BATCH_SIZE);
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/schema/AccumuloColumnTypeTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/schema/AccumuloColumnTypeTest.java
new file mode 100644
index 00000000000..1118e587ffa
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/schema/AccumuloColumnTypeTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.drill.exec.store.accumulo.schema;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+
+/**
+ * Unit tests for AccumuloColumnType.
+ */
+public class AccumuloColumnTypeTest extends BaseTest {
+
+ @Test
+ public void testSqlTypeMapping() {
+ assertEquals(SqlTypeName.VARCHAR, AccumuloColumnType.VARCHAR.getSqlTypeName());
+ assertEquals(SqlTypeName.INTEGER, AccumuloColumnType.INT.getSqlTypeName());
+ assertEquals(SqlTypeName.INTEGER, AccumuloColumnType.INTEGER.getSqlTypeName());
+ assertEquals(SqlTypeName.BIGINT, AccumuloColumnType.BIGINT.getSqlTypeName());
+ assertEquals(SqlTypeName.BIGINT, AccumuloColumnType.LONG.getSqlTypeName());
+ assertEquals(SqlTypeName.FLOAT, AccumuloColumnType.FLOAT.getSqlTypeName());
+ assertEquals(SqlTypeName.DOUBLE, AccumuloColumnType.DOUBLE.getSqlTypeName());
+ assertEquals(SqlTypeName.BOOLEAN, AccumuloColumnType.BOOLEAN.getSqlTypeName());
+ assertEquals(SqlTypeName.DATE, AccumuloColumnType.DATE.getSqlTypeName());
+ assertEquals(SqlTypeName.TIME, AccumuloColumnType.TIME.getSqlTypeName());
+ assertEquals(SqlTypeName.TIMESTAMP, AccumuloColumnType.TIMESTAMP.getSqlTypeName());
+ assertEquals(SqlTypeName.VARBINARY, AccumuloColumnType.VARBINARY.getSqlTypeName());
+ assertEquals(SqlTypeName.ANY, AccumuloColumnType.ANY.getSqlTypeName());
+ }
+
+ @Test
+ public void testFromString() {
+ // Direct matches
+ assertEquals(AccumuloColumnType.VARCHAR, AccumuloColumnType.fromString("VARCHAR"));
+ assertEquals(AccumuloColumnType.INTEGER, AccumuloColumnType.fromString("INTEGER"));
+ assertEquals(AccumuloColumnType.BIGINT, AccumuloColumnType.fromString("BIGINT"));
+ assertEquals(AccumuloColumnType.DOUBLE, AccumuloColumnType.fromString("DOUBLE"));
+ assertEquals(AccumuloColumnType.BOOLEAN, AccumuloColumnType.fromString("BOOLEAN"));
+
+ // Case insensitive
+ assertEquals(AccumuloColumnType.VARCHAR, AccumuloColumnType.fromString("varchar"));
+ assertEquals(AccumuloColumnType.INTEGER, AccumuloColumnType.fromString("integer"));
+ assertEquals(AccumuloColumnType.BOOLEAN, AccumuloColumnType.fromString("Boolean"));
+ }
+
+ @Test
+ public void testFromStringAliases() {
+ // String aliases
+ assertEquals(AccumuloColumnType.VARCHAR, AccumuloColumnType.fromString("STRING"));
+ assertEquals(AccumuloColumnType.VARCHAR, AccumuloColumnType.fromString("TEXT"));
+
+ // Integer aliases
+ assertEquals(AccumuloColumnType.INTEGER, AccumuloColumnType.fromString("INT"));
+
+ // Long aliases
+ assertEquals(AccumuloColumnType.BIGINT, AccumuloColumnType.fromString("LONG"));
+
+ // Boolean aliases
+ assertEquals(AccumuloColumnType.BOOLEAN, AccumuloColumnType.fromString("BOOL"));
+
+ // Binary aliases
+ assertEquals(AccumuloColumnType.VARBINARY, AccumuloColumnType.fromString("BYTES"));
+ assertEquals(AccumuloColumnType.VARBINARY, AccumuloColumnType.fromString("BINARY"));
+ }
+
+ @Test
+ public void testFromStringDefault() {
+ // Unknown types should default to VARCHAR
+ assertEquals(AccumuloColumnType.VARCHAR, AccumuloColumnType.fromString("UNKNOWN"));
+ assertEquals(AccumuloColumnType.VARCHAR, AccumuloColumnType.fromString(""));
+ assertEquals(AccumuloColumnType.VARCHAR, AccumuloColumnType.fromString(null));
+ assertEquals(AccumuloColumnType.VARCHAR, AccumuloColumnType.fromString(" "));
+ }
+
+ @Test
+ public void testIsNumeric() {
+ assertTrue(AccumuloColumnType.INT.isNumeric());
+ assertTrue(AccumuloColumnType.INTEGER.isNumeric());
+ assertTrue(AccumuloColumnType.BIGINT.isNumeric());
+ assertTrue(AccumuloColumnType.LONG.isNumeric());
+ assertTrue(AccumuloColumnType.SMALLINT.isNumeric());
+ assertTrue(AccumuloColumnType.TINYINT.isNumeric());
+ assertTrue(AccumuloColumnType.FLOAT.isNumeric());
+ assertTrue(AccumuloColumnType.DOUBLE.isNumeric());
+ assertTrue(AccumuloColumnType.DECIMAL.isNumeric());
+
+ assertFalse(AccumuloColumnType.VARCHAR.isNumeric());
+ assertFalse(AccumuloColumnType.BOOLEAN.isNumeric());
+ assertFalse(AccumuloColumnType.DATE.isNumeric());
+ assertFalse(AccumuloColumnType.VARBINARY.isNumeric());
+ }
+
+ @Test
+ public void testIsTemporal() {
+ assertTrue(AccumuloColumnType.DATE.isTemporal());
+ assertTrue(AccumuloColumnType.TIME.isTemporal());
+ assertTrue(AccumuloColumnType.TIMESTAMP.isTemporal());
+
+ assertFalse(AccumuloColumnType.VARCHAR.isTemporal());
+ assertFalse(AccumuloColumnType.INT.isTemporal());
+ assertFalse(AccumuloColumnType.BOOLEAN.isTemporal());
+ assertFalse(AccumuloColumnType.VARBINARY.isTemporal());
+ }
+}
diff --git a/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/schema/TableSchemaTest.java b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/schema/TableSchemaTest.java
new file mode 100644
index 00000000000..f6c146128ac
--- /dev/null
+++ b/contrib/storage-accumulo/src/test/java/org/apache/drill/exec/store/accumulo/schema/TableSchemaTest.java
@@ -0,0 +1,192 @@
+/*
+ * 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.drill.exec.store.accumulo.schema;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.drill.test.BaseTest;
+import org.junit.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Unit tests for ColumnDef and TableSchema.
+ */
+public class TableSchemaTest extends BaseTest {
+
+ @Test
+ public void testColumnDefCreation() {
+ ColumnDef col = new ColumnDef("name", "cf1", "name", AccumuloColumnType.VARCHAR, true);
+
+ assertEquals("name", col.getName());
+ assertEquals("cf1", col.getColumnFamily());
+ assertEquals("name", col.getColumnQualifier());
+ assertEquals(AccumuloColumnType.VARCHAR, col.getType());
+ assertTrue(col.isNullable());
+ assertEquals(SqlTypeName.VARCHAR, col.getSqlTypeName());
+ assertEquals("cf1:name", col.getFullColumnName());
+ }
+
+ @Test
+ public void testColumnDefFactoryMethods() {
+ ColumnDef col1 = ColumnDef.create("age", "cf1", "age", AccumuloColumnType.INT);
+ assertEquals("age", col1.getName());
+ assertEquals(AccumuloColumnType.INT, col1.getType());
+ assertTrue(col1.isNullable());
+
+ ColumnDef col2 = ColumnDef.varchar("email", "cf2", "email");
+ assertEquals("email", col2.getName());
+ assertEquals(AccumuloColumnType.VARCHAR, col2.getType());
+ }
+
+ @Test
+ public void testColumnDefFullColumnName() {
+ ColumnDef col1 = ColumnDef.varchar("name", "cf1", "name");
+ assertEquals("cf1:name", col1.getFullColumnName());
+
+ ColumnDef col2 = ColumnDef.varchar("data", "cf1", "");
+ assertEquals("cf1", col2.getFullColumnName());
+
+ ColumnDef col3 = ColumnDef.varchar("data", "cf1", null);
+ assertEquals("cf1", col3.getFullColumnName());
+ }
+
+ @Test
+ public void testTableSchemaBuilder() {
+ TableSchema schema = TableSchema.builder("users")
+ .rowKeyType(AccumuloColumnType.VARCHAR)
+ .addColumn("name", "cf1", "name", AccumuloColumnType.VARCHAR)
+ .addColumn("age", "cf1", "age", AccumuloColumnType.INT)
+ .addVarcharColumn("email", "cf2", "email")
+ .build();
+
+ assertEquals("users", schema.getTableName());
+ assertEquals(AccumuloColumnType.VARCHAR, schema.getRowKeyType());
+ assertEquals(3, schema.getColumnCount());
+ assertTrue(schema.hasExplicitColumns());
+ }
+
+ @Test
+ public void testTableSchemaColumnLookup() {
+ TableSchema schema = TableSchema.builder("test")
+ .addColumn("name", "cf1", "name", AccumuloColumnType.VARCHAR)
+ .addColumn("age", "cf1", "age", AccumuloColumnType.INT)
+ .build();
+
+ // By name
+ ColumnDef byName = schema.getColumnByName("name");
+ assertNotNull(byName);
+ assertEquals("name", byName.getName());
+
+ ColumnDef byNameCase = schema.getColumnByName("NAME");
+ assertNotNull(byNameCase);
+ assertEquals("name", byNameCase.getName());
+
+ ColumnDef notFound = schema.getColumnByName("notexist");
+ assertNull(notFound);
+
+ // By Accumulo key
+ ColumnDef byKey = schema.getColumnByAccumuloKey("cf1:name");
+ assertNotNull(byKey);
+ assertEquals("name", byKey.getName());
+
+ ColumnDef byKeyNotFound = schema.getColumnByAccumuloKey("cf2:name");
+ assertNull(byKeyNotFound);
+ }
+
+ @Test
+ public void testDynamicSchema() {
+ TableSchema schema = TableSchema.dynamic("dynamic_table");
+
+ assertEquals("dynamic_table", schema.getTableName());
+ assertEquals(AccumuloColumnType.VARBINARY, schema.getRowKeyType());
+ assertFalse(schema.hasExplicitColumns());
+ assertEquals(0, schema.getColumnCount());
+ }
+
+ @Test
+ public void testTableSchemaEquality() {
+ TableSchema schema1 = TableSchema.builder("test")
+ .addVarcharColumn("name", "cf1", "name")
+ .build();
+
+ TableSchema schema2 = TableSchema.builder("test")
+ .addVarcharColumn("name", "cf1", "name")
+ .build();
+
+ TableSchema schema3 = TableSchema.builder("test")
+ .addColumn("name", "cf1", "name", AccumuloColumnType.INT)
+ .build();
+
+ assertEquals(schema1, schema2);
+ assertEquals(schema1.hashCode(), schema2.hashCode());
+ assertFalse(schema1.equals(schema3));
+ }
+
+ @Test
+ public void testTableSchemaJsonSerialization() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+
+ TableSchema schema = TableSchema.builder("users")
+ .rowKeyType(AccumuloColumnType.VARCHAR)
+ .addColumn("name", "cf1", "name", AccumuloColumnType.VARCHAR)
+ .addColumn("age", "cf1", "age", AccumuloColumnType.INT)
+ .build();
+
+ String json = mapper.writeValueAsString(schema);
+ assertNotNull(json);
+ assertTrue(json.contains("users"));
+ assertTrue(json.contains("name"));
+ assertTrue(json.contains("age"));
+
+ TableSchema deserialized = mapper.readValue(json, TableSchema.class);
+ assertEquals(schema.getTableName(), deserialized.getTableName());
+ assertEquals(schema.getRowKeyType(), deserialized.getRowKeyType());
+ assertEquals(schema.getColumnCount(), deserialized.getColumnCount());
+ }
+
+ @Test
+ public void testColumnDefJsonSerialization() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+
+ ColumnDef col = new ColumnDef("name", "cf1", "qualifier1", AccumuloColumnType.VARCHAR, false);
+
+ String json = mapper.writeValueAsString(col);
+ assertNotNull(json);
+ assertTrue(json.contains("name"));
+ assertTrue(json.contains("cf1"));
+ assertTrue(json.contains("qualifier1"));
+ assertTrue(json.contains("VARCHAR"));
+
+ ColumnDef deserialized = mapper.readValue(json, ColumnDef.class);
+ assertEquals(col, deserialized);
+ }
+
+ @Test
+ public void testTableSchemaDefaultValues() {
+ // Null rowKeyType should default to VARBINARY
+ TableSchema schema = new TableSchema("test", null, null);
+ assertEquals(AccumuloColumnType.VARBINARY, schema.getRowKeyType());
+ assertFalse(schema.hasExplicitColumns());
+ }
+}
diff --git a/distribution/pom.xml b/distribution/pom.xml
index 10e27292638..f16424432bd 100644
--- a/distribution/pom.xml
+++ b/distribution/pom.xml
@@ -436,6 +436,11 @@
drill-storage-kafka
${project.version}
+
+ org.apache.drill.contrib
+ drill-storage-accumulo
+ ${project.version}
+
org.apache.drill.contrib
drill-storage-cassandra
diff --git a/distribution/src/assemble/component.xml b/distribution/src/assemble/component.xml
index 71974cdd127..c74dbad934d 100644
--- a/distribution/src/assemble/component.xml
+++ b/distribution/src/assemble/component.xml
@@ -50,6 +50,7 @@
org.apache.drill.contrib:drill-mongo-storage:jar
org.apache.drill.contrib:drill-opentsdb-storage:jar
org.apache.drill.contrib:drill-paimon-format:jar
+ org.apache.drill.contrib:drill-storage-accumulo:jar
org.apache.drill.contrib:drill-storage-cassandra:jar
org.apache.drill.contrib:drill-storage-elasticsearch:jar
org.apache.drill.contrib:drill-storage-googlesheets:jar