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 9895baaa9d [python] Check option changes on tables with snapshots like
Java (#10856)
9895baaa9d is described below
commit 9895baaa9dde96dc12cabeffeaf7fee15aa1e76d
Author: Jiajia Li <[email protected]>
AuthorDate: Thu Oct 29 10:10:18 2026 +0800
[python] Check option changes on tables with snapshots like Java (#10856)
---
.../pypaimon/common/options/core_options.py | 1 +
paimon-python/pypaimon/schema/schema_manager.py | 123 +++++++++++--
.../pypaimon/tests/filesystem_catalog_test.py | 12 ++
.../pypaimon/tests/schema_manager_test.py | 198 ++++++++++++++++++++-
4 files changed, 321 insertions(+), 13 deletions(-)
diff --git a/paimon-python/pypaimon/common/options/core_options.py
b/paimon-python/pypaimon/common/options/core_options.py
index 91ff535c2b..8c5214f52a 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -123,6 +123,7 @@ class CoreOptions:
"type",
"bucket-key",
"bucket-function.type",
+ "bucket-function.provider",
"data-file.path-directory",
"merge-engine",
"sequence.snapshot-ordering",
diff --git a/paimon-python/pypaimon/schema/schema_manager.py
b/paimon-python/pypaimon/schema/schema_manager.py
index 0d375d49a2..785a86af50 100644
--- a/paimon-python/pypaimon/schema/schema_manager.py
+++ b/paimon-python/pypaimon/schema/schema_manager.py
@@ -23,6 +23,7 @@ from pypaimon.common.file_io import FileIO
from pypaimon.common.identifier import DEFAULT_MAIN_BRANCH
from pypaimon.common.json_util import JSON
from pypaimon.common.options import CoreOptions, Options
+from pypaimon.common.options.options_utils import OptionsUtils
from pypaimon.schema.column_directive_utils import (
apply_add_column_directive, apply_directives,
remove_dropped_directive_options)
@@ -305,18 +306,115 @@ def _is_unchanged_normalized_key(key, old_value,
new_value, old_table_schema) ->
"""Values whose canonical home is outside the options map (or the implicit
default) are not a change when replayed. Mirrors the Java SchemaManager's
isUnchangedNormalizedKey."""
- if old_value is not None or new_value is None:
+ if new_value is None:
+ return False
+ if key == "type":
+ effective_old = CoreOptions.TYPE.default_value() if old_value is None
else old_value
+ return new_value.lower() == effective_old.lower()
+ if old_value is not None:
return False
if key == "primary-key":
return _normalize_key_list(new_value) == old_table_schema.primary_keys
if key == "partition":
return _normalize_key_list(new_value) ==
old_table_schema.partition_keys
- if key == "type":
- # a table created without an explicit type is of the default type
- return new_value == CoreOptions.TYPE.default_value()
return False
+def _parse_bucket(value: str) -> int:
+ # Match Integer.parseInt's signed 32-bit decimal syntax.
+ if value is None:
+ raise ValueError("null")
+ negative = value.startswith("-")
+ digits = value[1:] if value.startswith(("+", "-")) else value
+ try:
+ if not digits:
+ raise ValueError
+ bucket = 0
+ limit = 2147483648 if negative else 2147483647
+ for c in digits:
+ if not c.isdecimal() or ord(c) > 0xffff:
+ raise ValueError
+ bucket = bucket * 10 + int(c)
+ if bucket > limit:
+ raise ValueError
+ return -bucket if negative else bucket
+ except ValueError:
+ raise ValueError(f'For input string: "{value}"') from None
+
+
+def _option_text(value) -> Optional[str]:
+ # Options may be persisted as native values such as 2, True or enums;
compare them as Options reads them.
+ return None if value is None else OptionsUtils.convert_to_string(value)
+
+
+def _option_bool(value) -> bool:
+ # Same interpretation as the runtime; unparsable values are false, like
Boolean.parseBoolean.
+ try:
+ return value is not None and OptionsUtils.convert_to_boolean(value)
+ except ValueError:
+ return False
+
+
+def _check_alter_table_option(options: dict, key: str, old_value:
Optional[str], new_value: str):
+ # Booleans are read from the raw values, the way PyPaimon reads them.
+ raw_old, raw_new = old_value, new_value
+ old_value, new_value = _option_text(old_value), _option_text(new_value)
+ if key in CoreOptions.IMMUTABLE_OPTIONS:
+ raise NotImplementedError(f"Change '{key}' is not supported yet.")
+
+ if key == "data-evolution.nested-field.enabled":
+ old_enabled = _option_bool(raw_old)
+ new_enabled = _option_bool(raw_new)
+ if old_enabled and not new_enabled:
+ raise NotImplementedError(f"Cannot disable table option '{key}'.")
+
+ if key == "bucket":
+ old_bucket = CoreOptions.BUCKET.default_value() if old_value is None
else _parse_bucket(old_value)
+ new_bucket = _parse_bucket(new_value)
+ if old_bucket == -1:
+ raise NotImplementedError("Cannot change bucket when it is -1.")
+ if new_bucket == -1:
+ raise NotImplementedError("Cannot change bucket to -1.")
+
+ if key == "deletion-vectors.enabled":
+ dv_modifiable =
_option_bool(options.get("deletion-vectors.modifiable"))
+ if not dv_modifiable:
+ old_dv = _option_bool(raw_old)
+ new_dv = _option_bool(raw_new)
+ if old_dv != new_dv:
+ raise NotImplementedError(
+ f"Cannot change deletion vectors mode from
{str(old_dv).lower()} to {str(new_dv).lower()}. "
+ "If modifying table deletion-vectors mode without
full-compaction, "
+ "this may result in data duplication. If you are
confident, you can set table option "
+ "'deletion-vectors.modifiable' = 'true' to allow deletion
vectors modification.")
+
+ if key in ("ignore-delete", "ignore-update-before"):
+ # The writers read ignore-update-before as the literal "true" only.
+ parse = _option_bool if key == "ignore-delete" else (lambda v:
str(v).lower() == "true")
+ old_ignore = parse(raw_old)
+ new_ignore = parse(raw_new)
+ if old_ignore and not new_ignore:
+ raise NotImplementedError(f"Cannot change {key} from true to
false.")
+
+ if key == "clustering.columns" and "pk-clustering-override" in options:
+ raise NotImplementedError("Cannot change clustering.columns when
pk-clustering-override enabled.")
+
+
+def _check_reset_table_option(options: dict, key: str):
+ if key in CoreOptions.IMMUTABLE_OPTIONS:
+ raise NotImplementedError(f"Change '{key}' is not supported yet.")
+
+ if key == "bucket":
+ raise NotImplementedError(f"Cannot reset {key}.")
+
+ if key in ("deletion-vectors.enabled",
"data-evolution.nested-field.enabled",
+ "ignore-delete", "ignore-update-before"):
+ _check_alter_table_option(options, key, options.get(key), "false")
+
+ if key == "clustering.columns" and "pk-clustering-override" in options:
+ raise NotImplementedError(f"Cannot reset {key} when
pk-clustering-override enabled.")
+
+
def _assert_not_updating_partition_keys(
schema: 'TableSchema', field_names: List[str], operation: str):
if len(field_names) > 1:
@@ -754,6 +852,7 @@ class SchemaManager:
def _generate_table_schema(
self, old_table_schema: TableSchema, changes: List[SchemaChange]
) -> TableSchema:
+ old_options = dict(old_table_schema.options)
new_options = dict(old_table_schema.options)
new_fields = []
for field in old_table_schema.fields:
@@ -798,7 +897,7 @@ class SchemaManager:
# compare against the accumulated options so repeated changes
to
# the same key in one batch apply in order
old_value = new_options.get(change.key)
- unchanged = (old_value == change.value
+ unchanged = (_option_text(old_value) ==
_option_text(change.value)
or _is_unchanged_normalized_key(
change.key, old_value, change.value,
old_table_schema))
if unchanged:
@@ -807,15 +906,17 @@ class SchemaManager:
# format tables never create Paimon snapshots but can still
hold
# data -- reject independently of the snapshot check
if change.key == "type":
- raise ValueError(f"Change '{change.key}' is not supported
yet.")
- if change.key in CoreOptions.IMMUTABLE_OPTIONS and
_has_snapshots():
- raise ValueError(f"Change '{change.key}' is not supported
yet.")
+ raise NotImplementedError(f"Change '{change.key}' is not
supported yet.")
+ if _has_snapshots():
+ # Like Java, checks compare with the table's options
before the batch.
+ _check_alter_table_option(
+ old_options, change.key, old_options.get(change.key),
change.value)
new_options[change.key] = change.value
elif isinstance(change, RemoveOption):
if change.key == "type":
- raise ValueError(f"Change '{change.key}' is not supported
yet.")
- if change.key in CoreOptions.IMMUTABLE_OPTIONS and
_has_snapshots():
- raise ValueError(f"Change '{change.key}' is not supported
yet.")
+ raise NotImplementedError(f"Change '{change.key}' is not
supported yet.")
+ if _has_snapshots():
+ _check_reset_table_option(old_options, change.key)
new_options.pop(change.key, None)
elif isinstance(change, UpdateComment):
new_comment = change.comment
diff --git a/paimon-python/pypaimon/tests/filesystem_catalog_test.py
b/paimon-python/pypaimon/tests/filesystem_catalog_test.py
index 4690ffeefe..f30e44a6b0 100644
--- a/paimon-python/pypaimon/tests/filesystem_catalog_test.py
+++ b/paimon-python/pypaimon/tests/filesystem_catalog_test.py
@@ -304,6 +304,18 @@ class FileSystemCatalogTest(unittest.TestCase):
self.assertIn(
"Change 'index-file-in-data-file-dir' is not supported yet.",
str(ctx.exception))
+ for change, message in [
+ (SchemaChange.set_option("bucket", "4"), "Cannot change bucket
when it is -1."),
+ (SchemaChange.remove_option("bucket"), "Cannot reset bucket."),
+ (SchemaChange.set_option("deletion-vectors.enabled", "true"),
"Cannot change deletion vectors mode"),
+ (SchemaChange.set_option("bucket-function.provider", "custom"),
+ "Change 'bucket-function.provider' is not supported yet."),
+ ]:
+ with self.assertRaises(RuntimeError) as ctx:
+ catalog.alter_table(identifier, [change], False)
+ self.assertIn(message, str(ctx.exception))
+ self.assertEqual(catalog.get_table(identifier).table_schema.id,
table.table_schema.id)
+
# setting the default type explicitly is not a change
catalog.alter_table(
identifier, [SchemaChange.set_option("type", "table")], False)
diff --git a/paimon-python/pypaimon/tests/schema_manager_test.py
b/paimon-python/pypaimon/tests/schema_manager_test.py
index dd73d9142d..26354a6334 100644
--- a/paimon-python/pypaimon/tests/schema_manager_test.py
+++ b/paimon-python/pypaimon/tests/schema_manager_test.py
@@ -18,18 +18,20 @@
import os
import tempfile
import unittest
+from unittest.mock import patch
from pypaimon.branch.branch_manager import BranchManager
from pypaimon.common.identifier import DEFAULT_MAIN_BRANCH
from pypaimon.common.file_io import FileIO
-from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.options.core_options import CoreOptions, MergeEngine
from pypaimon.common.options.options import Options
from pypaimon.schema.data_types import (ArrayType, AtomicType, DataField,
MapType, RowType)
from pypaimon.schema.schema import Schema
-from pypaimon.schema.schema_change import SetOption
+from pypaimon.schema.schema_change import RemoveOption, SetOption
from pypaimon.schema.schema_manager import SchemaManager
from pypaimon.schema.table_schema import TableSchema
+from pypaimon.snapshot.snapshot_manager import SnapshotManager
class TestSchemaManagerBranch(unittest.TestCase):
@@ -295,6 +297,198 @@ class TestOptionValidation(unittest.TestCase):
self.assertNotIn(key, manager.latest().options)
+class TestAlterTableOptions(unittest.TestCase):
+
+ def setUp(self):
+ self.temp_dir = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp_dir.cleanup)
+ self.file_io = FileIO.get(self.temp_dir.name, Options({}))
+ self.manager = SchemaManager(self.file_io, self.temp_dir.name)
+
+ def _alter(self, options, changes, has_snapshots=True):
+ schema = TableSchema.from_schema(0, Schema(
+ fields=[DataField(0, "id", AtomicType("INT"))], options=options))
+ with patch.object(SnapshotManager, "get_latest_snapshot",
+ return_value=object() if has_snapshots else None):
+ return self.manager._generate_table_schema(schema, changes).options
+
+ def _assert_rejected(self, options, changes, message):
+ with self.assertRaises(NotImplementedError) as ctx:
+ self._alter(options, changes)
+ self.assertEqual(str(ctx.exception), message)
+
+ def test_immutable_options(self):
+ for key in CoreOptions.IMMUTABLE_OPTIONS |
{"bucket-function.provider"}:
+ with self.subTest(key=key):
+ old_value = "false" if key == "primary-key.nullable" else "old"
+ for options, change in [
+ ({}, SetOption(key, "new")),
+ ({key: old_value}, SetOption(key, "new")),
+ ({}, RemoveOption(key)),
+ ({key: old_value}, RemoveOption(key)),
+ ]:
+ self._assert_rejected(options, [change], f"Change '{key}'
is not supported yet.")
+ self.assertEqual(self._alter({key: old_value}, [SetOption(key,
old_value)]), {key: old_value})
+
+ def test_bucket_changes(self):
+ for old_value, new_value, message in [
+ (None, "4", "Cannot change bucket when it is -1."),
+ (None, "-1", "Cannot change bucket when it is -1."),
+ ("-1", "4", "Cannot change bucket when it is -1."),
+ ("-1", "-01", "Cannot change bucket when it is -1."),
+ ("4", "-1", "Cannot change bucket to -1."),
+ ]:
+ with self.subTest(old=old_value, new=new_value):
+ options = {} if old_value is None else {"bucket": old_value}
+ self._assert_rejected(options, [SetOption("bucket",
new_value)], message)
+ for options in [{}, {"bucket": "-1"}, {"bucket": "4"}]:
+ self._assert_rejected(options, [RemoveOption("bucket")], "Cannot
reset bucket.")
+ for old_value, new_value in [
+ ("-1", "-1"), ("4", "8"), ("4", "+4"), ("-2", "4"),
+ ("4", "2147483647"), ("4", "-2147483648"), ("4", "\uff14"), ("4",
"0" * 5000 + "4"),
+ ]:
+ self.assertEqual(
+ self._alter({"bucket": old_value}, [SetOption("bucket",
new_value)]),
+ {"bucket": new_value})
+
+ def test_bucket_requires_java_integer(self):
+ for value in ["", "+", "invalid", " 4", "4 ", "4_0", "1.0",
"2147483648", "-2147483649", "\U0001d7dc"]:
+ for old_value, new_value in [("4", value), (value, "4")]:
+ with self.subTest(old=old_value, new=new_value):
+ with self.assertRaises(ValueError) as ctx:
+ self._alter({"bucket": old_value},
[SetOption("bucket", new_value)])
+ self.assertEqual(str(ctx.exception), f'For input string:
"{value}"')
+
+ def test_deletion_vectors_changes(self):
+ key = "deletion-vectors.enabled"
+ for old_value, new_value in [(None, "true"), ("false", "true"),
("true", "false"), ("true", None)]:
+ for modifiable in [None, "false", "no"]:
+ options = {} if old_value is None else {key: old_value}
+ if modifiable is not None:
+ options["deletion-vectors.modifiable"] = modifiable
+ message = (
+ f"Cannot change deletion vectors mode from {old_value or
'false'} to {new_value or 'false'}. "
+ "If modifying table deletion-vectors mode without
full-compaction, "
+ "this may result in data duplication. If you are
confident, you can set table option "
+ "'deletion-vectors.modifiable' = 'true' to allow deletion
vectors modification.")
+ changes = [SetOption(key, new_value)]
+ if new_value == "false":
+ changes.append(RemoveOption(key))
+ for change in changes:
+ with self.subTest(old=old_value, new=new_value,
modifiable=modifiable, change=change):
+ self._assert_rejected(options, [change], message)
+ for old_value, new_value in [(None, "false"), ("false", "FALSE"),
("true", "TRUE")]:
+ options = {} if old_value is None else {key: old_value}
+ self.assertEqual(self._alter(options, [SetOption(key,
new_value)])[key], new_value)
+ for options in [{}, {key: "false"}]:
+ self.assertEqual(self._alter(options, [RemoveOption(key)]), {})
+ for change in [SetOption(key, "true"), SetOption(key, "false"),
RemoveOption(key)]:
+ for old_value in ["false", "true"]:
+ options = {key: old_value, "deletion-vectors.modifiable":
"TRUE"}
+ result = self._alter(options, [change])
+ self.assertEqual(result.get(key), getattr(change, "value",
None))
+
+ def test_options_cannot_be_disabled(self):
+ for key, message in [
+ ("ignore-delete", "Cannot change ignore-delete from true to
false."),
+ ("ignore-update-before", "Cannot change ignore-update-before from
true to false."),
+ ("data-evolution.nested-field.enabled",
+ "Cannot disable table option
'data-evolution.nested-field.enabled'."),
+ ]:
+ with self.subTest(key=key):
+ for change in [SetOption(key, "false"), SetOption(key, None),
SetOption(key, "no"),
+ RemoveOption(key)]:
+ self._assert_rejected({key: "TRUE"}, [change], message)
+ for old_value in [None, "false", "true"]:
+ options = {} if old_value is None else {key: old_value}
+ self.assertEqual(self._alter(options, [SetOption(key,
"true")]), {key: "true"})
+ for options in [{}, {key: "false"}]:
+ self.assertEqual(self._alter(options,
[RemoveOption(key)]), {})
+
+ def test_clustering_columns(self):
+ key = "clustering.columns"
+ for override in ["true", "false"]:
+ for old_value in [None, "id"]:
+ options = {"pk-clustering-override": override}
+ if old_value is not None:
+ options[key] = old_value
+ self._assert_rejected(options, [SetOption(key, "name")],
+ "Cannot change clustering.columns when
pk-clustering-override enabled.")
+ self._assert_rejected(options, [RemoveOption(key)],
+ "Cannot reset clustering.columns when
pk-clustering-override enabled.")
+ self.assertEqual(self._alter(options, [SetOption(key, "id")]),
options)
+ self.assertEqual(self._alter({}, [SetOption(key, "id")]), {key: "id"})
+ self.assertEqual(self._alter({key: "id"}, [RemoveOption(key)]), {})
+
+ def test_guards_require_snapshots(self):
+ for options, key, value in [
+ ({"bucket": "-1"}, "bucket", "4"),
+ ({"bucket": "4"}, "bucket", "-1"),
+ ({"deletion-vectors.enabled": "true"}, "deletion-vectors.enabled",
"false"),
+ ({"ignore-delete": "true"}, "ignore-delete", "false"),
+ ({"ignore-update-before": "true"}, "ignore-update-before",
"false"),
+ ({"data-evolution.nested-field.enabled": "true"},
"data-evolution.nested-field.enabled", "false"),
+ ({"pk-clustering-override": "true", "clustering.columns": "id"},
"clustering.columns", "name"),
+ ({"bucket-function.provider": "old"}, "bucket-function.provider",
"new"),
+ ]:
+ with self.subTest(key=key):
+ self.assertEqual(self._alter(options, [SetOption(key, value)],
False)[key], value)
+ self.assertNotIn(key, self._alter(options,
[RemoveOption(key)], False))
+ for change in [SetOption("type", "format-table"),
RemoveOption("type")]:
+ with self.assertRaisesRegex(NotImplementedError, "Change 'type' is
not supported yet."):
+ self._alter({}, [change], False)
+
+ def test_checks_accept_native_option_values(self):
+ self.assertEqual(self._alter({"bucket": 2}, [SetOption("bucket",
"4")]), {"bucket": "4"})
+ self.assertEqual(self._alter({"bucket": -1}, [SetOption("bucket",
"-1")]), {"bucket": -1})
+ self.assertEqual(self._alter({"ignore-delete": True},
[SetOption("ignore-delete", "true")]),
+ {"ignore-delete": "true"})
+ self.assertEqual(self._alter({"partition.default-name": True},
[SetOption("partition.default-name", "true")]),
+ {"partition.default-name": "true"})
+ self.assertEqual(self._alter({"deletion-vectors.enabled": "yes"},
[SetOption("deletion-vectors.enabled", "1")]),
+ {"deletion-vectors.enabled": "1"})
+ with self.assertRaisesRegex(NotImplementedError, "Cannot change
deletion vectors mode"):
+ self._alter({"deletion-vectors.enabled": "false"},
[SetOption("deletion-vectors.enabled", "yes")])
+ dv = "deletion-vectors.enabled"
+ self.assertEqual(self._alter({dv: 1.0}, [SetOption(dv, "true")]), {dv:
"true"})
+ self.assertEqual(self._alter({"ignore-update-before": "yes"},
[SetOption("ignore-update-before", "false")]),
+ {"ignore-update-before": "false"})
+ self.assertEqual(self._alter({"merge-engine": "first-row"},
[SetOption("merge-engine", MergeEngine.FIRST_ROW)]),
+ {"merge-engine": "first-row"})
+ with self.assertRaisesRegex(NotImplementedError, "Cannot change
ignore-delete from true to false"):
+ self._alter({"ignore-delete": True}, [SetOption("ignore-delete",
"false")])
+ options = {"deletion-vectors.modifiable": True}
+ self.assertEqual(self._alter(options,
[SetOption("deletion-vectors.enabled", "true")]),
+ {"deletion-vectors.modifiable": True,
"deletion-vectors.enabled": "true"})
+
+ def test_checks_use_original_options(self):
+ key = "deletion-vectors.enabled"
+ modifiable = "deletion-vectors.modifiable"
+ for changes in [
+ [SetOption(modifiable, "true"), SetOption(key, "true")],
+ [SetOption(modifiable, "true"), RemoveOption(key)],
+ ]:
+ options = {key: "true"} if isinstance(changes[1], RemoveOption)
else {}
+ with self.assertRaisesRegex(NotImplementedError, "Cannot change
deletion vectors mode"):
+ self._alter(options, changes)
+ self.assertEqual(self._alter({modifiable: "true"},
[RemoveOption(modifiable), SetOption(key, "true")]),
+ {key: "true"})
+ self.assertEqual(self._alter({}, [SetOption("ignore-delete", "true"),
SetOption("ignore-delete", "false")]),
+ {"ignore-delete": "false"})
+ self.assertEqual(self._alter({"bucket": "4"}, [SetOption("bucket",
"8"), SetOption("bucket", "4")]),
+ {"bucket": "4"})
+
+ def test_normalized_unchanged_options(self):
+ for options in [{}, {"type": "table"}, {"type": "TABLE"}]:
+ self.assertEqual(self._alter(options, [SetOption("type",
"TaBlE")]), options)
+ schema = TableSchema.from_schema(0, Schema(
+ fields=[DataField(0, "id", AtomicType("INT"))],
primary_keys=["id"], partition_keys=["id"]))
+ with patch.object(SnapshotManager, "get_latest_snapshot",
return_value=object()):
+ result = self.manager._generate_table_schema(schema, [
+ SetOption("primary-key", " id, "), SetOption("partition", "
id, ")])
+ self.assertEqual(result.options, schema.options)
+
+
class TestRowTrackingValidation(unittest.TestCase):
"""Row tracking tables must follow Java
SchemaValidation#validateRowTracking."""