This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 5c3d4bcf6c [spark] prepare MAP selected-key pushdown read type (#8478)
5c3d4bcf6c is described below
commit 5c3d4bcf6c4d0cac6aa26a09c626946f4ad1b8f5
Author: lszskye <[email protected]>
AuthorDate: Wed Jul 8 22:12:22 2026 -0700
[spark] prepare MAP selected-key pushdown read type (#8478)
This PR introduces the preliminary Spark-side and metadata
infrastructure for MAP selected-key pushdown.
The target use case is to allow upper engines, such as Spark, to
describe a read schema like:
`SELECT
id,
attrs['key1'] AS key1_value,
attrs['key2'] AS key2_value
FROM T;`
as a temporary Paimon read type where the MAP field is rewritten to a
ROW field, and the selected MAP keys are recorded in
DataField.description:
`__PAIMON_MAP_SELECTED_KEYS:key1;key2`
This metadata is only intended to exist in the temporary scan/read
schema. It must not be persisted into table schema or catalog schema.
---
.../shredding/MapSelectedKeysMetadataUtils.java | 102 ++++++++
.../MapSelectedKeysMetadataUtilsTest.java | 131 ++++++++++
.../scala/org/apache/paimon/spark/PaimonScan.scala | 1 +
.../optimizer/PushDownMapSelectedKeys.scala | 34 +++
.../scala/org/apache/paimon/spark/PaimonScan.scala | 1 +
.../optimizer/PushDownMapSelectedKeys.scala | 34 +++
.../optimizer/PushDownMapSelectedKeys.scala | 39 +++
.../optimizer/PushDownMapSelectedKeys.scala | 39 +++
.../scala/org/apache/paimon/spark/PaimonScan.scala | 1 +
.../optimizer/PushDownMapSelectedKeys.scala | 289 +++++++++++++++++++++
.../extensions/PaimonSparkSessionExtensions.scala | 2 +
.../org/apache/paimon/spark/read/BaseScan.scala | 21 +-
.../spark/read/MapSelectedKeysPushDownUtils.scala | 71 +++++
.../read/MapSelectedKeysPushDownUtilsTest.scala | 80 ++++++
.../spark/sql/PaimonOptimizationTestBase.scala | 48 +++-
.../optimizer/PushDownMapSelectedKeys.scala | 39 +++
16 files changed, 928 insertions(+), 4 deletions(-)
diff --git
a/paimon-common/src/main/java/org/apache/paimon/data/shredding/MapSelectedKeysMetadataUtils.java
b/paimon-common/src/main/java/org/apache/paimon/data/shredding/MapSelectedKeysMetadataUtils.java
new file mode 100644
index 0000000000..6a4163d673
--- /dev/null
+++
b/paimon-common/src/main/java/org/apache/paimon/data/shredding/MapSelectedKeysMetadataUtils.java
@@ -0,0 +1,102 @@
+/*
+ * 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.paimon.data.shredding;
+
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.RowType;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+
+/**
+ * Utils for marking a temporary read {@link RowType} field as selected-key
MAP output. Uses
+ * description field in DataField to encode selected-key metadata.
+ *
+ * <p>Example: __PAIMON_MAP_SELECTED_KEYS:key1;key2
+ */
+public class MapSelectedKeysMetadataUtils {
+
+ public static final String METADATA_KEY = "__PAIMON_MAP_SELECTED_KEYS:";
+ public static final String KEY_DELIMITER = ";";
+
+ private MapSelectedKeysMetadataUtils() {}
+
+ public static String buildMapSelectedKeysMetadata(List<String> keys) {
+ List<String> normalizedKeys = normalizeKeys(keys);
+ return METADATA_KEY + String.join(KEY_DELIMITER, normalizedKeys);
+ }
+
+ public static boolean isMapSelectedKeysMetadata(@Nullable String
description) {
+ return description != null && description.startsWith(METADATA_KEY);
+ }
+
+ public static boolean isMapSelectedKeysField(DataField field) {
+ return isMapSelectedKeysMetadata(field.description()) && field.type()
instanceof RowType;
+ }
+
+ public static DataField withSelectedKeys(DataField field, DataType
rowType, List<String> keys) {
+ checkArgument(rowType instanceof RowType, "Selected-key MAP read type
must be ROW.");
+ return
field.newType(rowType).newDescription(buildMapSelectedKeysMetadata(keys));
+ }
+
+ public static List<String> selectedKeys(String description) {
+ checkArgument(
+ isMapSelectedKeysMetadata(description),
+ "Invalid selected-key MAP metadata: %s",
+ description);
+ String encoded = description.substring(METADATA_KEY.length());
+
+ String[] parts = encoded.split(KEY_DELIMITER, -1);
+ List<String> keys = new ArrayList<>(parts.length);
+ for (String part : parts) {
+ keys.add(part);
+ }
+ return keys;
+ }
+
+ private static List<String> normalizeKeys(List<String> keys) {
+ checkNotNull(keys, "Selected keys must not be null.");
+ checkArgument(!keys.isEmpty(), "Selected keys must not be empty.");
+
+ Set<String> seenKeys = new LinkedHashSet<>();
+ for (String key : keys) {
+ checkNotNull(key, "Selected key must not be null.");
+ checkArgument(
+ !key.contains(KEY_DELIMITER),
+ "Selected key must not contain '%s': %s",
+ KEY_DELIMITER,
+ key);
+ checkArgument(
+ !key.startsWith(METADATA_KEY),
+ "Selected key must not start with metadata prefix: %s",
+ key);
+ checkArgument(!seenKeys.contains(key), "Selected key must not be
duplicated: %s", key);
+ seenKeys.add(key);
+ }
+ return new ArrayList<>(seenKeys);
+ }
+}
diff --git
a/paimon-common/src/test/java/org/apache/paimon/data/shredding/MapSelectedKeysMetadataUtilsTest.java
b/paimon-common/src/test/java/org/apache/paimon/data/shredding/MapSelectedKeysMetadataUtilsTest.java
new file mode 100644
index 0000000000..c921ee7528
--- /dev/null
+++
b/paimon-common/src/test/java/org/apache/paimon/data/shredding/MapSelectedKeysMetadataUtilsTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.paimon.data.shredding;
+
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link MapSelectedKeysMetadataUtils}. */
+class MapSelectedKeysMetadataUtilsTest {
+
+ @Test
+ void testBuildAndParseMetadata() {
+ String metadata =
+ MapSelectedKeysMetadataUtils.buildMapSelectedKeysMetadata(
+ Arrays.asList("key1", "key2"));
+
+ assertThat(metadata).isEqualTo("__PAIMON_MAP_SELECTED_KEYS:key1;key2");
+
assertThat(MapSelectedKeysMetadataUtils.isMapSelectedKeysMetadata(metadata)).isTrue();
+ assertThat(MapSelectedKeysMetadataUtils.selectedKeys(metadata))
+ .containsExactly("key1", "key2");
+ }
+
+ @Test
+ void testBuildAndParseMetadataWithEmptyKey() {
+ String metadata =
+ MapSelectedKeysMetadataUtils.buildMapSelectedKeysMetadata(
+ Arrays.asList("key1", "", "key2"));
+
+ String expectedMetadata = String.join(";",
"__PAIMON_MAP_SELECTED_KEYS:key1", "", "key2");
+ assertThat(metadata).isEqualTo(expectedMetadata);
+ assertThat(MapSelectedKeysMetadataUtils.selectedKeys(metadata))
+ .containsExactly("key1", "", "key2");
+
assertThat(MapSelectedKeysMetadataUtils.selectedKeys("__PAIMON_MAP_SELECTED_KEYS:"))
+ .containsExactly("");
+ }
+
+ @Test
+ void testBuildMetadataRejectsDuplicateKeys() {
+ assertThatThrownBy(
+ () ->
+
MapSelectedKeysMetadataUtils.buildMapSelectedKeysMetadata(
+ Arrays.asList("key1", "key2", "key1")))
+ .hasMessageContaining("Selected key must not be duplicated:
key1");
+ assertThatThrownBy(
+ () ->
+
MapSelectedKeysMetadataUtils.buildMapSelectedKeysMetadata(
+ Arrays.asList("key1", "", "")))
+ .hasMessageContaining("Selected key must not be duplicated");
+ }
+
+ @Test
+ void testWithSelectedKeys() {
+ DataField field =
+ DataTypes.FIELD(
+ 1,
+ "attrs",
+ DataTypes.MAP(DataTypes.STRING(), DataTypes.BIGINT()),
+ "user comment");
+ RowType selectedType =
+ DataTypes.ROW(
+ DataTypes.FIELD(0, "0", DataTypes.BIGINT()),
+ DataTypes.FIELD(1, "1", DataTypes.BIGINT()));
+
+ DataField rewritten =
+ MapSelectedKeysMetadataUtils.withSelectedKeys(
+ field, selectedType, Arrays.asList("key1", "key2"));
+
+ assertThat(rewritten.id()).isEqualTo(1);
+ assertThat(rewritten.name()).isEqualTo("attrs");
+ assertThat(rewritten.type()).isEqualTo(selectedType);
+
assertThat(rewritten.description()).isEqualTo("__PAIMON_MAP_SELECTED_KEYS:key1;key2");
+
assertThat(MapSelectedKeysMetadataUtils.isMapSelectedKeysField(rewritten)).isTrue();
+ }
+
+ @Test
+ void testErrors() {
+ assertThatThrownBy(
+ () ->
+
MapSelectedKeysMetadataUtils.buildMapSelectedKeysMetadata(
+ Collections.emptyList()))
+ .hasMessageContaining("Selected keys must not be empty.");
+ assertThatThrownBy(
+ () ->
+
MapSelectedKeysMetadataUtils.buildMapSelectedKeysMetadata(
+ Arrays.asList("key1", null)))
+ .hasMessageContaining("Selected key must not be null.");
+ assertThatThrownBy(
+ () ->
+
MapSelectedKeysMetadataUtils.buildMapSelectedKeysMetadata(
+ Arrays.asList("key;1")))
+ .hasMessageContaining("Selected key must not contain ';'");
+ assertThatThrownBy(() ->
MapSelectedKeysMetadataUtils.selectedKeys("invalid"))
+ .hasMessageContaining("Invalid selected-key MAP metadata");
+ assertThatThrownBy(
+ () ->
+ MapSelectedKeysMetadataUtils.withSelectedKeys(
+ DataTypes.FIELD(
+ 1,
+ "attrs",
+ DataTypes.MAP(
+ DataTypes.STRING(),
DataTypes.BIGINT())),
+ DataTypes.BIGINT(),
+ Arrays.asList("key1")))
+ .hasMessageContaining("Selected-key MAP read type must be
ROW.");
+ }
+}
diff --git
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
index e1fd258186..fab2cfe861 100644
---
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
+++
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
@@ -36,5 +36,6 @@ case class PaimonScan(
override val pushedHybridSearch: Option[HybridSearch] = None,
override val pushedFullTextSearch: Option[FullTextSearch] = None,
override val pushedVariantExtractions: Map[Seq[String],
Seq[VariantExtractionInfo]] = Map.empty,
+ override val pushedMapSelectedKeys: Map[String, Seq[String]] = Map.empty,
bucketedScanDisabled: Boolean = true)
extends PaimonBaseScan(table) {}
diff --git
a/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
new file mode 100644
index 0000000000..bc4f413e46
--- /dev/null
+++
b/paimon-spark/paimon-spark-3.2/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
@@ -0,0 +1,34 @@
+/*
+ * 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.paimon.spark.catalyst.optimizer
+
+import org.apache.paimon.spark.PaimonScan
+
+import org.apache.spark.sql.catalyst.expressions.AttributeReference
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
+
+object PushDownMapSelectedKeys extends PushDownMapSelectedKeysBase {
+
+ override protected def copyDataSourceV2ScanRelation(
+ relation: DataSourceV2ScanRelation,
+ scan: PaimonScan,
+ output: Seq[AttributeReference]): DataSourceV2ScanRelation = {
+ DataSourceV2ScanRelation(relation.relation, scan, output)
+ }
+}
diff --git
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
index 6ecc3dd847..9cc7e409c7 100644
---
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
+++
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
@@ -40,6 +40,7 @@ case class PaimonScan(
override val pushedHybridSearch: Option[HybridSearch] = None,
override val pushedFullTextSearch: Option[FullTextSearch] = None,
override val pushedVariantExtractions: Map[Seq[String],
Seq[VariantExtractionInfo]] = Map.empty,
+ override val pushedMapSelectedKeys: Map[String, Seq[String]] = Map.empty,
bucketedScanDisabled: Boolean = false)
extends PaimonBaseScan(table)
with SupportsReportPartitioning {
diff --git
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
new file mode 100644
index 0000000000..823c32a302
--- /dev/null
+++
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
@@ -0,0 +1,34 @@
+/*
+ * 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.paimon.spark.catalyst.optimizer
+
+import org.apache.paimon.spark.PaimonScan
+
+import org.apache.spark.sql.catalyst.expressions.AttributeReference
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
+
+object PushDownMapSelectedKeys extends PushDownMapSelectedKeysBase {
+
+ override protected def copyDataSourceV2ScanRelation(
+ relation: DataSourceV2ScanRelation,
+ scan: PaimonScan,
+ output: Seq[AttributeReference]): DataSourceV2ScanRelation = {
+ DataSourceV2ScanRelation(relation.relation, scan, output,
relation.keyGroupedPartitioning)
+ }
+}
diff --git
a/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
new file mode 100644
index 0000000000..88e6347cee
--- /dev/null
+++
b/paimon-spark/paimon-spark-3.4/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
@@ -0,0 +1,39 @@
+/*
+ * 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.paimon.spark.catalyst.optimizer
+
+import org.apache.paimon.spark.PaimonScan
+
+import org.apache.spark.sql.catalyst.expressions.AttributeReference
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
+
+object PushDownMapSelectedKeys extends PushDownMapSelectedKeysBase {
+
+ override protected def copyDataSourceV2ScanRelation(
+ relation: DataSourceV2ScanRelation,
+ scan: PaimonScan,
+ output: Seq[AttributeReference]): DataSourceV2ScanRelation = {
+ DataSourceV2ScanRelation(
+ relation.relation,
+ scan,
+ output,
+ relation.keyGroupedPartitioning,
+ relation.ordering)
+ }
+}
diff --git
a/paimon-spark/paimon-spark-3.5/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
b/paimon-spark/paimon-spark-3.5/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
new file mode 100644
index 0000000000..88e6347cee
--- /dev/null
+++
b/paimon-spark/paimon-spark-3.5/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
@@ -0,0 +1,39 @@
+/*
+ * 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.paimon.spark.catalyst.optimizer
+
+import org.apache.paimon.spark.PaimonScan
+
+import org.apache.spark.sql.catalyst.expressions.AttributeReference
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
+
+object PushDownMapSelectedKeys extends PushDownMapSelectedKeysBase {
+
+ override protected def copyDataSourceV2ScanRelation(
+ relation: DataSourceV2ScanRelation,
+ scan: PaimonScan,
+ output: Seq[AttributeReference]): DataSourceV2ScanRelation = {
+ DataSourceV2ScanRelation(
+ relation.relation,
+ scan,
+ output,
+ relation.keyGroupedPartitioning,
+ relation.ordering)
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
index 412d92b7f4..1f0b1b75b0 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
@@ -46,6 +46,7 @@ case class PaimonScan(
override val pushedHybridSearch: Option[HybridSearch] = None,
override val pushedFullTextSearch: Option[FullTextSearch] = None,
override val pushedVariantExtractions: Map[Seq[String],
Seq[VariantExtractionInfo]] = Map.empty,
+ override val pushedMapSelectedKeys: Map[String, Seq[String]] = Map.empty,
bucketedScanDisabled: Boolean = false)
extends PaimonBaseScan(table)
with SupportsReportPartitioning
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
new file mode 100644
index 0000000000..16db25531f
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
@@ -0,0 +1,289 @@
+/*
+ * 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.paimon.spark.catalyst.optimizer
+
+import org.apache.paimon.CoreOptions
+import org.apache.paimon.CoreOptions.MapStorageLayout
+import org.apache.paimon.data.shredding.MapSelectedKeysMetadataUtils
+import org.apache.paimon.data.shredding.MapSharedShreddingUtils
+import org.apache.paimon.spark.PaimonScan
+
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute,
AttributeReference, Expression, ExprId, GetMapValue, GetStructField, Literal,
NamedExpression}
+import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, Project}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
+import org.apache.spark.sql.types.{MapType, StringType, StructField,
StructType}
+
+import scala.collection.mutable
+
+/**
+ * Pushes literal-key MAP access on shared-shredding MAP columns into Paimon
scan read type.
+ *
+ * <p>Example: {@code SELECT id, attrs['key1'], attrs['key2'] FROM T} becomes
a scan returning
+ * {@code attrs} as {@code struct<0:valueType,1:valueType>} and a project
reading struct fields.
+ */
+object PushDownMapSelectedKeys extends Rule[LogicalPlan] {
+
+ override def apply(plan: LogicalPlan): LogicalPlan = {
+ plan
+ }
+}
+
+abstract class PushDownMapSelectedKeysBase extends Rule[LogicalPlan] {
+
+ override def apply(plan: LogicalPlan): LogicalPlan = plan.transform {
+ case project @ Project(projectList, relation: DataSourceV2ScanRelation) =>
+ relation.scan match {
+ case scan: PaimonScan =>
+ rewriteProject(project, projectList, relation,
scan).getOrElse(project)
+ case _ => project
+ }
+ }
+
+ private def rewriteProject(
+ project: Project,
+ projectList: Seq[NamedExpression],
+ relation: DataSourceV2ScanRelation,
+ scan: PaimonScan): Option[LogicalPlan] = {
+ val candidates = collectCandidates(projectList, scan)
+ if (candidates.isEmpty) {
+ return None
+ }
+
+ val outputByExprId = relation.output.map(a => a.exprId -> a).toMap
+ val selectedMaps = mutable.LinkedHashMap.empty[ExprId, SelectedMap]
+ candidates.foreach {
+ candidate =>
+ outputByExprId.get(candidate.rootExprId).foreach {
+ root =>
+ val selectedMap =
+ selectedMaps.getOrElseUpdate(
+ root.exprId,
+ SelectedMap(
+ root,
+ candidate.fieldName,
+ candidate.mapType,
+ mutable.ArrayBuffer.empty))
+ if (!selectedMap.keys.contains(candidate.key)) {
+ selectedMap.keys.append(candidate.key)
+ }
+ }
+ }
+ if (selectedMaps.isEmpty) {
+ return None
+ }
+
+ val selectedByExprId = selectedMaps.toMap
+ val attrToRewritten = selectedByExprId.map {
+ case (exprId, selected) =>
+ exprId ->
selected.root.withDataType(selected.structType).asInstanceOf[AttributeReference]
+ }
+
+ val rewriteState = RewriteState(selectedByExprId, attrToRewritten)
+ val rewrittenProjectList = projectList.map(rewriteExpression(_,
rewriteState))
+ if (rewriteState.failed) {
+ return None
+ }
+
+ val pushedMapSelectedKeys = scan.pushedMapSelectedKeys ++
selectedMaps.values.map {
+ selected => selected.fieldName -> selected.keys.toSeq
+ }.toMap
+ val rewrittenScan = scan.copy(pushedMapSelectedKeys =
pushedMapSelectedKeys)
+ val rewrittenOutput =
+ relation.output.map(attr => attrToRewritten.getOrElse(attr.exprId, attr))
+ val rewrittenRelation = copyDataSourceV2ScanRelation(relation,
rewrittenScan, rewrittenOutput)
+ Some(project.copy(projectList = rewrittenProjectList, child =
rewrittenRelation))
+ }
+
+ protected def copyDataSourceV2ScanRelation(
+ relation: DataSourceV2ScanRelation,
+ scan: PaimonScan,
+ output: Seq[AttributeReference]): DataSourceV2ScanRelation
+
+ private def collectCandidates(
+ projectList: Seq[NamedExpression],
+ scan: PaimonScan): Seq[MapKeyCandidate] = {
+ val candidates = mutable.ArrayBuffer.empty[MapKeyCandidate]
+ projectList.foreach {
+ expr =>
+ expr.foreach {
+ case mapKeyValueAccess(access) if canPushDown(scan, access) =>
+ candidates.append(
+ MapKeyCandidate(access.root.exprId, access.fieldName,
access.mapType, access.key))
+ case _ =>
+ }
+ }
+ candidates.toSeq
+ }
+
+ private def rewriteExpression(
+ expression: NamedExpression,
+ state: RewriteState): NamedExpression = {
+ expression match {
+ case alias: Alias =>
+ alias.child match {
+ case mapKeyValueAccess(access) if
state.selectedByExprId.contains(access.root.exprId) =>
+ rewriteMapKeyAccess(access, state) match {
+ case Some(rewritten) =>
+
alias.withNewChildren(Seq(rewritten)).asInstanceOf[NamedExpression]
+ case None => expression
+ }
+ case _ =>
+ if (hasSelectedMapReference(alias, state)) {
+ state.failed = true
+ }
+ expression
+ }
+ case attr: Attribute if state.hasRootSelection(attr.exprId) =>
+ state.failed = true
+ expression
+ case _ =>
+ if (hasSelectedMapReference(expression, state)) {
+ state.failed = true
+ }
+ expression
+ }
+ }
+
+ private def rewriteMapKeyAccess(access: MapKeyAccess, state: RewriteState):
Option[Expression] = {
+ val selected = state.selectedByExprId(access.root.exprId)
+ val ordinal = selected.keys.indexOf(access.key)
+ if (ordinal < 0) {
+ state.failed = true
+ None
+ } else {
+ Some(GetStructField(state.attrToRewritten(access.root.exprId), ordinal,
Some(access.key)))
+ }
+ }
+
+ private def hasSelectedMapReference(expression: Expression, state:
RewriteState): Boolean = {
+ var found = false
+ expression.foreach {
+ case mapFieldReference(access) if
state.selectedByExprId.contains(access.root.exprId) =>
+ found = true
+ case _ =>
+ }
+ found
+ }
+
+ private def canPushDown(scan: PaimonScan, access: MapKeyAccess): Boolean = {
+ if (!canEncodeKey(access.key)) {
+ return false
+ }
+
+ access.mapType match {
+ case MapType(StringType, _, _) =>
+ fieldType(scan.table.rowType(), access.fieldName) match {
+ case Some(mapType: org.apache.paimon.types.MapType) if
isStringKeyMap(mapType) =>
+
CoreOptions.fromMap(scan.table.options()).mapStorageLayout(access.fieldName) ==
+ MapStorageLayout.SHARED_SHREDDING
+ case _ => false
+ }
+ case _ => false
+ }
+ }
+
+ private def canEncodeKey(key: String): Boolean = {
+ !key.contains(MapSelectedKeysMetadataUtils.KEY_DELIMITER) &&
+ !key.startsWith(MapSelectedKeysMetadataUtils.METADATA_KEY)
+ }
+
+ private def isStringKeyMap(mapType: org.apache.paimon.types.MapType):
Boolean = {
+ MapSharedShreddingUtils.isShreddingKeyMap(mapType)
+ }
+
+ private object mapKeyValueAccess {
+ def unapply(expression: Expression): Option[MapKeyAccess] = {
+ expression match {
+ case GetMapValue(mapFieldReference(access), StringLiteral(key)) =>
+ Some(access.copy(key = key))
+ case e if e.prettyName == "element_at" && e.children.length == 2 =>
+ (e.children.head, e.children(1)) match {
+ case (mapFieldReference(access), StringLiteral(key)) =>
Some(access.copy(key = key))
+ case _ => None
+ }
+ case _ => None
+ }
+ }
+ }
+
+ private object mapFieldReference {
+ def unapply(expression: Expression): Option[MapKeyAccess] = {
+ expression match {
+ case attr: Attribute =>
+ attr.dataType match {
+ case mapType: MapType => Some(MapKeyAccess(attr, attr.name,
mapType, ""))
+ case _ => None
+ }
+ case _ => None
+ }
+ }
+ }
+
+ private object StringLiteral {
+ def unapply(expression: Expression): Option[String] = {
+ expression match {
+ case Literal(value, StringType) if value != null =>
Some(value.toString)
+ case _ => None
+ }
+ }
+ }
+
+ private def fieldType(
+ rowType: org.apache.paimon.types.RowType,
+ fieldName: String): Option[org.apache.paimon.types.DataType] = {
+ if (!rowType.containsField(fieldName)) {
+ None
+ } else {
+ Some(rowType.getField(fieldName).`type`())
+ }
+ }
+
+ private case class MapKeyCandidate(
+ rootExprId: ExprId,
+ fieldName: String,
+ mapType: MapType,
+ key: String)
+
+ private case class MapKeyAccess(root: Attribute, fieldName: String, mapType:
MapType, key: String)
+
+ private case class SelectedMap(
+ root: Attribute,
+ fieldName: String,
+ mapType: MapType,
+ keys: mutable.ArrayBuffer[String]) {
+ def structType: StructType = {
+ val MapType(_, valueType, _) = mapType
+ StructType(keys.zipWithIndex.map {
+ case (_, ordinal) =>
+ StructField(ordinal.toString, valueType, nullable = true)
+ }.toSeq)
+ }
+ }
+
+ private case class RewriteState(
+ selectedByExprId: Map[ExprId, SelectedMap],
+ attrToRewritten: Map[ExprId, AttributeReference]) {
+ var failed: Boolean = false
+
+ def hasRootSelection(exprId: ExprId): Boolean = {
+ selectedByExprId.contains(exprId)
+ }
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
index 61481e201c..388889bbea 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/extensions/PaimonSparkSessionExtensions.scala
@@ -101,6 +101,8 @@ class PaimonSparkSessionExtensions extends
(SparkSessionExtensions => Unit) {
// optimization rules
extensions.injectOptimizerRule(spark => ReplacePaimonFunctions(spark))
extensions.injectOptimizerRule(_ =>
OptimizeMetadataOnlyDeleteFromPaimonTable)
+ // TODO: Enable MAP selected-key pushdown after core reader supports
+ // __PAIMON_MAP_SELECTED_KEYS read type.
extensions.injectOptimizerRule(_ => MergePaimonScalarSubqueries)
extensions.injectOptimizerRule(_ => PushDownLateralVectorSearchFilter)
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
index b36a0f1f02..088b785c7b 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BaseScan.scala
@@ -54,6 +54,7 @@ trait BaseScan extends Scan with SupportsReportStatistics
with Logging {
def pushedHybridSearch: Option[HybridSearch] = None
def pushedFullTextSearch: Option[FullTextSearch] = None
def pushedVariantExtractions: Map[Seq[String], Seq[VariantExtractionInfo]] =
Map.empty
+ def pushedMapSelectedKeys: Map[String, Seq[String]] = Map.empty
// Runtime push down
val pushedRuntimePartitionFilters: ListBuffer[PartitionPredicate] =
ListBuffer.empty
@@ -80,7 +81,10 @@ trait BaseScan extends Scan with SupportsReportStatistics
with Logging {
}
}
- /** Pruned read RowType, with variant fields rewritten if variant pushdown
was accepted. */
+ /**
+ * Pruned read RowType, with variant fields rewritten if variant pushdown
was accepted, with
+ * accepted nested field pushdowns rewritten.
+ */
private[paimon] val (readTableRowType, metadataFields) = {
requiredSchema.fields.foreach(f => checkMetadataColumn(f.name))
val (_requiredTableFields, _metadataFields) =
@@ -90,7 +94,10 @@ trait BaseScan extends Scan with SupportsReportStatistics
with Logging {
val withVariants =
if (pushedVariantExtractions.isEmpty) pruned
else VariantPushDownUtils.rewriteRowType(pruned,
pushedVariantExtractions)
- (withVariants, _metadataFields)
+ val withMapSelectedKeys =
+ if (pushedMapSelectedKeys.isEmpty) withVariants
+ else MapSelectedKeysPushDownUtils.rewriteRowType(withVariants,
pushedMapSelectedKeys)
+ (withMapSelectedKeys, _metadataFields)
}
private def checkMetadataColumn(fieldName: String): Unit = {
@@ -198,6 +205,13 @@ trait BaseScan extends Scan with SupportsReportStatistics
with Logging {
.describeRewrittenRowType(readTableRowType)
.map(s => s", PushedVariants: [$s]")
.getOrElse("")
+ val pushedMapSelectedKeysStr =
+ if (pushedMapSelectedKeys.isEmpty) ""
+ else
+ MapSelectedKeysPushDownUtils
+ .describeRewrittenRowType(readTableRowType)
+ .map(s => s", PushedMapSelectedKeys: [$s]")
+ .getOrElse("")
s"${getClass.getSimpleName}: [${table.name}]" +
pushedPartitionFiltersStr +
pushedRuntimePartitionFiltersStr +
@@ -207,6 +221,7 @@ trait BaseScan extends Scan with SupportsReportStatistics
with Logging {
pushedVectorSearch.map(vs => s", VectorSearch: [$vs]").getOrElse("") +
pushedHybridSearch.map(hs => s", HybridSearch: [$hs]").getOrElse("") +
pushedFullTextSearch.map(fts => s", FullTextSearch:
[$fts]").getOrElse("") +
- pushedVariantsStr
+ pushedVariantsStr +
+ pushedMapSelectedKeysStr
}
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/MapSelectedKeysPushDownUtils.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/MapSelectedKeysPushDownUtils.scala
new file mode 100644
index 0000000000..bb131d2e78
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/MapSelectedKeysPushDownUtils.scala
@@ -0,0 +1,71 @@
+/*
+ * 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.paimon.spark.read
+
+import org.apache.paimon.data.shredding.MapSelectedKeysMetadataUtils
+import org.apache.paimon.types.{DataField, MapType, RowType}
+
+import scala.collection.JavaConverters._
+import scala.collection.mutable
+
+/** Shared-shredding MAP selected-key pushdown business logic. */
+object MapSelectedKeysPushDownUtils {
+
+ /** Replace accepted top-level MAP fields with selected-key ROW fields in
the read type. */
+ def rewriteRowType(rt: RowType, accepted: Map[String, Seq[String]]): RowType
= {
+ val newFields = rt.getFields.asScala.map(f => rewriteField(f,
accepted)).asJava
+ new RowType(rt.isNullable, newFields)
+ }
+
+ private def rewriteField(field: DataField, accepted: Map[String,
Seq[String]]): DataField = {
+ accepted.get(field.name()) match {
+ case Some(keys) =>
+ field.`type`() match {
+ case mapType: MapType =>
+ val selectedFields = keys.zipWithIndex.map {
+ case (_, ordinal) =>
+ new DataField(ordinal, ordinal.toString,
mapType.getValueType.copy(true))
+ }.asJava
+ MapSelectedKeysMetadataUtils.withSelectedKeys(
+ field,
+ new RowType(field.`type`().isNullable, selectedFields),
+ keys.asJava)
+ case _ => field
+ }
+ case None => field
+ }
+ }
+
+ /** "col=[key1,key2]" rendering for `Scan.description()`. */
+ def describeRewrittenRowType(rt: RowType): Option[String] = {
+ val parts = mutable.ArrayBuffer.empty[String]
+ collectSelectedKeys(rt, parts)
+ if (parts.isEmpty) None else Some(parts.mkString(", "))
+ }
+
+ private def collectSelectedKeys(rt: RowType, out:
mutable.ArrayBuffer[String]): Unit = {
+ rt.getFields.asScala.foreach {
+ field =>
+ if (MapSelectedKeysMetadataUtils.isMapSelectedKeysField(field)) {
+ val keys =
MapSelectedKeysMetadataUtils.selectedKeys(field.description()).asScala
+ out.append(s"${field.name()}=[${keys.mkString(",")}]")
+ }
+ }
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/read/MapSelectedKeysPushDownUtilsTest.scala
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/read/MapSelectedKeysPushDownUtilsTest.scala
new file mode 100644
index 0000000000..d3019baa59
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/read/MapSelectedKeysPushDownUtilsTest.scala
@@ -0,0 +1,80 @@
+/*
+ * 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.paimon.spark.read
+
+import org.apache.paimon.data.shredding.MapSelectedKeysMetadataUtils
+import org.apache.paimon.types.{DataTypes, MapType, RowType}
+
+import org.scalatest.funsuite.AnyFunSuite
+
+import scala.collection.JavaConverters._
+
+/** Tests for {@link MapSelectedKeysPushDownUtils}. */
+class MapSelectedKeysPushDownUtilsTest extends AnyFunSuite {
+
+ test("rewrite map field with selected keys") {
+ val rowType = DataTypes.ROW(
+ DataTypes.FIELD(0, "id", DataTypes.INT()),
+ DataTypes.FIELD(1, "attrs", DataTypes.MAP(DataTypes.STRING(),
DataTypes.BIGINT().notNull())),
+ DataTypes.FIELD(2, "tags", DataTypes.MAP(DataTypes.STRING(),
DataTypes.STRING()))
+ )
+
+ val rewritten = MapSelectedKeysPushDownUtils.rewriteRowType(
+ rowType,
+ Map("attrs" -> Seq("key1", "key2"))
+ )
+
+ assert(rewritten.getField("id").`type`() == DataTypes.INT())
+ assert(rewritten.getField("tags").`type`().isInstanceOf[MapType])
+
+ val attrs = rewritten.getField("attrs")
+ assert(attrs.description() == "__PAIMON_MAP_SELECTED_KEYS:key1;key2")
+ assert(MapSelectedKeysMetadataUtils.isMapSelectedKeysField(attrs))
+ assert(
+ MapSelectedKeysMetadataUtils.selectedKeys(attrs.description()).asScala
== Seq("key1", "key2"))
+
+ val attrsRowType = attrs.`type`().asInstanceOf[RowType]
+ assert(attrsRowType.getFieldNames.asScala == Seq("0", "1"))
+ assert(attrsRowType.getField("0").`type`() == DataTypes.BIGINT())
+ assert(attrsRowType.getField("1").`type`() == DataTypes.BIGINT())
+ assert(attrsRowType.getField("0").`type`().isNullable)
+ assert(attrsRowType.getField("1").`type`().isNullable)
+ }
+
+ test("ignore nested map field with selected keys") {
+ val nestedType = DataTypes.ROW(
+ DataTypes.FIELD(2, "name", DataTypes.STRING()),
+ DataTypes.FIELD(3, "attrs", DataTypes.MAP(DataTypes.STRING(),
DataTypes.DOUBLE()))
+ )
+ val rowType = DataTypes.ROW(
+ DataTypes.FIELD(0, "id", DataTypes.INT()),
+ DataTypes.FIELD(1, "profile", nestedType)
+ )
+
+ val rewritten = MapSelectedKeysPushDownUtils.rewriteRowType(
+ rowType,
+ Map("profile.attrs" -> Seq("key1", "key2"))
+ )
+
+ val profile = rewritten.getField("profile").`type`().asInstanceOf[RowType]
+ assert(profile.getField("name").`type`() == DataTypes.STRING())
+ assert(profile.getField("attrs").description() == null)
+ assert(profile.getField("attrs").`type`().isInstanceOf[MapType])
+ }
+}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonOptimizationTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonOptimizationTestBase.scala
index 178006ff1a..0550e1e4e6 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonOptimizationTestBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonOptimizationTestBase.scala
@@ -19,9 +19,10 @@
package org.apache.paimon.spark.sql
import org.apache.paimon.Snapshot.CommitKind
-import org.apache.paimon.spark.PaimonSparkTestBase
+import org.apache.paimon.spark.{PaimonScan, PaimonSparkTestBase}
import org.apache.paimon.spark.catalyst.analysis.expressions.ExpressionHelper
import org.apache.paimon.spark.catalyst.optimizer.MergePaimonScalarSubqueries
+import org.apache.paimon.spark.catalyst.optimizer.PushDownMapSelectedKeys
import org.apache.paimon.spark.execution.TruncatePaimonTableWithFilterExec
import org.apache.spark.sql.{DataFrame, PaimonUtils, Row}
@@ -29,6 +30,7 @@ import org.apache.spark.sql.catalyst.expressions.{Attribute,
CreateNamedStruct,
import org.apache.spark.sql.catalyst.plans.logical.{CTERelationDef,
LogicalPlan, OneRowRelation, WithCTE}
import org.apache.spark.sql.catalyst.rules.RuleExecutor
import org.apache.spark.sql.execution.CommandResultExec
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
import org.apache.spark.sql.functions._
import org.junit.jupiter.api.Assertions
@@ -118,6 +120,44 @@ abstract class PaimonOptimizationTestBase extends
PaimonSparkTestBase with Expre
}
}
+ test("Paimon Optimization: map selected-key pushdown only supports
shared-shredding map") {
+ withTable("T") {
+ spark.sql("CREATE TABLE T (id INT, attrs MAP<STRING, BIGINT>)")
+
+ val normalMapScan = mapSelectedKeysPaimonScan("SELECT attrs['key1'] FROM
T")
+ Assertions.assertTrue(normalMapScan.pushedMapSelectedKeys.isEmpty)
+ }
+
+ withTable("T") {
+ spark.sql("CREATE TABLE T (id INT, profile STRUCT<attrs: MAP<STRING,
BIGINT>>)")
+
+ val nestedMapScan = mapSelectedKeysPaimonScan("SELECT
profile.attrs['key1'] FROM T")
+ Assertions.assertTrue(nestedMapScan.pushedMapSelectedKeys.isEmpty)
+ }
+
+ withTable("T") {
+ spark.sql("""
+ |CREATE TABLE T (id INT, attrs MAP<STRING, BIGINT>)
+ |TBLPROPERTIES (
+ | 'fields.attrs.map.storage-layout' = 'shared-shredding'
+ |)
+ |""".stripMargin)
+
+ val sharedShreddingMapScan =
+ mapSelectedKeysPaimonScan("SELECT attrs['key1'], attrs['key2'] FROM T")
+ Assertions.assertEquals(
+ Map("attrs" -> Seq("key1", "key2")),
+ sharedShreddingMapScan.pushedMapSelectedKeys)
+
+ val delimiterKeyScan = mapSelectedKeysPaimonScan("SELECT attrs['a;b']
FROM T")
+ Assertions.assertTrue(delimiterKeyScan.pushedMapSelectedKeys.isEmpty)
+
+ val metadataPrefixKeyScan =
+ mapSelectedKeysPaimonScan("SELECT
attrs['__PAIMON_MAP_SELECTED_KEYS:key1'] FROM T")
+
Assertions.assertTrue(metadataPrefixKeyScan.pushedMapSelectedKeys.isEmpty)
+ }
+ }
+
test(s"Paimon Optimization: optimize metadata only delete") {
for (useV2Write <- Seq("true", "false")) {
withSparkSQLConf("spark.paimon.write.use-v2-write" -> useV2Write) {
@@ -233,6 +273,12 @@ abstract class PaimonOptimizationTestBase extends
PaimonSparkTestBase with Expre
def extractorExpression(cteIndex: Int, output: Seq[Attribute], fieldIndex:
Int): NamedExpression
+ private def mapSelectedKeysPaimonScan(sqlText: String): PaimonScan = {
+
PushDownMapSelectedKeys(sql(sqlText).queryExecution.optimizedPlan).collectFirst
{
+ case relation: DataSourceV2ScanRelation =>
relation.scan.asInstanceOf[PaimonScan]
+ }.get
+ }
+
def checkTruncatePaimonTable(df: DataFrame): Unit = {
val plan =
df.queryExecution.executedPlan.asInstanceOf[CommandResultExec].commandPhysicalPlan
assert(plan.isInstanceOf[TruncatePaimonTableWithFilterExec])
diff --git
a/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
new file mode 100644
index 0000000000..88e6347cee
--- /dev/null
+++
b/paimon-spark/paimon-spark4-common/src/main/scala/org/apache/paimon/spark/catalyst/optimizer/PushDownMapSelectedKeys.scala
@@ -0,0 +1,39 @@
+/*
+ * 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.paimon.spark.catalyst.optimizer
+
+import org.apache.paimon.spark.PaimonScan
+
+import org.apache.spark.sql.catalyst.expressions.AttributeReference
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
+
+object PushDownMapSelectedKeys extends PushDownMapSelectedKeysBase {
+
+ override protected def copyDataSourceV2ScanRelation(
+ relation: DataSourceV2ScanRelation,
+ scan: PaimonScan,
+ output: Seq[AttributeReference]): DataSourceV2ScanRelation = {
+ DataSourceV2ScanRelation(
+ relation.relation,
+ scan,
+ output,
+ relation.keyGroupedPartitioning,
+ relation.ordering)
+ }
+}