@@ -219,6 +219,138 @@ def test_alter_table(self):
219219 table = catalog .get_table (identifier )
220220 self .assertEqual (len (table .fields ), 2 )
221221
222+ def test_alter_immutable_options (self ):
223+ catalog = CatalogFactory .create ({"warehouse" : self .warehouse })
224+ catalog .create_database ("test_db" , False )
225+
226+ # while a table has no snapshots, structural options may still be changed
227+ empty_identifier = "test_db.immutable_options_empty"
228+ pa_schema = pa .schema ([('id' , pa .int32 ()), ('name' , pa .string ())])
229+ catalog .create_table (empty_identifier , Schema .from_pyarrow_schema (pa_schema ), False )
230+ catalog .alter_table (
231+ empty_identifier ,
232+ [SchemaChange .set_option ("merge-engine" , "first-row" )],
233+ False
234+ )
235+ # the type is rejected even without snapshots: table kinds like format
236+ # tables never create Paimon snapshots but can still hold data
237+ with self .assertRaises (RuntimeError ) as ctx :
238+ catalog .alter_table (
239+ empty_identifier ,
240+ [SchemaChange .set_option ("type" , "format-table" )], False )
241+ self .assertIn ("Change 'type' is not supported yet." , str (ctx .exception ))
242+ with self .assertRaises (RuntimeError ):
243+ catalog .alter_table (
244+ empty_identifier , [SchemaChange .remove_option ("type" )], False )
245+
246+ # once the table has a snapshot, immutable options are rejected
247+ identifier = "test_db.immutable_options_table"
248+ catalog .create_table (identifier , Schema .from_pyarrow_schema (pa_schema ), False )
249+ table = catalog .get_table (identifier )
250+ write_builder = table .new_batch_write_builder ()
251+ table_write = write_builder .new_write ()
252+ table_commit = write_builder .new_commit ()
253+ table_write .write_arrow (
254+ pa .Table .from_pydict ({'id' : [1 , 2 ], 'name' : ['a' , 'b' ]}, schema = pa_schema ))
255+ table_commit .commit (table_write .prepare_commit ())
256+ table_write .close ()
257+ table_commit .close ()
258+
259+ # the catalog wraps alter failures in RuntimeError
260+ with self .assertRaises (RuntimeError ) as ctx :
261+ catalog .alter_table (
262+ identifier , [SchemaChange .set_option ("type" , "format-table" )], False )
263+ self .assertIn ("Change 'type' is not supported yet." , str (ctx .exception ))
264+
265+ with self .assertRaises (RuntimeError ) as ctx :
266+ catalog .alter_table (
267+ identifier , [SchemaChange .set_option ("merge-engine" , "deduplicate" )], False )
268+ self .assertIn ("Change 'merge-engine' is not supported yet." , str (ctx .exception ))
269+
270+ with self .assertRaises (RuntimeError ) as ctx :
271+ catalog .alter_table (
272+ identifier , [SchemaChange .remove_option ("merge-engine" )], False )
273+ self .assertIn ("Change 'merge-engine' is not supported yet." , str (ctx .exception ))
274+
275+ # canonical hyphenated keys are guarded too
276+ with self .assertRaises (RuntimeError ) as ctx :
277+ catalog .alter_table (
278+ identifier ,
279+ [SchemaChange .set_option ("index-file-in-data-file-dir" , "true" )], False )
280+ self .assertIn (
281+ "Change 'index-file-in-data-file-dir' is not supported yet." , str (ctx .exception ))
282+
283+ # setting the default type explicitly is not a change
284+ catalog .alter_table (
285+ identifier , [SchemaChange .set_option ("type" , "table" )], False )
286+ table = catalog .get_table (identifier )
287+ self .assertNotIn ("type" , table .table_schema .options )
288+ # ...and the reloaded effective type resolves to the requested "table"
289+ # (the default), not some other configured default
290+ from pypaimon .common .options import CoreOptions , Options
291+ self .assertEqual (
292+ CoreOptions (Options (table .table_schema .options )).type (), "table" )
293+
294+ # mutable options can still be changed
295+ catalog .alter_table (
296+ identifier , [SchemaChange .set_option ("target-file-size" , "10mb" )], False )
297+
298+ # repeated changes to the same option in one batch apply in order
299+ catalog .alter_table (identifier , [
300+ SchemaChange .set_option ("target-file-size" , "20mb" ),
301+ SchemaChange .set_option ("target-file-size" , "10mb" )], False )
302+ table = catalog .get_table (identifier )
303+ self .assertEqual (table .table_schema .options .get ("target-file-size" ), "10mb" )
304+ catalog .alter_table (identifier , [
305+ SchemaChange .remove_option ("target-file-size" ),
306+ SchemaChange .set_option ("target-file-size" , "10mb" )], False )
307+ table = catalog .get_table (identifier )
308+ self .assertEqual (table .table_schema .options .get ("target-file-size" ), "10mb" )
309+
310+ # replaying the actual primary/partition keys is not a change: they are
311+ # stored in the schema fields, not the options map
312+ pk_identifier = "test_db.immutable_options_pk"
313+ catalog .create_table (
314+ pk_identifier ,
315+ Schema .from_pyarrow_schema (
316+ pa_schema , primary_keys = ['id' ], options = {'bucket' : '1' }),
317+ False )
318+ pk_table = catalog .get_table (pk_identifier )
319+ write_builder = pk_table .new_batch_write_builder ()
320+ table_write = write_builder .new_write ()
321+ table_commit = write_builder .new_commit ()
322+ table_write .write_arrow (
323+ pa .Table .from_pydict ({'id' : [1 ], 'name' : ['a' ]}, schema = pa_schema ))
324+ table_commit .commit (table_write .prepare_commit ())
325+ table_write .close ()
326+ table_commit .close ()
327+ catalog .alter_table (
328+ pk_identifier , [SchemaChange .set_option ("primary-key" , "id" )], False )
329+ pk_table = catalog .get_table (pk_identifier )
330+ self .assertNotIn ("primary-key" , pk_table .table_schema .options )
331+ with self .assertRaises (RuntimeError ):
332+ catalog .alter_table (
333+ pk_identifier , [SchemaChange .set_option ("primary-key" , "name" )], False )
334+
335+ part_identifier = "test_db.immutable_options_part"
336+ self ._create_partitioned_table_with_data (
337+ catalog , part_identifier , [{'dt' : '2026-01-01' , 'rows' : 1 }])
338+ catalog .alter_table (
339+ part_identifier , [SchemaChange .set_option ("partition" , "dt" )], False )
340+ with self .assertRaises (RuntimeError ):
341+ catalog .alter_table (
342+ part_identifier , [SchemaChange .set_option ("partition" , "col1" )], False )
343+
344+ # the LATEST file is only a hint: the guard must still see the
345+ # snapshots when it is missing
346+ os .remove (os .path .join (
347+ self .warehouse , "test_db.db" , "immutable_options_table" ,
348+ "snapshot" , "LATEST" ))
349+ with self .assertRaises (RuntimeError ):
350+ catalog .alter_table (
351+ identifier ,
352+ [SchemaChange .set_option ("merge-engine" , "deduplicate" )], False )
353+
222354 def test_update_column_type_guards_null_to_not_null (self ):
223355 catalog = CatalogFactory .create ({"warehouse" : self .warehouse })
224356 catalog .create_database ("test_db_guard" , False )
0 commit comments