Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions docs/en/antalya/part_export.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,23 @@ SETTINGS allow_experimental_export_merge_tree_part = 1

## Requirements

Source and destination tables must be 100% compatible:
Source and destination tables must support positional schema conversion:

1. **Identical schemas** - same columns, types, and order
2. **Matching partition keys** - partition expressions must be identical
1. **Positionally compatible schemas** - source columns are matched to destination columns by position, similar to `INSERT INTO dest SELECT * FROM src`. Corresponding types must be safely castable by default. Set `export_merge_tree_part_allow_lossy_cast = 1` to permit lossy casts.
2. **Compatible partitioning** - for destinations other than data lakes, the source and destination `PARTITION BY` expressions must be identical. For Apache Iceberg destinations, the source partition key must be representable as an Iceberg partition spec and must match the destination partition fields and transforms.
3. **Matching partition key column positions and layouts** - it is not enough for the `PARTITION BY` expressions to be textually identical: every top-level column that provides a column or subcolumn used by the source table's partition key must have the same name at the same position in the destination table's schema. If such a column contains a named `Tuple`, its element names must also be declared in the same order. This comparison is recursive through nested tuples and through container types such as `Array` and `Map`. For example, `CREATE TABLE src (a Int32, b Int32) ... PARTITION BY a` and `CREATE TABLE dst (b Int32, a Int32) ... PARTITION BY a` both have the expression `PARTITION BY a`, but `a` is at position 0 in `src` and position 1 in `dst`. The export is rejected with a `BAD_ARGUMENTS` exception whose message includes `Cannot export to <destination>: partition key column 'a' is at position 0 in the source table, but the destination's column at that position is named 'b'`.

This explicit name check only applies to partition key columns. A mismatch in the position of a non-partition-key column is **not** rejected by name - it is only caught if the source and destination types are not castable. If two non-partition-key columns happen to have swapped positions but compatible types, the export succeeds and silently writes values into the wrong destination column, so keep the intended column order rather than relying on type compatibility alone.

For `PARTITION BY t.a`, this rule applies to the top-level owning column `t`. Exporting from `t Tuple(a Int32, b Int32)` to `t Tuple(b Int32, a Int32)` is rejected, even though `a` is accessed by name. Requiring a stable layout for every partition-key owner also protects positional expressions such as `tupleElement(t, 1)` from changing their meaning after conversion.

The element-name check only applies when both the source and destination `Tuple` declare explicit names; an unnamed `Tuple` (e.g. `Tuple(Int32, Int32)`) is compared to the destination by element position and type only. For example, exporting from `t Tuple(Int32, Int32)` to `t Tuple(x Int32, y Int32)` is allowed as long as element types match positionally.

The same rule applies when the named tuple is nested inside a container. For example, `arr Array(Tuple(a Int32, b Int32))` and `arr Array(Tuple(b Int32, a Int32))` are incompatible when `arr` provides an input to the partition key. Likewise, tuple layouts in both the key and value types of `Map` are checked recursively.

In this case, the export throws a `BAD_ARGUMENTS` exception whose message includes `partition key column 't' has a different Tuple element layout in the source (Tuple(a Int32, b Int32)) and destination (Tuple(b Int32, a Int32)). Tuple element names must be declared in the same order in both tables`.

For partition expressions containing functions, the check applies to their input columns. For example, `PARTITION BY (toYYYYMM(ts), category)` requires both `ts` and `category` to have the same names at the same top-level positions in both tables.

In case a table function is used as the destination, the schema can be omitted and it will be inferred from the source table.

Expand Down
11 changes: 9 additions & 2 deletions docs/en/antalya/partition_export.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ TO TABLE [destination_database.]destination_table
- **`partition_id`**: The partition identifier to export (e.g., `'2020'`, `'2021'`)
- **`destination_table`**: The target table for the export (typically an S3, Azure, or other object storage table)

## Requirements

`EXPORT PARTITION` exports each part via the same mechanism as [`EXPORT PART`](/docs/en/antalya/part_export.md#requirements), so the source and destination tables must satisfy the same compatibility requirements, in particular:

1. **Positionally compatible schemas** - source columns are matched to destination columns by position. Corresponding types must be safely castable unless `export_merge_tree_part_allow_lossy_cast = 1` is set.
2. **Compatible partitioning** - for destinations other than data lakes, the source and destination `PARTITION BY` expressions must be identical. For Apache Iceberg destinations, the source partition key must match the destination partition fields and transforms.
3. **Matching partition key column positions and layouts** - every top-level column that provides a column or subcolumn used by the source table's partition key must have the same name at the same position in the destination table's schema. Named `Tuple` elements within such a column must also be declared in the same order, including tuples nested inside `Array` or `Map`. This applies even if both tables' `PARTITION BY` expressions are textually identical. See [`EXPORT PART` requirements](/docs/en/antalya/part_export.md#requirements) for a worked example and the corresponding exception message.

## Settings

### Server Settings
Expand Down Expand Up @@ -251,5 +259,4 @@ WHERE source_table = 'rmt_table' AND destination_table = 's3_table';

## Related Features

- [ALTER TABLE EXPORT PART](/docs/en/engines/table-engines/mergetree-family/part_export.md) - Export individual parts (non-replicated)

- [ALTER TABLE EXPORT PART](/docs/en/antalya/part_export.md) - Export individual parts (non-replicated)
132 changes: 129 additions & 3 deletions src/Storages/MergeTree/ExportPartitionUtils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@
#include <Storages/MergeTree/MergeTreeData.h>
#include <filesystem>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <Core/Block.h>
#include <Core/Settings.h>
#include <DataTypes/DataTypeArray.h>
#include <DataTypes/DataTypeLowCardinality.h>
#include <DataTypes/DataTypeMap.h>
#include <DataTypes/DataTypeNullable.h>
#include <DataTypes/DataTypeTuple.h>
#include <DataTypes/Utils.h>
#include <Functions/FunctionHelpers.h>
#include <Interpreters/ActionsDAG.h>
#include <Interpreters/Context.h>
#include <Interpreters/ExpressionActions.h>
#include <Storages/ColumnsDescription.h>

#if USE_AVRO
#include <Storages/ObjectStorage/DataLakes/Iceberg/Constant.h>
Expand Down Expand Up @@ -634,6 +642,106 @@ namespace ExportPartitionUtils
}
#endif

namespace
{
bool haveSameTupleElementLayout(const DataTypePtr & source_type, const DataTypePtr & destination_type)
{
const auto source_type_unwrapped = removeNullable(removeLowCardinality(source_type));
const auto destination_type_unwrapped = removeNullable(removeLowCardinality(destination_type));

const auto * source_tuple = checkAndGetDataType<DataTypeTuple>(source_type_unwrapped.get());
const auto * destination_tuple = checkAndGetDataType<DataTypeTuple>(destination_type_unwrapped.get());
if (source_tuple || destination_tuple)
{
if (!source_tuple || !destination_tuple)
return false;

if (source_tuple->hasExplicitNames() && destination_tuple->hasExplicitNames())
{
if (source_tuple->getElementNames() != destination_tuple->getElementNames())
return false;
}
else if (source_tuple->getElements().size() != destination_tuple->getElements().size())
return false;

const auto & source_elements = source_tuple->getElements();
const auto & destination_elements = destination_tuple->getElements();
for (size_t i = 0; i < source_elements.size(); ++i)
if (!haveSameTupleElementLayout(source_elements[i], destination_elements[i]))
return false;

return true;
}

const auto * source_array = checkAndGetDataType<DataTypeArray>(source_type_unwrapped.get());
const auto * destination_array = checkAndGetDataType<DataTypeArray>(destination_type_unwrapped.get());
if (source_array || destination_array)
{
if (!source_array || !destination_array)
return false;

return haveSameTupleElementLayout(source_array->getNestedType(), destination_array->getNestedType());
}

const auto * source_map = checkAndGetDataType<DataTypeMap>(source_type_unwrapped.get());
const auto * destination_map = checkAndGetDataType<DataTypeMap>(destination_type_unwrapped.get());
if (source_map || destination_map)
{
if (!source_map || !destination_map)
return false;

return haveSameTupleElementLayout(source_map->getKeyType(), destination_map->getKeyType())
&& haveSameTupleElementLayout(source_map->getValueType(), destination_map->getValueType());
}

return true;
}

void verifyPartitionKeyColumn(
const ColumnWithTypeAndName & source_column,
const ColumnWithTypeAndName & destination_column,
size_t position,
const StorageID & destination_storage_id)
{
if (source_column.name != destination_column.name)
throw Exception(
ErrorCodes::BAD_ARGUMENTS,
"Cannot export to {}: partition key column '{}' is at position {} in the source "
"table, but the destination's column at that position is named '{}'. EXPORT "
"PART/PARTITION matches columns by position, so partition key columns must be "
"declared at the same position in both tables.",
destination_storage_id.getFullTableName(),
source_column.name,
position,
destination_column.name);

if (!haveSameTupleElementLayout(source_column.type, destination_column.type))
throw Exception(
ErrorCodes::BAD_ARGUMENTS,
"Cannot export to {}: partition key column '{}' has a different Tuple element "
"layout in the source ({}) and destination ({}). Tuple element names must be "
"declared in the same order in both tables.",
destination_storage_id.getFullTableName(),
source_column.name,
source_column.type->getName(),
destination_column.type->getName());
}
}

void assertPartitionKeyASTAreEqual(
const StorageMetadataPtr & source_metadata,
const StorageMetadataPtr & destination_metadata)
{
constexpr auto query_to_string = [] (const ASTPtr & ast)
{
return ast ? ast->formatWithSecretsOneLine() : "";
};

if (query_to_string(source_metadata->getPartitionKeyAST()) != query_to_string(destination_metadata->getPartitionKeyAST()))
throw Exception(ErrorCodes::BAD_ARGUMENTS,
"Cannot export partition: source and destination tables have different `PARTITION BY` expressions");
}

void verifyExportSchemaCastable(
const StorageMetadataPtr & source_metadata,
const StorageMetadataPtr & destination_metadata,
Expand All @@ -657,15 +765,33 @@ namespace ExportPartitionUtils
ActionsDAG::MatchColumnsMode::Position,
context);

/// Lossy casts may silently change values, so reject them unless the user opts in.
if (context->getSettingsRef()[Setting::export_merge_tree_part_allow_lossy_cast])
return;
const auto & source_columns_description = source_metadata->getColumns();
/// Collect the top-level columns that own columns or subcolumns required by `PARTITION BY`.
/// For example, both `PARTITION BY t.a` and `PARTITION BY (t.a, t.b)` add `t`.
std::unordered_set<String> partition_key_owner_columns;
for (const auto & column_or_subcolumn_name : source_metadata->getColumnsRequiredForPartitionKey())
{
auto resolved = source_columns_description.tryGetColumnOrSubcolumn(
GetColumnsOptions::All, column_or_subcolumn_name);
const auto & column_name = resolved ? resolved->getNameInStorage() : column_or_subcolumn_name;
partition_key_owner_columns.insert(column_name);
}

const bool allow_lossy_cast = context->getSettingsRef()[Setting::export_merge_tree_part_allow_lossy_cast];

const size_t num_columns = std::min(source_columns.size(), destination_columns.size());
for (size_t i = 0; i < num_columns; ++i)
{
const auto & source_column = source_columns[i];
const auto & destination_column = destination_columns[i];

if (partition_key_owner_columns.contains(source_column.name))
verifyPartitionKeyColumn(source_column, destination_column, i, destination_storage_id);

/// Lossy casts may silently change values, so reject them unless the user opts in.
if (allow_lossy_cast)
continue;

if (!canBeSafelyCast(source_column.type, destination_column.type))
throw Exception(ErrorCodes::INCOMPATIBLE_COLUMNS,
"Cannot export to {}: column '{}' requires a lossy cast from {} to {}, "
Expand Down
4 changes: 4 additions & 0 deletions src/Storages/MergeTree/ExportPartitionUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ namespace ExportPartitionUtils
const std::string & exception_message,
const LoggerPtr & log);

void assertPartitionKeyASTAreEqual(
const StorageMetadataPtr & source_metadata,
const StorageMetadataPtr & destination_metadata);

/// Validates that source columns can be exported into the destination with the
/// same positional CAST matching as `INSERT INTO dest SELECT * FROM src`. Lossy
/// casts are rejected unless `export_merge_tree_part_allow_lossy_cast` is set.
Expand Down
12 changes: 1 addition & 11 deletions src/Storages/MergeTree/MergeTreeData.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6726,11 +6726,6 @@ void MergeTreeData::exportPartToTable(
if (!dest_storage->supportsImport(query_context))
throw Exception(ErrorCodes::NOT_IMPLEMENTED, "Destination storage {} does not support MergeTree parts or uses unsupported partitioning", dest_storage->getName());

auto query_to_string = [] (const ASTPtr & ast)
{
return ast ? ast->formatWithSecretsOneLine() : "";
};

auto source_metadata_ptr = getInMemoryMetadataPtr();
auto destination_metadata_ptr = dest_storage->getInMemoryMetadataPtr();

Expand Down Expand Up @@ -6791,13 +6786,8 @@ void MergeTreeData::exportPartToTable(
ExportPartitionUtils::verifyExportSchemaCastable(
source_metadata_ptr, destination_metadata_ptr, dest_storage->getStorageID(), query_context);

/// Iceberg partition compatibility is checked above; here we only need the
/// partition-key ASTs to match (partition-column types follow the lossy-cast gate).
if (!dest_storage->isDataLake())
{
if (query_to_string(source_metadata_ptr->getPartitionKeyAST()) != query_to_string(destination_metadata_ptr->getPartitionKeyAST()))
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Tables have different partition key");
}
ExportPartitionUtils::assertPartitionKeyASTAreEqual(source_metadata_ptr, destination_metadata_ptr);

auto part = getPartIfExists(part_name, {MergeTreeDataPartState::Active, MergeTreeDataPartState::Outdated});

Expand Down
12 changes: 1 addition & 11 deletions src/Storages/StorageReplicatedMergeTree.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8408,25 +8408,15 @@ void StorageReplicatedMergeTree::exportPartitionToTable(const PartitionCommand &
if (!dest_storage->supportsImport(query_context))
throw Exception(ErrorCodes::NOT_IMPLEMENTED, "Destination storage {} does not support MergeTree parts or uses unsupported partitioning", dest_storage->getName());

auto query_to_string = [] (const ASTPtr & ast)
{
return ast ? ast->formatWithSecretsOneLine() : "";
};

auto src_snapshot = getInMemoryMetadataPtr();
auto destination_snapshot = dest_storage->getInMemoryMetadataPtr();

/// Positional CAST matching, like `INSERT INTO dest SELECT * FROM src`.
ExportPartitionUtils::verifyExportSchemaCastable(
src_snapshot, destination_snapshot, dest_storage->getStorageID(), query_context);

/// Iceberg partition compatibility is checked below; here we only need the
/// partition-key ASTs to match (partition-column types follow the lossy-cast gate).
if (!dest_storage->isDataLake())
{
if (query_to_string(src_snapshot->getPartitionKeyAST()) != query_to_string(destination_snapshot->getPartitionKeyAST()))
throw Exception(ErrorCodes::BAD_ARGUMENTS, "Tables have different partition key");
}
ExportPartitionUtils::assertPartitionKeyASTAreEqual(src_snapshot, destination_snapshot);

zkutil::ZooKeeperPtr zookeeper = getZooKeeperAndAssertNotReadonly();

Expand Down
Loading
Loading