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 012c99d6f7 [spark][flink] Add import_files procedure with external
paths (#10423)
012c99d6f7 is described below
commit 012c99d6f76e69cea076ddacb7209cfd1dc25717
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 8 15:29:19 2026 +0800
[spark][flink] Add import_files procedure with external paths (#10423)
---
docs/docs/flink/procedures.md | 2 +-
docs/docs/flink/procedures/table-operations.md | 41 +++
docs/docs/spark/procedures.md | 3 +-
docs/docs/spark/procedures/migration.md | 38 ++
.../paimon/migrate/ExternalFileImporter.java | 148 ++++++++
.../paimon/migrate/ExternalFileImporterTest.java | 386 +++++++++++++++++++++
.../flink/procedure/ImportFilesProcedure.java | 50 +++
.../services/org.apache.paimon.factories.Factory | 1 +
.../procedure/ImportFilesProcedureITCase.java | 102 ++++++
.../org/apache/paimon/spark/SparkProcedures.java | 2 +
.../spark/procedure/ImportFilesProcedure.java | 101 ++++++
.../spark/procedure/ImportFilesProcedureTest.scala | 111 ++++++
12 files changed, 983 insertions(+), 2 deletions(-)
diff --git a/docs/docs/flink/procedures.md b/docs/docs/flink/procedures.md
index b248d8ab1c..883324e889 100644
--- a/docs/docs/flink/procedures.md
+++ b/docs/docs/flink/procedures.md
@@ -69,7 +69,7 @@ defaults are specific to the procedure.
| [Compaction and Layout](./procedures/compaction) |
[`compact`](./procedures/compaction#compact),
[`compact_database`](./procedures/compaction#compact_database),
[`compact_chain_table`](./procedures/compaction#compact_chain_table),
[`compact_manifest`](./procedures/compaction#compact_manifest),
[`rescale`](./procedures/compaction#rescale),
[`materialize_deletion_vectors`](./procedures/compaction#materialize_deletion_vectors),
[`reassign_row_id`](./procedures/compaction#reassign_row_id) |
| [Snapshots and Retention](./procedures/snapshots) |
[`expire_snapshots`](./procedures/snapshots#expire_snapshots),
[`expire_changelogs`](./procedures/snapshots#expire_changelogs),
[`expire_partitions`](./procedures/snapshots#expire_partitions),
[`rollback_to`](./procedures/snapshots#rollback_to),
[`rollback_to_as_latest`](./procedures/snapshots#rollback_to_as_latest),
[`rollback_to_timestamp`](./procedures/snapshots#rollback_to_timestamp),
[`rollback_to_watermark`](./procedures/snapsho [...]
| [Tags and Branches](./procedures/tags-and-branches) |
[`create_tag`](./procedures/tags-and-branches#create_tag),
[`create_tag_from_timestamp`](./procedures/tags-and-branches#create_tag_from_timestamp),
[`create_tag_from_watermark`](./procedures/tags-and-branches#create_tag_from_watermark),
[`replace_tag`](./procedures/tags-and-branches#replace_tag),
[`rename_tag`](./procedures/tags-and-branches#rename_tag),
[`delete_tag`](./procedures/tags-and-branches#delete_tag), [`expire_tags`](./pr
[...]
-| [Table Operations](./procedures/table-operations) |
[`merge_into`](./procedures/table-operations#merge_into),
[`data_evolution_merge_into`](./procedures/table-operations#data_evolution_merge_into),
[`migrate_database`](./procedures/table-operations#migrate_database),
[`migrate_table`](./procedures/table-operations#migrate_table),
[`clone`](./procedures/table-operations#clone),
[`copy_files`](./procedures/table-operations#copy_files),
[`alter_column_default_value`](./procedures/table-op [...]
+| [Table Operations](./procedures/table-operations) |
[`merge_into`](./procedures/table-operations#merge_into),
[`data_evolution_merge_into`](./procedures/table-operations#data_evolution_merge_into),
[`migrate_database`](./procedures/table-operations#migrate_database),
[`migrate_table`](./procedures/table-operations#migrate_table),
[`import_files`](./procedures/table-operations#import_files),
[`clone`](./procedures/table-operations#clone),
[`copy_files`](./procedures/table-operations#cop [...]
| [Indexes and Search](./procedures/indexes) |
[`create_global_index`](./procedures/indexes#create_global_index),
[`drop_global_index`](./procedures/indexes#drop_global_index),
[`full_text_search`](./procedures/indexes#full_text_search),
[`vector_search`](./procedures/indexes#vector_search),
[`rewrite_file_index`](./procedures/indexes#rewrite_file_index) |
| [Consumers and Query Service](./procedures/consumers) |
[`reset_consumer`](./procedures/consumers#reset_consumer),
[`clear_consumers`](./procedures/consumers#clear_consumers),
[`query_service`](./procedures/consumers#query_service) |
| [Cleanup and Repair](./procedures/repair) |
[`remove_orphan_files`](./procedures/repair#remove_orphan_files),
[`remove_orphan_blobs`](./procedures/repair#remove_orphan_blobs),
[`remove_unexisting_files`](./procedures/repair#remove_unexisting_files),
[`remove_unexisting_manifests`](./procedures/repair#remove_unexisting_manifests),
[`repair`](./procedures/repair#repair),
[`repair_earliest_snapshot`](./procedures/repair#repair_earliest_snapshot) |
diff --git a/docs/docs/flink/procedures/table-operations.md
b/docs/docs/flink/procedures/table-operations.md
index 6eaff3df24..503796f269 100644
--- a/docs/docs/flink/procedures/table-operations.md
+++ b/docs/docs/flink/procedures/table-operations.md
@@ -28,6 +28,47 @@ Merge records, migrate or clone tables, and manage defaults
and partitions.
See [Procedures](../procedures) for Flink version requirements, argument
conventions, and catalog selection.
+## import_files
+
+Append existing files to one partition by recording their original paths in
+`DataFileMeta.externalPath`. The procedure opens source files to extract their
actual row counts
+and available column statistics, using the same metadata extractors as
`migrate_table`. It does not
+copy, rename, or restore files. Archived files must be restored and readable
before import.
+
+**Arguments**
+
+- `table` (`STRING`, required): target Paimon table identifier.
+- `location` (`STRING`, required): absolute source directory path.
+- `partition` (`STRING`, optional): complete partition specification, such as
`dt=2026-10-08,hour=12`.
+ Required for partitioned tables; omit it for an unpartitioned table.
+
+```sql
+CALL sys.import_files(
+ `table` => 'default.T',
+ location => 'oss://bucket/external-files/',
+ `partition` => 'dt=2026-10-08,hour=12'
+);
+
+-- Unpartitioned table.
+CALL sys.import_files('default.unpartitioned_table',
'oss://bucket/external-files/');
+```
+
+Returns the number of registered files. Only files directly in the directory
with a suffix matching
+the table's `file.format` are imported; hidden files and subdirectories are
skipped. An empty
+directory returns zero without creating a snapshot. Repeated directory entries
are deduplicated by
+their full external paths. Files already referenced by the current table
snapshot are rejected before
+reading any source file. Avoid concurrent imports of the same directory.
+
+The target must be an append table with `bucket = -1`, without row tracking or
data evolution.
+Source files must match the target format and non-partition columns. Partition
values come from
+the supplied specification, and source locations must be accessible through
the table's FileIO.
+
+Metadata extraction respects the table's `metadata.stats-mode`; row counts are
collected even when
+column statistics are disabled. If any selected file cannot be read or its
metadata cannot be
+extracted, the import fails without committing a snapshot. Do not modify
registered source files.
+Imported files participate in the normal Paimon lifecycle, including deletion
after compaction or
+partition removal and snapshot expiration.
+
## merge_into
To perform "MERGE INTO" syntax. See [merge_into
action](../action-jars#merging-into-table) for details of arguments.
diff --git a/docs/docs/spark/procedures.md b/docs/docs/spark/procedures.md
index 441033b332..70f329b3a4 100644
--- a/docs/docs/spark/procedures.md
+++ b/docs/docs/spark/procedures.md
@@ -50,7 +50,7 @@ Choose a group, then use the page contents to jump to a
procedure:
| --- | --- |
| [Compaction and cleanup](./procedures/maintenance) | Compact files and
manifests, rescale buckets, expire history, and repair metadata. |
| [Tags, branches, and rollback](./procedures/versions) | Create and manage
versions, merge branches, or restore table state. |
-| [Migration and copy](./procedures/migration) | Migrate Hive tables or copy
Paimon files. |
+| [Migration and copy](./procedures/migration) | Migrate Hive tables, import
external files, or copy Paimon files. |
| [Indexes and row IDs](./procedures/indexes) | Build or drop indexes and
reassign row IDs. |
| [Consumers, views, and functions](./procedures/metadata) | Manage reader
progress, partition completion, and catalog objects. |
@@ -98,6 +98,7 @@ Choose a group, then use the page contents to jump to a
procedure:
[`migrate_database`](./procedures/migration#migrate_database),
[`migrate_table`](./procedures/migration#migrate_table),
+[`import_files`](./procedures/migration#import_files),
[`copy`](./procedures/migration#copy)
### Indexes and Row IDs
diff --git a/docs/docs/spark/procedures/migration.md
b/docs/docs/spark/procedures/migration.md
index 6812d8f61c..235dbe70c4 100644
--- a/docs/docs/spark/procedures/migration.md
+++ b/docs/docs/spark/procedures/migration.md
@@ -90,3 +90,41 @@ CALL sys.copy(source_table => "t1", target_table =>
"t1_copy");
CALL sys.copy(source_table => "t1", target_table => "t1_copy", where => "day =
'2025-08-17'");
```
+
+## import_files
+
+Append files from a directory to one partition by storing their original
locations in
+`DataFileMeta.externalPath`. The procedure opens source files to extract their
actual row counts
+and available column statistics, using the same metadata extractors as
`migrate_table`. It does not
+copy, rename, or restore files. Archived files must be restored and readable
before import.
+
+**Arguments**
+
+- `table` (`STRING`, required): target Paimon table identifier.
+- `location` (`STRING`, required): absolute source directory path.
+- `partition` (`STRING`, optional): complete partition specification, such as
`dt=2026-10-08,hour=12`.
+ Required for partitioned tables; omit it for an unpartitioned table.
+
+```sql
+CALL sys.import_files(
+ table => 'default.T',
+ location => 'oss://bucket/external-files/',
+ partition => 'dt=2026-10-08,hour=12'
+);
+```
+
+Returns `imported_files`, the number of registered files. An empty directory
returns zero without
+creating a snapshot. Only files directly in the directory whose suffix matches
the table's
+`file.format` are imported. Hidden files and subdirectories are skipped.
Repeated directory entries
+are deduplicated by their full external paths. Files already referenced by the
current table snapshot
+are rejected before reading any source file. Concurrent imports of the same
directory must be avoided.
+
+The target must be an append table with `bucket = -1`, without row tracking or
data evolution.
+Source files must match the target's format and non-partition columns.
Partition values come from
+the supplied specification. Source locations must be accessible through the
table's FileIO.
+
+Metadata extraction respects the table's `metadata.stats-mode`; row counts are
collected even when
+column statistics are disabled. If any selected file cannot be read or its
metadata cannot be
+extracted, the import fails without committing a snapshot. Do not modify
registered source files.
+Imported files participate in the normal Paimon file lifecycle, including
deletion after compaction
+or partition removal and snapshot expiration.
diff --git
a/paimon-core/src/main/java/org/apache/paimon/migrate/ExternalFileImporter.java
b/paimon-core/src/main/java/org/apache/paimon/migrate/ExternalFileImporter.java
new file mode 100644
index 0000000000..096a031c79
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/migrate/ExternalFileImporter.java
@@ -0,0 +1,148 @@
+/*
+ * 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.migrate;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryWriter;
+import org.apache.paimon.format.SimpleStatsExtractor;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.FileStatus;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.StringUtils;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import static
org.apache.paimon.utils.ParameterUtils.parseCommaSeparatedKeyValues;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Imports existing files by recording their external paths in a single
append commit. */
+public class ExternalFileImporter {
+
+ private ExternalFileImporter() {}
+
+ public static long importFiles(Table table, String location, @Nullable
String partition)
+ throws Exception {
+ checkArgument(table instanceof FileStoreTable, "import_files requires
a FileStoreTable.");
+ FileStoreTable fileStoreTable = (FileStoreTable) table;
+ checkArgument(
+ table.primaryKeys().isEmpty() &&
fileStoreTable.coreOptions().bucket() == -1,
+ "import_files only supports append tables with bucket = -1.");
+ checkArgument(
+ !fileStoreTable.coreOptions().rowTrackingEnabled()
+ &&
!fileStoreTable.coreOptions().dataEvolutionEnabled(),
+ "import_files does not support row tracking or data-evolution
tables.");
+
+ Map<String, String> partitionSpec =
parseCommaSeparatedKeyValues(partition);
+ checkArgument(
+ partitionSpec.keySet().equals(new
HashSet<>(table.partitionKeys())),
+ "Partition must specify exactly the table partition keys: %s.",
+ table.partitionKeys());
+ RowType partitionType = fileStoreTable.store().partitionType();
+ BinaryRow partitionRow =
+ FileMetaUtils.writePartitionValue(
+ partitionType,
+ table.partitionKeys().stream()
+ .map(partitionSpec::get)
+ .collect(Collectors.toList()),
+ partitionType.getFields().stream()
+ .map(f ->
BinaryWriter.createValueSetter(f.type()))
+ .collect(Collectors.toList()),
+ fileStoreTable.coreOptions().partitionDefaultName());
+
+ String fileFormat = fileStoreTable.coreOptions().fileFormatString();
+ checkArgument(!StringUtils.isNullOrWhitespaceOnly(location), "Location
must not be empty.");
+ Path directory = new Path(location);
+ checkArgument(
+ directory.toUri().getPath().startsWith("/"), "Location must be
an absolute path.");
+ FileIO fileIO = table.fileIO();
+ checkArgument(fileIO.getFileStatus(directory).isDir(), "Location must
be a directory.");
+
+ Map<String, FileStatus> files = new LinkedHashMap<>();
+ for (FileStatus status : fileIO.listStatus(directory)) {
+ String name = status.getPath().getName();
+ if (!status.isDir()
+ && !name.startsWith(".")
+ && !name.startsWith("_")
+ && name.endsWith("." + fileFormat)) {
+ files.putIfAbsent(status.getPath().toString(), status);
+ }
+ }
+ if (files.isEmpty()) {
+ return 0;
+ }
+
+ Set<String> existingPaths = new HashSet<>();
+ for (Split split : fileStoreTable.newScan().plan().splits()) {
+ for (DataFileMeta file : ((DataSplit) split).dataFiles()) {
+ file.externalPath().ifPresent(existingPaths::add);
+ }
+ }
+ for (String externalPath : files.keySet()) {
+ checkArgument(
+ !existingPaths.contains(externalPath),
+ "File has already been imported: %s.",
+ externalPath);
+ }
+ List<DataFileMeta> fileMetas = new ArrayList<>();
+ SimpleStatsExtractor statsExtractor =
+ FileMetaUtils.createSimpleStatsExtractor(table, fileFormat);
+ for (FileStatus file : files.values()) {
+ String externalPath = file.getPath().toString();
+ // Manifest identity is independent of the source name, which can
collide across dirs.
+ DataFileMeta fileMeta =
+ FileMetaUtils.constructFileMeta(
+ "data-" + UUID.randomUUID() + "." + fileFormat,
+ file.getLen(),
+ file.getPath(),
+ statsExtractor,
+ fileIO,
+ table);
+ checkArgument(
+ fileMeta.rowCount() >= 0,
+ "Cannot determine row count for file: %s.",
+ externalPath);
+ fileMetas.add(fileMeta.newExternalPath(externalPath));
+ }
+
+ // Extract every file's metadata before committing, so a failed
extraction adds no files.
+ try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(
+ Collections.singletonList(
+ FileMetaUtils.createCommitMessage(partitionRow,
-1, fileMetas)));
+ }
+ return fileMetas.size();
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/migrate/ExternalFileImporterTest.java
b/paimon-core/src/test/java/org/apache/paimon/migrate/ExternalFileImporterTest.java
new file mode 100644
index 0000000000..d3dc8992a8
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/migrate/ExternalFileImporterTest.java
@@ -0,0 +1,386 @@
+/*
+ * 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.migrate;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.FileStatus;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.schema.FileSystemSchemaManager;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaUtils;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.apache.paimon.data.BinaryString.fromString;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+/** Tests external file registration with extracted counts and unchanged
source files. */
+class ExternalFileImporterTest {
+
+ @TempDir java.nio.file.Path tempDir;
+
+ private FileIO fileIO;
+ private Path source;
+
+ @BeforeEach
+ void before() throws IOException {
+ fileIO = spy(LocalFileIO.create());
+ source = new Path(tempDir.resolve("external").toUri());
+ fileIO.mkdirs(source);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"parquet", "orc", "avro"})
+ void testImportExtractsCountsAndPreservesSources(String format) throws
Exception {
+ FileStoreTable table = createTable(format, Collections.emptyMap());
+ Path first = externalFile(format, "first." + format, 1, 2);
+ Path second = externalFile(format, "second." + format, 3);
+ invalidFile("_hidden." + format);
+ invalidFile(".hidden." + format);
+ invalidFile("unrelated.txt");
+ invalidFile("nested/ignored." + format);
+
+ assertThat(
+ ExternalFileImporter.importFiles(
+ table, source.toString(),
"hour=12,dt=2026-10-08"))
+ .isEqualTo(2);
+
+ DataSplit split = splits(table).get(0);
+
assertThat(split.partition().getString(0)).isEqualTo(fromString("2026-10-08"));
+ assertThat(split.partition().getInt(1)).isEqualTo(12);
+ assertThat(split.bucket()).isZero();
+ assertThat(split.rowCount()).isEqualTo(3);
+ assertThat(split.mergedRowCount()).hasValue(3);
+ List<DataFileMeta> metas = split.dataFiles();
+ assertThat(metas).hasSize(2);
+ assertThat(metas.stream().map(f -> f.externalPath().get()))
+ .containsExactlyInAnyOrder(first.toString(),
second.toString());
+ for (DataFileMeta meta : metas) {
+ Path external = new Path(meta.externalPath().get());
+
assertThat(meta.fileSize()).isEqualTo(fileIO.getFileStatus(external).getLen());
+ assertThat(meta.schemaId()).isEqualTo(table.schema().id());
+ assertThat(meta.fileFormat()).isEqualTo(format);
+ assertThat(fileIO.exists(external)).isTrue();
+ boolean isFirst = external.equals(first);
+ assertThat(meta.rowCount()).isEqualTo(isFirst ? 2 : 1);
+ if (!format.equals("avro")) {
+
assertThat(meta.valueStats().minValues().getInt(0)).isEqualTo(isFirst ? 1 : 3);
+
assertThat(meta.valueStats().maxValues().getInt(0)).isEqualTo(isFirst ? 2 : 3);
+ }
+ }
+
assertThat(table.snapshotManager().latestSnapshot().totalRecordCount()).isEqualTo(3);
+
assertThat(table.snapshotManager().latestSnapshot().deltaRecordCount()).isEqualTo(3);
+
assertThat(table.newScan().listPartitionEntries().get(0).recordCount()).isEqualTo(3);
+ verify(fileIO, atLeastOnce())
+ .newInputStream(argThat(path ->
path.toString().startsWith(source.toString())));
+ verify(fileIO, never())
+ .rename(argThat(path ->
path.toString().startsWith(source.toString())), any());
+ verify(fileIO, never())
+ .copyFile(
+ argThat(path ->
path.toString().startsWith(source.toString())),
+ any(),
+ anyBoolean());
+ }
+
+ @Test
+ void testStatsModeNoneStillExtractsRowCount() throws Exception {
+ FileStoreTable table =
+ createTable("parquet",
Collections.singletonMap("metadata.stats-mode", "none"));
+ externalFile("parquet", "data.parquet", 1, 2);
+ ExternalFileImporter.importFiles(table, source.toString(),
"dt=p1,hour=1");
+ DataFileMeta meta = splits(table).get(0).dataFiles().get(0);
+ assertThat(meta.rowCount()).isEqualTo(2);
+ assertThat(meta.valueStats().minValues().getFieldCount()).isZero();
+ assertThat(meta.valueStats().nullCounts().size()).isZero();
+ }
+
+ @Test
+ void testUnreadableFileDoesNotCommit() throws Exception {
+ FileStoreTable table = createTable("parquet", Collections.emptyMap());
+ write(table, GenericRow.of(9, fromString("p1"), 1));
+ long snapshotId = table.snapshotManager().latestSnapshotId();
+ Path first = externalFile("parquet", "first.parquet", 1);
+ Path unreadable = externalFile("parquet", "unreadable.parquet", 2);
+ doThrow(new IOException("Source file is unavailable"))
+ .when(fileIO)
+ .newInputStream(unreadable);
+
+ assertThatThrownBy(
+ () ->
+ ExternalFileImporter.importFiles(
+ table, source.toString(),
"dt=p1,hour=1"))
+ .hasStackTraceContaining("Source file is unavailable");
+
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(snapshotId);
+ assertThat(splits(table).get(0).dataFiles()).hasSize(1);
+
assertThat(table.snapshotManager().latestSnapshot().totalRecordCount()).isEqualTo(1);
+ assertThat(fileIO.exists(first)).isTrue();
+ assertThat(fileIO.exists(unreadable)).isTrue();
+ }
+
+ @Test
+ void testCorruptFileDoesNotCommit() throws Exception {
+ FileStoreTable table = createTable("parquet", Collections.emptyMap());
+ externalFile("parquet", "first.parquet", 1);
+ Path corrupt = invalidFile("corrupt.parquet");
+ assertThatThrownBy(
+ () ->
+ ExternalFileImporter.importFiles(
+ table, source.toString(),
"dt=p1,hour=1"))
+ .hasStackTraceContaining("corrupt.parquet");
+ assertThat(table.snapshotManager().latestSnapshot()).isNull();
+ assertThat(fileIO.exists(corrupt)).isTrue();
+ }
+
+ @Test
+ void testDuplicateDirectoryEntriesAreImportedOnce() throws Exception {
+ FileStoreTable table = createTable("parquet", Collections.emptyMap());
+ Path first = externalFile("parquet", "first.parquet", 1, 2);
+ Path second = externalFile("parquet", "second.parquet", 3);
+ doReturn(
+ new FileStatus[] {
+ fileIO.getFileStatus(first),
+ fileIO.getFileStatus(second),
+ fileIO.getFileStatus(first),
+ fileIO.getFileStatus(second)
+ })
+ .when(fileIO)
+ .listStatus(source);
+
+ assertThat(ExternalFileImporter.importFiles(table, source.toString(),
"dt=p1,hour=1"))
+ .isEqualTo(2);
+ DataSplit split = splits(table).get(0);
+ assertThat(split.dataFiles().stream().map(f -> f.externalPath().get()))
+ .containsExactlyInAnyOrder(first.toString(),
second.toString());
+ assertThat(split.rowCount()).isEqualTo(3);
+
assertThat(table.snapshotManager().latestSnapshot().totalRecordCount()).isEqualTo(3);
+
assertThat(table.newScan().listPartitionEntries().get(0).recordCount()).isEqualTo(3);
+ assertThat(fileIO.exists(first)).isTrue();
+ assertThat(fileIO.exists(second)).isTrue();
+ }
+
+ @Test
+ void testDuplicateImportDoesNotCommit() throws Exception {
+ FileStoreTable table = createTable("parquet", Collections.emptyMap());
+ Path imported = externalFile("parquet", "first.parquet", 1);
+ ExternalFileImporter.importFiles(table, source.toString(),
"dt=p1,hour=1");
+ long snapshotId = table.snapshotManager().latestSnapshotId();
+ Path fresh = externalFile("parquet", "second.parquet", 2);
+ doReturn(new FileStatus[] {fileIO.getFileStatus(fresh),
fileIO.getFileStatus(imported)})
+ .when(fileIO)
+ .listStatus(source);
+ clearInvocations(fileIO);
+
+ assertThatThrownBy(
+ () ->
+ ExternalFileImporter.importFiles(
+ table, source.toString(),
"dt=p1,hour=1"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("already been imported");
+
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(snapshotId);
+ assertThat(splits(table).get(0).dataFiles()).hasSize(1);
+
assertThat(table.snapshotManager().latestSnapshot().totalRecordCount()).isEqualTo(1);
+ verify(fileIO, never())
+ .newInputStream(argThat(path ->
path.toString().startsWith(source.toString())));
+ }
+
+ @Test
+ void testSameSourceNameInDifferentDirectories() throws Exception {
+ FileStoreTable table = createTable("parquet", Collections.emptyMap());
+ externalFile("parquet", "data.parquet", 1);
+ externalFile("parquet", "nested/data.parquet", 2);
+ ExternalFileImporter.importFiles(table, source.toString(),
"dt=p1,hour=1");
+ ExternalFileImporter.importFiles(
+ table, new Path(source, "nested").toString(), "dt=p1,hour=1");
+
+ List<DataFileMeta> metas = splits(table).get(0).dataFiles();
+ assertThat(metas).hasSize(2);
+
assertThat(metas.stream().map(DataFileMeta::fileName).distinct()).hasSize(2);
+ assertThat(metas.stream().map(f ->
f.externalPath().get()).distinct()).hasSize(2);
+
assertThat(table.snapshotManager().latestSnapshot().totalRecordCount()).isEqualTo(2);
+ }
+
+ @Test
+ void testCountsWithExistingData() throws Exception {
+ FileStoreTable table = createTable("parquet", Collections.emptyMap());
+ write(table, GenericRow.of(1, fromString("p1"), 1));
+ externalFile("parquet", "imported.parquet", 3, 4);
+ ExternalFileImporter.importFiles(table, source.toString(),
"dt=p1,hour=1");
+ write(table, GenericRow.of(2, fromString("p1"), 1));
+
+ DataSplit split = splits(table).get(0);
+ assertThat(split.dataFiles()).hasSize(3);
+ assertThat(split.rowCount()).isEqualTo(4);
+ assertThat(split.mergedRowCount()).hasValue(4);
+
assertThat(table.snapshotManager().latestSnapshot().totalRecordCount()).isEqualTo(4);
+
assertThat(table.newScan().listPartitionEntries().get(0).recordCount()).isEqualTo(4);
+ }
+
+ @Test
+ void testEmptyDirectoryDoesNotCommit() throws Exception {
+ FileStoreTable table = createTable("parquet", Collections.emptyMap());
+ assertThat(ExternalFileImporter.importFiles(table, source.toString(),
"dt=p1,hour=1"))
+ .isZero();
+ assertThat(table.snapshotManager().latestSnapshot()).isNull();
+ }
+
+ @Test
+ void testCompletePartitionIsRequired() throws Exception {
+ FileStoreTable table = createTable("parquet", Collections.emptyMap());
+ for (String partition : Arrays.asList(null, "", "dt=p1",
"dt=p1,hour=1,extra=x")) {
+ assertThatThrownBy(
+ () ->
+ ExternalFileImporter.importFiles(
+ table, source.toString(),
partition))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("exactly the table partition keys");
+ }
+ assertThatThrownBy(
+ () ->
+ ExternalFileImporter.importFiles(
+ table, source.toString(),
"dt=p1,hour=invalid"))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThat(table.snapshotManager().latestSnapshot()).isNull();
+ }
+
+ @Test
+ void testUnsupportedTables() throws Exception {
+ Map<String, String> bucketOptions = new HashMap<>();
+ bucketOptions.put("bucket", "1");
+ bucketOptions.put("bucket-key", "id");
+ FileStoreTable bucketed = createTable("parquet", bucketOptions);
+ assertThatThrownBy(
+ () ->
+ ExternalFileImporter.importFiles(
+ bucketed, source.toString(),
"dt=p1,hour=1"))
+ .hasMessageContaining("bucket = -1");
+ FileStoreTable tracked =
+ createTable("parquet",
Collections.singletonMap("row-tracking.enabled", "true"));
+ assertThatThrownBy(
+ () ->
+ ExternalFileImporter.importFiles(
+ tracked, source.toString(),
"dt=p1,hour=1"))
+ .hasMessageContaining("row tracking");
+ }
+
+ private Path externalFile(String format, String name, int... ids) throws
Exception {
+ FileStoreTable producer = createTable(format, Collections.emptyMap());
+ GenericRow[] rows =
+ Arrays.stream(ids)
+ .mapToObj(id -> GenericRow.of(id,
fromString("source"), 0))
+ .toArray(GenericRow[]::new);
+ write(producer, rows);
+ DataSplit split = splits(producer).get(0);
+ DataFileMeta meta = split.dataFiles().get(0);
+ Path dataFile =
+ producer.store()
+ .pathFactory()
+ .createDataFilePathFactory(split.partition(),
split.bucket())
+ .toPath(meta);
+ Path external = new Path(source, name);
+ fileIO.mkdirs(external.getParent());
+ fileIO.copyFile(dataFile, external, true);
+ return external;
+ }
+
+ private Path invalidFile(String name) throws IOException {
+ java.nio.file.Path path = tempDir.resolve("external").resolve(name);
+ Files.createDirectories(path.getParent());
+ Files.write(path, new byte[] {1, 2, 3});
+ return new Path(path.toUri());
+ }
+
+ private FileStoreTable createTable(String format, Map<String, String>
overrides)
+ throws Exception {
+ Path tablePath = new Path(tempDir.resolve("table-" +
System.nanoTime()).toUri());
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.BUCKET.key(), "-1");
+ options.put(CoreOptions.FILE_FORMAT.key(), format);
+ options.putAll(overrides);
+ RowType rowType =
+ RowType.builder()
+ .field("id", DataTypes.INT())
+ .field("dt", DataTypes.STRING())
+ .field("hour", DataTypes.INT())
+ .build();
+ TableSchema schema =
+ SchemaUtils.forceCommit(
+ new FileSystemSchemaManager(fileIO, tablePath),
+ new Schema(
+ rowType.getFields(),
+ Arrays.asList("dt", "hour"),
+ Collections.emptyList(),
+ options,
+ ""));
+ return FileStoreTableFactory.create(fileIO, tablePath, schema);
+ }
+
+ private List<DataSplit> splits(FileStoreTable table) {
+ return table.newScan().plan().splits().stream()
+ .map(s -> (DataSplit) s)
+ .collect(Collectors.toList());
+ }
+
+ private void write(FileStoreTable table, GenericRow... rows) throws
Exception {
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ for (GenericRow row : rows) {
+ write.write(row);
+ }
+ commit.commit(write.prepareCommit());
+ }
+ }
+}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/ImportFilesProcedure.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/ImportFilesProcedure.java
new file mode 100644
index 0000000000..58f1fb9428
--- /dev/null
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/ImportFilesProcedure.java
@@ -0,0 +1,50 @@
+/*
+ * 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.flink.procedure;
+
+import org.apache.paimon.migrate.ExternalFileImporter;
+
+import org.apache.flink.table.annotation.ArgumentHint;
+import org.apache.flink.table.annotation.DataTypeHint;
+import org.apache.flink.table.annotation.ProcedureHint;
+import org.apache.flink.table.procedure.ProcedureContext;
+
+import javax.annotation.Nullable;
+
+/** Imports files from a directory into a partition using external paths. */
+public class ImportFilesProcedure extends ProcedureBase {
+
+ @Override
+ public String identifier() {
+ return "import_files";
+ }
+
+ @ProcedureHint(
+ argument = {
+ @ArgumentHint(name = "table", type = @DataTypeHint("STRING")),
+ @ArgumentHint(name = "location", type =
@DataTypeHint("STRING")),
+ @ArgumentHint(name = "partition", type =
@DataTypeHint("STRING"), isOptional = true)
+ },
+ output = @DataTypeHint("BIGINT"))
+ public Long[] call(
+ ProcedureContext context, String tableId, String location,
@Nullable String partition)
+ throws Exception {
+ return new Long[] {ExternalFileImporter.importFiles(table(tableId),
location, partition)};
+ }
+}
diff --git
a/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
b/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
index 288431c31a..b9223d0334 100644
---
a/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
+++
b/paimon-flink/paimon-flink-common/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory
@@ -111,3 +111,4 @@ org.apache.paimon.flink.procedure.CreateGlobalIndexProcedure
org.apache.paimon.flink.procedure.VectorSearchProcedure
org.apache.paimon.flink.procedure.FullTextSearchProcedure
org.apache.paimon.flink.procedure.DropGlobalIndexProcedure
+org.apache.paimon.flink.procedure.ImportFilesProcedure
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/ImportFilesProcedureITCase.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/ImportFilesProcedureITCase.java
new file mode 100644
index 0000000000..23ddf9f750
--- /dev/null
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/ImportFilesProcedureITCase.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.flink.procedure;
+
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.flink.CatalogITCaseBase;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.Split;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** SQL integration tests for {@link ImportFilesProcedure}. */
+class ImportFilesProcedureITCase extends CatalogITCaseBase {
+
+ @Test
+ void testImportIntoPartition() throws Exception {
+ Path location = prepareFiles();
+ sql(
+ "CREATE TABLE T (id INT, dt STRING, `hour` INT) PARTITIONED BY
(dt, `hour`) "
+ + "WITH ('bucket'='-1', 'file.format'='parquet')");
+
+ assertThat(
+ sql("CALL sys.import_files(`table` => 'default.T',
location => '"
+ + location
+ + "', `partition` => 'hour=12,dt=p1')")
+ .get(0)
+ .getField(0))
+ .isEqualTo(1L);
+ FileStoreTable table = paimonTable("T");
+ DataSplit split = (DataSplit) table.newScan().plan().splits().get(0);
+ DataFileMeta file = split.dataFiles().get(0);
+ assertThat(file.externalPath()).isPresent();
+ assertThat(table.fileIO().exists(new
Path(file.externalPath().get()))).isTrue();
+ assertThat(file.rowCount()).isEqualTo(2L);
+ assertThat(sql("SELECT * FROM T ORDER BY id").toString())
+ .isEqualTo("[+I[1, p1, 12], +I[2, p1, 12]]");
+ assertThat(sql("SELECT COUNT(*) FROM
T").get(0).getField(0)).isEqualTo(2L);
+ sql("ALTER TABLE T ADD added INT");
+ assertThat(sql("SELECT COUNT(*) FROM T WHERE added IS
NULL").get(0).getField(0))
+ .isEqualTo(2L);
+ }
+
+ @Test
+ void testImportIntoUnpartitionedTable() throws Exception {
+ Path location = prepareFiles();
+ sql("CREATE TABLE T (id INT) WITH ('bucket'='-1',
'file.format'='parquet')");
+ assertThat(sql("CALL sys.import_files('default.T', '" + location +
"')").get(0).getField(0))
+ .isEqualTo(1L);
+ assertThat(sql("SELECT COUNT(*) FROM
T").get(0).getField(0)).isEqualTo(2L);
+ }
+
+ private Path prepareFiles() throws Exception {
+ sql("CREATE TABLE S (id INT) WITH ('bucket'='-1',
'file.format'='parquet')");
+ FileStoreTable source = paimonTable("S");
+ BatchWriteBuilder builder = source.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ write.write(GenericRow.of(1));
+ write.write(GenericRow.of(2));
+ commit.commit(write.prepareCommit());
+ }
+ Path location = new Path(path, "external");
+ source.fileIO().mkdirs(location);
+ for (Split split : source.newScan().plan().splits()) {
+ DataSplit dataSplit = (DataSplit) split;
+ for (DataFileMeta file : dataSplit.dataFiles()) {
+ Path sourcePath =
+ source.store()
+ .pathFactory()
+ .createDataFilePathFactory(
+ dataSplit.partition(),
dataSplit.bucket())
+ .toPath(file);
+ source.fileIO().copyFile(sourcePath, new Path(location,
file.fileName()), true);
+ }
+ }
+ return location;
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
index aebc19389e..61d318a5d9 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java
@@ -43,6 +43,7 @@ import
org.apache.paimon.spark.procedure.ExpireSnapshotsProcedure;
import org.apache.paimon.spark.procedure.ExpireTagsProcedure;
import org.apache.paimon.spark.procedure.FastForwardProcedure;
import org.apache.paimon.spark.procedure.GrantPermissionProcedure;
+import org.apache.paimon.spark.procedure.ImportFilesProcedure;
import org.apache.paimon.spark.procedure.ListPermissionsProcedure;
import org.apache.paimon.spark.procedure.ListPoliciesProcedure;
import org.apache.paimon.spark.procedure.MarkPartitionDoneProcedure;
@@ -124,6 +125,7 @@ public class SparkProcedures {
procedureBuilders.put("rescale", RescaleProcedure::builder);
procedureBuilders.put("migrate_database",
MigrateDatabaseProcedure::builder);
procedureBuilders.put("migrate_table", MigrateTableProcedure::builder);
+ procedureBuilders.put("import_files", ImportFilesProcedure::builder);
procedureBuilders.put("remove_orphan_files",
RemoveOrphanFilesProcedure::builder);
procedureBuilders.put("remove_orphan_blobs",
RemoveOrphanBlobsProcedure::builder);
procedureBuilders.put("remove_unexisting_files",
RemoveUnexistingFilesProcedure::builder);
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ImportFilesProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ImportFilesProcedure.java
new file mode 100644
index 0000000000..69bbb46b4f
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/ImportFilesProcedure.java
@@ -0,0 +1,101 @@
+/*
+ * 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.procedure;
+
+import org.apache.paimon.migrate.ExternalFileImporter;
+
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+
+import static org.apache.spark.sql.types.DataTypes.LongType;
+import static org.apache.spark.sql.types.DataTypes.StringType;
+
+/** Imports files from a directory into a partition using external paths. */
+public class ImportFilesProcedure extends BaseProcedure {
+
+ private static final ProcedureParameter[] PARAMETERS = {
+ ProcedureParameter.required("table", StringType),
+ ProcedureParameter.required("location", StringType),
+ ProcedureParameter.optional("partition", StringType)
+ };
+
+ private static final StructType OUTPUT_TYPE =
+ new StructType(
+ new StructField[] {
+ new StructField("imported_files", LongType, false,
Metadata.empty())
+ });
+
+ private ImportFilesProcedure(TableCatalog tableCatalog) {
+ super(tableCatalog);
+ }
+
+ @Override
+ public ProcedureParameter[] parameters() {
+ return PARAMETERS;
+ }
+
+ @Override
+ public StructType outputType() {
+ return OUTPUT_TYPE;
+ }
+
+ @Override
+ public InternalRow[] call(InternalRow args) {
+ Identifier ident = toIdentifier(args.getString(0),
PARAMETERS[0].name());
+ return modifyPaimonTable(
+ ident,
+ table -> {
+ try {
+ long importedFiles =
+ ExternalFileImporter.importFiles(
+ table,
+ args.getString(1),
+ args.isNullAt(2) ? null :
args.getString(2));
+ return new InternalRow[]
{newInternalRow(importedFiles)};
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ } catch (RuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to import files",
e);
+ }
+ });
+ }
+
+ public static ProcedureBuilder builder() {
+ return new BaseProcedure.Builder<ImportFilesProcedure>() {
+ @Override
+ public ImportFilesProcedure doBuild() {
+ return new ImportFilesProcedure(tableCatalog());
+ }
+ };
+ }
+
+ @Override
+ public String description() {
+ return "ImportFilesProcedure";
+ }
+}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/ImportFilesProcedureTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/ImportFilesProcedureTest.scala
new file mode 100644
index 0000000000..6a87eff049
--- /dev/null
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/ImportFilesProcedureTest.scala
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.spark.procedure
+
+import org.apache.paimon.spark.PaimonSparkTestBase
+import org.apache.paimon.table.source.DataSplit
+
+import org.apache.spark.sql.Row
+
+import java.io.File
+
+import scala.collection.JavaConverters._
+
+class ImportFilesProcedureTest extends PaimonSparkTestBase {
+
+ Seq("parquet", "orc", "avro").foreach {
+ format =>
+ test(s"import $format files with real counts into a partition") {
+ withTempDir {
+ dir =>
+ val location = new File(dir, "external").getCanonicalPath
+ spark
+ .sql("SELECT 1 AS id UNION ALL SELECT 2 UNION ALL SELECT 3")
+ .repartition(2)
+ .write
+ .format(format)
+ .save(location)
+ val sourceFiles = new
File(location).listFiles.filter(_.getName.endsWith(s".$format"))
+
+ spark.sql(s"""
+ |CREATE TABLE T (id INT, dt STRING, hour INT)
+ |PARTITIONED BY (dt, hour)
+ |TBLPROPERTIES ('bucket'='-1',
'file.format'='$format')
+ |""".stripMargin)
+ spark.sql("INSERT INTO T VALUES (4, 'p1', 12), (5, 'p2', 13)")
+ spark.sql("CACHE TABLE T")
+ try {
+ checkAnswer(
+ spark.sql(s"""
+ |CALL sys.import_files(
+ | table => 'test.T',
+ | location => '$location',
+ | partition => 'hour=12,dt=p1')
+ |""".stripMargin),
+ Row(sourceFiles.length.toLong)
+ )
+
+ assert(sourceFiles.forall(_.exists()))
+ val table =
+
paimonCatalog.getTable(org.apache.paimon.catalog.Identifier.create("test", "T"))
+ val externalFiles =
table.newReadBuilder.newScan.plan.splits.asScala
+ .flatMap(_.asInstanceOf[DataSplit].dataFiles.asScala)
+ .filter(_.externalPath.isPresent)
+ assert(externalFiles.size == sourceFiles.length)
+ assert(externalFiles.forall(_.rowCount >= 0L))
+ assert(externalFiles.map(_.rowCount).sum == 3L)
+
+ checkAnswer(
+ spark.sql("SELECT * FROM T WHERE dt = 'p1' ORDER BY id"),
+ Seq(Row(1, "p1", 12), Row(2, "p1", 12), Row(3, "p1", 12),
Row(4, "p1", 12)))
+ checkAnswer(spark.sql("SELECT COUNT(*) FROM T"), Row(5L))
+ checkAnswer(
+ spark.sql("SELECT dt, COUNT(*) FROM T GROUP BY dt"),
+ Seq(Row("p1", 4L), Row("p2", 1L)))
+ } finally {
+ spark.sql("UNCACHE TABLE T")
+ }
+
+ checkAnswer(spark.sql("SELECT COUNT(*) FROM T"), Row(5L))
+ checkAnswer(
+ spark.sql("SELECT dt, COUNT(*) FROM T GROUP BY dt"),
+ Seq(Row("p1", 4L), Row("p2", 1L)))
+ spark.sql("ALTER TABLE T ADD COLUMN added INT")
+ checkAnswer(spark.sql("SELECT COUNT(*) FROM T WHERE added IS
NULL"), Row(5L))
+ checkAnswer(
+ spark.sql("SELECT id FROM T WHERE id > 2 ORDER BY id LIMIT 2"),
+ Seq(Row(3), Row(4)))
+ assert(spark.sql("SELECT * FROM T LIMIT 3").collect().length == 3)
+ spark.sql("ANALYZE TABLE T COMPUTE STATISTICS FOR COLUMNS id")
+ checkAnswer(spark.sql("SELECT COUNT(*) FROM T"), Row(5L))
+ }
+ }
+ }
+
+ test("import into an unpartitioned table without optional arguments") {
+ withTempDir {
+ dir =>
+ val location = new File(dir, "external").getCanonicalPath
+ spark.sql("SELECT 1 AS id UNION ALL SELECT
2").coalesce(1).write.parquet(location)
+ spark.sql("CREATE TABLE T (id INT) TBLPROPERTIES ('bucket'='-1',
'file.format'='parquet')")
+ checkAnswer(spark.sql(s"CALL sys.import_files('test.T',
'$location')"), Row(1L))
+ checkAnswer(spark.sql("SELECT COUNT(*) FROM T"), Row(2L))
+ }
+ }
+}