This is an automated email from the ASF dual-hosted git repository.

AHeise pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git


The following commit(s) were added to refs/heads/master by this push:
     new d70e596602b [FLINK-39847][table] Support in-place conversion of a 
table to a materialized table (#28287)
d70e596602b is described below

commit d70e596602b6211a415be40edab9a0f7bc7328dd
Author: Ramin Gharib <[email protected]>
AuthorDate: Mon Jun 15 22:08:40 2026 +0200

    [FLINK-39847][table] Support in-place conversion of a table to a 
materialized table (#28287)
    
    * [FLINK-39848][table] Add config option and Catalog API for table to 
materialized table conversion
    
    Introduce the building blocks for in-place conversion of a regular table to 
a materialized table. Neither part is wired into SQL execution on its own.
    
    Add the cluster-level 
table.materialized-table.conversion-from-table.enabled option (default false) 
that gates the conversion. It is read from the root configuration, so a 
session-level SET has no effect.
    
    Add Catalog#convertTableToMaterializedTable, which swaps an existing 
regular table's catalog entry for a materialized table in place while 
preserving its identity and storage. The default throws 
UnsupportedOperationException; GenericInMemoryCatalog overrides it with a 
table-kind check. CatalogManager resolves both tables, delegates to the 
catalog, and fires an AlterTableEvent. Launching the refresh job is left to the 
executor, not the catalog.
    
    * [FLINK-39849][table] Convert regular table to materialized table via 
CREATE OR ALTER
    
    handleCreateOrAlter now dispatches on the kind of the existing object: a 
materialized table is altered as before, a view is rejected, and a regular 
table is converted in place to a materialized table when 
table.materialized-table.conversion-from-table.enabled is set on the cluster 
(otherwise the statement is rejected as before). The conversion carries over 
the source table's watermark and primary key when the DDL defines them, 
rejecting a source with more than one watermark. It emits  [...]
    
    * [FLINK-39850][table] Launch refresh job when converting a table to a 
materialized table
    
    The SQL gateway's MaterializedTableManager now handles 
ConvertTableToMaterializedTableOperation: it swaps the catalog entry and then 
launches the refresh job (a continuous streaming job or a full-mode workflow) 
and persists the refresh handler, mirroring CREATE MATERIALIZED TABLE. Without 
this the gateway rejected the operation as unsupported, so conversion swapped 
the catalog entry but never started refreshing.
    
    On a refresh-job-launch failure the conversion makes a best-effort 
rollback: it drops the materialized table and recreates the original regular 
table from the operation's original table. Unlike CREATE there is no clean 
reverse path (the catalog only converts table to materialized table, and 
alterTable rejects kind changes), so this restores the user's prior state 
rather than dropping the table outright.
    
    TestFileSystemCatalog gains the conversion override and the materialized 
table ITCase base enables the cluster-level conversion flag so the path can be 
exercised end to end.
    
    * [FLINK-39851][docs] Document in-place table to materialized table 
conversion
---
 .../docs/sql/materialized-table/statements.md      | 123 ++++
 .../docs/sql/materialized-table/statements.md      | 123 ++++
 .../generated/table_config_configuration.html      |   6 +
 .../table/tests/test_catalog_completeness.py       |   5 +-
 .../MaterializedTableManager.java                  | 183 +++++-
 .../service/MaterializedTableConversionITCase.java | 343 +++++++++++
 .../service/MaterializedTableStatementITCase.java  |   8 +-
 .../service/MaterializedTableTestUtils.java        |  53 ++
 .../flink/table/api/config/TableConfigOptions.java |  15 +
 .../apache/flink/table/catalog/CatalogManager.java |  38 ++
 .../table/catalog/GenericInMemoryCatalog.java      |  17 +
 .../AlterMaterializedTableChangeOperation.java     |   2 +-
 .../ConvertTableToMaterializedTableOperation.java  | 107 ++++
 .../table/catalog/GenericInMemoryCatalogTest.java  |  51 ++
 .../org/apache/flink/table/catalog/Catalog.java    |  25 +
 ...SqlCreateOrAlterMaterializedTableConverter.java | 162 +++--
 .../SqlNodeToOperationConversionTestBase.java      |   2 +-
 ...erationConvertTableToMaterializedTableTest.java | 663 +++++++++++++++++++++
 .../testutils/catalog/TestFileSystemCatalog.java   |  58 +-
 19 files changed, 1895 insertions(+), 89 deletions(-)

diff --git a/docs/content.zh/docs/sql/materialized-table/statements.md 
b/docs/content.zh/docs/sql/materialized-table/statements.md
index 260027f6d02..f8f4f2a93e0 100644
--- a/docs/content.zh/docs/sql/materialized-table/statements.md
+++ b/docs/content.zh/docs/sql/materialized-table/statements.md
@@ -242,6 +242,7 @@ The `OR ALTER` clause provides create-or-update semantics:
 
 - **If the materialized table does not exist**: Creates a new materialized 
table with the specified options
 - **If the materialized table exists**: Modifies the query definition (behaves 
like `ALTER MATERIALIZED TABLE AS`)
+- **If a regular table with the same name exists**: Converts it in place into 
a materialized table, when enabled (see [Converting a Table to a Materialized 
Table](#converting-a-table-to-a-materialized-table))
 
 This is particularly useful in declarative deployment scenarios where you want 
to define the desired state without checking if the materialized table already 
exists.
 
@@ -260,6 +261,128 @@ The operation updates the materialized table similarly to 
[ALTER MATERIALIZED TA
 
 See [ALTER MATERIALIZED TABLE AS](#as-select_statement-1) for more details.
 
+## Converting a Table to a Materialized Table
+
+This lets you adopt a materialized table on top of a table that already 
exists, without dropping and recreating it.
+
+`CREATE OR ALTER MATERIALIZED TABLE` can convert an existing regular table 
into a materialized table in place. The catalog object keeps its identity and 
underlying storage. Its kind becomes materialized table, and its schema, 
options, query definition, freshness, and refresh mode are taken from the 
conversion statement, exactly as for a newly created materialized table. After 
the conversion, a refresh job is launched just as it is for a newly created 
materialized table.
+
+**Enabling conversion**
+
+Conversion is disabled by default. It is a one-way operation: it permanently 
turns a regular table into a materialized table and cannot be undone, because 
there is no operation that converts a materialized table back into a regular 
table. Keeping it off by default also preserves source compatibility. A `CREATE 
OR ALTER MATERIALIZED TABLE` that happens to name an existing regular table 
keeps its previous behavior of being rejected, so no existing workflow silently 
changes meaning until yo [...]
+
+When conversion is disabled, `CREATE OR ALTER MATERIALIZED TABLE` against a 
regular table is rejected. To enable it, set:
+
+```yaml
+table.materialized-table.conversion-from-table.enabled: true
+```
+
+The option is read at planning time from the session's root configuration, so 
it must be set when the `TableEnvironment` session is initialized. Set it in 
the cluster configuration file `config.yaml`, or in the configuration used to 
create the session. Changing it afterwards with a session-level `SET` statement 
has no effect.
+
+**Schema**
+
+The schema comes from the `CREATE OR ALTER MATERIALIZED TABLE` statement and 
its query, exactly as for a brand-new materialized table. Nothing is taken from 
the source table. These are the same rules `CREATE MATERIALIZED TABLE` already 
uses.
+
+The examples below read from a source table `orders` and convert an existing 
regular table `user_spending`:
+
+```sql
+-- Source table the query reads from
+CREATE TABLE orders (
+    user_id BIGINT NOT NULL,
+    amount BIGINT,
+    order_time TIMESTAMP(3)
+) WITH (
+    'connector' = '...'
+);
+
+-- The existing regular table to convert. It has a primary key and a watermark,
+-- which the conversion does not carry over.
+CREATE TABLE user_spending (
+    user_id BIGINT NOT NULL,
+    total_amount BIGINT,
+    last_order TIMESTAMP(3),
+    PRIMARY KEY (user_id) NOT ENFORCED,
+    WATERMARK FOR last_order AS last_order - INTERVAL '5' SECOND
+) WITH (
+    'connector' = '...'
+);
+```
+
+With no column list, the schema is exactly the query output:
+
+```sql
+CREATE OR ALTER MATERIALIZED TABLE user_spending
+    AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY 
user_id;
+-- columns: user_id, total_amount
+-- the source's last_order column, primary key, and watermark are not carried 
over
+```
+
+To keep a primary key or watermark, re-declare it in the conversion statement:
+
+```sql
+CREATE OR ALTER MATERIALIZED TABLE user_spending (
+    PRIMARY KEY (user_id) NOT ENFORCED,
+    WATERMARK FOR order_time AS order_time - INTERVAL '5' SECOND
+) AS SELECT user_id, order_time FROM orders;
+-- columns: user_id, order_time (primary key: user_id, watermark on order_time)
+```
+
+If you declare columns, every persisted column must be produced by the query. 
Types do not have to match exactly: the query type just needs to be safely 
castable to the declared type, for example `INT` to `BIGINT`.
+
+```sql
+-- OK: both declared columns are produced by the query
+CREATE OR ALTER MATERIALIZED TABLE user_spending (user_id BIGINT, total_amount 
BIGINT)
+    AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY 
user_id;
+-- columns: user_id, total_amount
+
+-- Error: `note` is not produced by the query
+CREATE OR ALTER MATERIALIZED TABLE user_spending (user_id BIGINT, total_amount 
BIGINT, note STRING)
+    AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY 
user_id;
+
+-- Error: BIGINT cannot be implicitly cast to BOOLEAN
+CREATE OR ALTER MATERIALIZED TABLE user_spending (user_id BIGINT, total_amount 
BOOLEAN)
+    AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY 
user_id;
+```
+
+A list of names only, without types, reorders the query columns. The names 
must be the query's columns and the count must match:
+
+```sql
+-- query produces (user_id, total_amount); store them in the order 
(total_amount, user_id)
+CREATE OR ALTER MATERIALIZED TABLE user_spending (total_amount, user_id)
+    AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY 
user_id;
+-- columns: total_amount, user_id
+```
+
+You can also declare a primary key, a watermark, computed columns, and 
metadata columns, just like in any materialized table. Computed columns and 
`VIRTUAL` metadata columns are not stored, so they do not need to be in the 
query. Columns you declare that the query does not produce appear before the 
query's columns. The source table's own primary key and watermark are **not** 
carried over, so declare them again if you want them.
+
+```sql
+CREATE OR ALTER MATERIALIZED TABLE user_spending (
+    total_doubled AS total_amount * 2,   -- computed column, not stored
+    PRIMARY KEY (user_id) NOT ENFORCED
+) AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id;
+-- columns: total_doubled, user_id, total_amount (primary key: user_id)
+```
+
+Because the schema, primary key, and watermark all come from the statement and 
its query, not from the existing table, the result is fully defined by the DDL. 
The same statement always produces the same materialized table. Running it 
again after the conversion matches the now-existing materialized table and 
produces the same result.
+
+**Using the converted table**
+
+The conversion is one-way and cannot be undone. You can still use the result 
like a regular table: suspend it to stop the refresh job, then query it.
+
+```sql
+-- Suspend the materialized table to stop the refresh job
+ALTER MATERIALIZED TABLE user_spending SUSPEND;
+
+-- Use the materialized table as a regular table
+SELECT * FROM user_spending;
+```
+
+
+<span class="label label-danger">Note</span>
+- The catalog must support in-place conversion; catalogs that do not implement 
it reject the statement.
+- Converting a view into a materialized table is not supported.
+- If the refresh job (continuous mode) or refresh workflow (full mode) cannot 
be started, the conversion is **not** rolled back: the table is left as a 
materialized table in `SUSPENDED` status. Fix the underlying issue and `RESUME` 
it.
+
 ## 示例
 
 假定 `materialized-table.refresh-mode.freshness-threshold` 为 30 分钟。
diff --git a/docs/content/docs/sql/materialized-table/statements.md 
b/docs/content/docs/sql/materialized-table/statements.md
index 5b0628219cb..2dbb49a8902 100644
--- a/docs/content/docs/sql/materialized-table/statements.md
+++ b/docs/content/docs/sql/materialized-table/statements.md
@@ -242,6 +242,7 @@ The `OR ALTER` clause provides create-or-update semantics:
 
 - **If the materialized table does not exist**: Creates a new materialized 
table with the specified options
 - **If the materialized table exists**: Modifies the query definition (behaves 
like `ALTER MATERIALIZED TABLE AS`)
+- **If a regular table with the same name exists**: Converts it in place into 
a materialized table, when enabled (see [Converting a Table to a Materialized 
Table](#converting-a-table-to-a-materialized-table))
 
 This is particularly useful in declarative deployment scenarios where you want 
to define the desired state without checking if the materialized table already 
exists.
 
@@ -260,6 +261,128 @@ The operation updates the materialized table similarly to 
[ALTER MATERIALIZED TA
 
 See [ALTER MATERIALIZED TABLE AS](#as-select_statement-1) for more details.
 
+## Converting a Table to a Materialized Table
+
+This lets you adopt a materialized table on top of a table that already 
exists, without dropping and recreating it.
+
+`CREATE OR ALTER MATERIALIZED TABLE` can convert an existing regular table 
into a materialized table in place. The catalog object keeps its identity and 
underlying storage. Its kind becomes materialized table, and its schema, 
options, query definition, freshness, and refresh mode are taken from the 
conversion statement, exactly as for a newly created materialized table. After 
the conversion, a refresh job is launched just as it is for a newly created 
materialized table.
+
+**Enabling conversion**
+
+Conversion is disabled by default. It is a one-way operation: it permanently 
turns a regular table into a materialized table and cannot be undone, because 
there is no operation that converts a materialized table back into a regular 
table. Keeping it off by default also preserves source compatibility. A `CREATE 
OR ALTER MATERIALIZED TABLE` that happens to name an existing regular table 
keeps its previous behavior of being rejected, so no existing workflow silently 
changes meaning until yo [...]
+
+When conversion is disabled, `CREATE OR ALTER MATERIALIZED TABLE` against a 
regular table is rejected. To enable it, set:
+
+```yaml
+table.materialized-table.conversion-from-table.enabled: true
+```
+
+The option is read at planning time from the session's root configuration, so 
it must be set when the `TableEnvironment` session is initialized. Set it in 
the cluster configuration file `config.yaml`, or in the configuration used to 
create the session. Changing it afterwards with a session-level `SET` statement 
has no effect.
+
+**Schema**
+
+The schema comes from the `CREATE OR ALTER MATERIALIZED TABLE` statement and 
its query, exactly as for a brand-new materialized table. Nothing is taken from 
the source table. These are the same rules `CREATE MATERIALIZED TABLE` already 
uses.
+
+The examples below read from a source table `orders` and convert an existing 
regular table `user_spending`:
+
+```sql
+-- Source table the query reads from
+CREATE TABLE orders (
+    user_id BIGINT NOT NULL,
+    amount BIGINT,
+    order_time TIMESTAMP(3)
+) WITH (
+    'connector' = '...'
+);
+
+-- The existing regular table to convert. It has a primary key and a watermark,
+-- which the conversion does not carry over.
+CREATE TABLE user_spending (
+    user_id BIGINT NOT NULL,
+    total_amount BIGINT,
+    last_order TIMESTAMP(3),
+    PRIMARY KEY (user_id) NOT ENFORCED,
+    WATERMARK FOR last_order AS last_order - INTERVAL '5' SECOND
+) WITH (
+    'connector' = '...'
+);
+```
+
+With no column list, the schema is exactly the query output:
+
+```sql
+CREATE OR ALTER MATERIALIZED TABLE user_spending
+    AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY 
user_id;
+-- columns: user_id, total_amount
+-- the source's last_order column, primary key, and watermark are not carried 
over
+```
+
+To keep a primary key or watermark, re-declare it in the conversion statement:
+
+```sql
+CREATE OR ALTER MATERIALIZED TABLE user_spending (
+    PRIMARY KEY (user_id) NOT ENFORCED,
+    WATERMARK FOR order_time AS order_time - INTERVAL '5' SECOND
+) AS SELECT user_id, order_time FROM orders;
+-- columns: user_id, order_time (primary key: user_id, watermark on order_time)
+```
+
+If you declare columns, every persisted column must be produced by the query. 
Types do not have to match exactly: the query type just needs to be safely 
castable to the declared type, for example `INT` to `BIGINT`.
+
+```sql
+-- OK: both declared columns are produced by the query
+CREATE OR ALTER MATERIALIZED TABLE user_spending (user_id BIGINT, total_amount 
BIGINT)
+    AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY 
user_id;
+-- columns: user_id, total_amount
+
+-- Error: `note` is not produced by the query
+CREATE OR ALTER MATERIALIZED TABLE user_spending (user_id BIGINT, total_amount 
BIGINT, note STRING)
+    AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY 
user_id;
+
+-- Error: BIGINT cannot be implicitly cast to BOOLEAN
+CREATE OR ALTER MATERIALIZED TABLE user_spending (user_id BIGINT, total_amount 
BOOLEAN)
+    AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY 
user_id;
+```
+
+A list of names only, without types, reorders the query columns. The names 
must be the query's columns and the count must match:
+
+```sql
+-- query produces (user_id, total_amount); store them in the order 
(total_amount, user_id)
+CREATE OR ALTER MATERIALIZED TABLE user_spending (total_amount, user_id)
+    AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY 
user_id;
+-- columns: total_amount, user_id
+```
+
+You can also declare a primary key, a watermark, computed columns, and 
metadata columns, just like in any materialized table. Computed columns and 
`VIRTUAL` metadata columns are not stored, so they do not need to be in the 
query. Columns you declare that the query does not produce appear before the 
query's columns. The source table's own primary key and watermark are **not** 
carried over, so declare them again if you want them.
+
+```sql
+CREATE OR ALTER MATERIALIZED TABLE user_spending (
+    total_doubled AS total_amount * 2,   -- computed column, not stored
+    PRIMARY KEY (user_id) NOT ENFORCED
+) AS SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id;
+-- columns: total_doubled, user_id, total_amount (primary key: user_id)
+```
+
+Because the schema, primary key, and watermark all come from the statement and 
its query, not from the existing table, the result is fully defined by the DDL. 
The same statement always produces the same materialized table. Running it 
again after the conversion matches the now-existing materialized table and 
produces the same result.
+
+**Using the converted table as a regular table**
+
+The conversion is one-way and cannot be undone. You can still use the result 
like a regular table: suspend it to stop the refresh job, then query it.
+
+```sql
+-- Suspend the materialized table to stop the refresh job
+ALTER MATERIALIZED TABLE user_spending SUSPEND;
+
+-- Use the materialized table as a regular table
+SELECT * FROM user_spending;
+```
+
+
+<span class="label label-danger">Note</span>
+- The catalog must support in-place conversion; catalogs that do not implement 
it reject the statement.
+- Converting a view into a materialized table is not supported.
+- If the refresh job (continuous mode) or refresh workflow (full mode) cannot 
be started, the conversion is **not** rolled back: the table is left as a 
materialized table in `SUSPENDED` status. Fix the underlying issue and `RESUME` 
it.
+
 ## Examples
 
 Assuming `materialized-table.refresh-mode.freshness-threshold` is 30 minutes.
diff --git a/docs/layouts/shortcodes/generated/table_config_configuration.html 
b/docs/layouts/shortcodes/generated/table_config_configuration.html
index 9c9229a96eb..94b6ab63df6 100644
--- a/docs/layouts/shortcodes/generated/table_config_configuration.html
+++ b/docs/layouts/shortcodes/generated/table_config_configuration.html
@@ -68,6 +68,12 @@
             <td>String</td>
             <td>The local time zone defines current session time zone id. It 
is used when converting to/from &lt;code&gt;TIMESTAMP WITH LOCAL TIME 
ZONE&lt;/code&gt;. Internally, timestamps with local time zone are always 
represented in the UTC time zone. However, when converting to data types that 
don't include a time zone (e.g. TIMESTAMP, TIME, or simply STRING), the session 
time zone is used during conversion. The input of option is either a full name 
such as "America/Los_Angeles", or  [...]
         </tr>
+        <tr>
+            
<td><h5>table.materialized-table.conversion-from-table.enabled</h5><br> <span 
class="label label-primary">Batch</span> <span class="label 
label-primary">Streaming</span></td>
+            <td style="word-wrap: break-word;">false</td>
+            <td>Boolean</td>
+            <td>If enabled, executing a CREATE OR ALTER MATERIALIZED TABLE on 
an existing regular table converts it in place to a materialized table, 
preserving identity and storage. The option is read at planning time from the 
session's root configuration, so it must be set when the TableEnvironment 
session is initialized (for example in the cluster configuration file 
config.yaml, or in the configuration used to create the session); a 
session-level SET statement has no effect. When disa [...]
+        </tr>
         <tr>
             <td><h5>table.plan.compile.catalog-objects</h5><br> <span 
class="label label-primary">Batch</span> <span class="label 
label-primary">Streaming</span></td>
             <td style="word-wrap: break-word;">ALL</td>
diff --git a/flink-python/pyflink/table/tests/test_catalog_completeness.py 
b/flink-python/pyflink/table/tests/test_catalog_completeness.py
index 3e20aab37ca..5b14f867d3e 100644
--- a/flink-python/pyflink/table/tests/test_catalog_completeness.py
+++ b/flink-python/pyflink/table/tests/test_catalog_completeness.py
@@ -38,7 +38,7 @@ class 
CatalogAPICompletenessTests(PythonAPICompletenessTestCase, PyFlinkTestCase
 
     @classmethod
     def excluded_methods(cls):
-        # open/close are not needed in Python API as they are used internally
+        # open/close are not needed in Python API as they are used internally.
         return {
             'open',
             'close',
@@ -51,7 +51,8 @@ class 
CatalogAPICompletenessTests(PythonAPICompletenessTestCase, PyFlinkTestCase
             'connectionExists',
             'listConnections',
             'createConnection',
-            'alterConnection'}
+            'alterConnection',
+            'convertTableToMaterializedTable'}
 
 
 class CatalogDatabaseAPICompletenessTests(PythonAPICompletenessTestCase, 
PyFlinkTestCase):
diff --git 
a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/service/materializedtable/MaterializedTableManager.java
 
b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/service/materializedtable/MaterializedTableManager.java
index 6555bd57d19..02c646440b1 100644
--- 
a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/service/materializedtable/MaterializedTableManager.java
+++ 
b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/service/materializedtable/MaterializedTableManager.java
@@ -60,6 +60,7 @@ import 
org.apache.flink.table.operations.materializedtable.AlterMaterializedTabl
 import 
org.apache.flink.table.operations.materializedtable.AlterMaterializedTableRefreshOperation;
 import 
org.apache.flink.table.operations.materializedtable.AlterMaterializedTableResumeOperation;
 import 
org.apache.flink.table.operations.materializedtable.AlterMaterializedTableSuspendOperation;
+import 
org.apache.flink.table.operations.materializedtable.ConvertTableToMaterializedTableOperation;
 import 
org.apache.flink.table.operations.materializedtable.CreateMaterializedTableOperation;
 import 
org.apache.flink.table.operations.materializedtable.DropMaterializedTableOperation;
 import 
org.apache.flink.table.operations.materializedtable.MaterializedTableOperation;
@@ -186,6 +187,9 @@ public class MaterializedTableManager {
         } else if (op instanceof AlterMaterializedTableChangeOperation) {
             return callAlterMaterializedTableChangeOperation(
                     operationExecutor, handle, 
(AlterMaterializedTableChangeOperation) op);
+        } else if (op instanceof ConvertTableToMaterializedTableOperation) {
+            return callConvertTableToMaterializedTableOperation(
+                    operationExecutor, handle, 
(ConvertTableToMaterializedTableOperation) op);
         }
 
         throw new SqlExecutionException(
@@ -206,7 +210,7 @@ public class MaterializedTableManager {
             createMaterializedTableInFullMode(
                     operationExecutor, handle, 
createMaterializedTableOperation);
         }
-        // Just return ok for unify different refresh job info of continuous 
and full mode, user
+        // Just return ok to unify different refresh job info of continuous 
and full mode, user
         // should get the refresh job info via desc table.
         return ResultFetcher.fromTableResult(handle, TABLE_RESULT_OK, false);
     }
@@ -229,7 +233,7 @@ public class MaterializedTableManager {
                     handle,
                     catalogMaterializedTable,
                     materializedTableIdentifier,
-                    Collections.emptyMap(),
+                    Map.of(),
                     Optional.empty());
         } catch (Exception e) {
             // drop materialized table if submitting the Flink streaming job 
encounters an
@@ -261,37 +265,15 @@ public class MaterializedTableManager {
         ResolvedCatalogMaterializedTable catalogMaterializedTable =
                 createMaterializedTableOperation.getCatalogMaterializedTable();
 
-        final IntervalFreshness freshness = 
catalogMaterializedTable.getDefinitionFreshness();
-        String cronExpression = convertFreshnessToCron(freshness);
-        // create full refresh job
-        CreateRefreshWorkflow createRefreshWorkflow =
-                new CreatePeriodicRefreshWorkflow(
-                        materializedTableIdentifier,
-                        catalogMaterializedTable.getExpandedQuery(),
-                        cronExpression,
-                        getSessionInitializationConf(operationExecutor),
-                        Collections.emptyMap(),
-                        restEndpointUrl);
-
         try {
-            RefreshHandler refreshHandler =
-                    
workflowScheduler.createRefreshWorkflow(createRefreshWorkflow);
-            RefreshHandlerSerializer refreshHandlerSerializer =
-                    workflowScheduler.getRefreshHandlerSerializer();
-            byte[] serializedRefreshHandler = 
refreshHandlerSerializer.serialize(refreshHandler);
-
-            updateRefreshHandler(
+            createPeriodicRefreshWorkflow(
                     operationExecutor,
                     handle,
                     materializedTableIdentifier,
-                    catalogMaterializedTable,
-                    RefreshStatus.ACTIVATED,
-                    refreshHandler.asSummaryString(),
-                    serializedRefreshHandler);
+                    catalogMaterializedTable);
         } catch (Exception e) {
-            // drop materialized table if creating the refresh workflow 
encounters an exception.
-            // Thus, weak
-            // atomicity is guaranteed
+            // drop materialized table if creating the refresh workflow 
encounters an exception, so
+            // weak atomicity is guaranteed
             operationExecutor.callExecutableOperation(
                     handle, new 
DropMaterializedTableOperation(materializedTableIdentifier, true));
             throw new SqlExecutionException(
@@ -302,6 +284,145 @@ public class MaterializedTableManager {
         }
     }
 
+    private ResultFetcher callConvertTableToMaterializedTableOperation(
+            OperationExecutor operationExecutor,
+            OperationHandle handle,
+            ConvertTableToMaterializedTableOperation convertOperation) {
+        ResolvedCatalogMaterializedTable materializedTable =
+                convertOperation.getMaterializedTable();
+        if (RefreshMode.CONTINUOUS == materializedTable.getRefreshMode()) {
+            convertTableToMaterializedTableInContinuousMode(
+                    operationExecutor, handle, convertOperation);
+        } else {
+            convertTableToMaterializedTableInFullMode(operationExecutor, 
handle, convertOperation);
+        }
+        // Just return ok for unify different refresh job info of continuous 
and full mode, user
+        // should get the refresh job info via desc table.
+        return ResultFetcher.fromTableResult(handle, TABLE_RESULT_OK, false);
+    }
+
+    private void convertTableToMaterializedTableInContinuousMode(
+            OperationExecutor operationExecutor,
+            OperationHandle handle,
+            ConvertTableToMaterializedTableOperation convertOperation) {
+        // swap the catalog entry from a regular table to a materialized table 
first
+        operationExecutor.callExecutableOperation(handle, convertOperation);
+
+        ObjectIdentifier materializedTableIdentifier = 
convertOperation.getTableIdentifier();
+        ResolvedCatalogMaterializedTable catalogMaterializedTable =
+                convertOperation.getMaterializedTable();
+
+        try {
+            executeContinuousRefreshJob(
+                    operationExecutor,
+                    handle,
+                    catalogMaterializedTable,
+                    materializedTableIdentifier,
+                    Map.of(),
+                    Optional.empty());
+        } catch (Exception e) {
+            suspendMaterializedTable(
+                    operationExecutor,
+                    handle,
+                    materializedTableIdentifier,
+                    catalogMaterializedTable);
+            throw new SqlExecutionException(
+                    String.format(
+                            "Failed to start the continuous refresh job when 
converting table %s to a materialized table. "
+                                    + "The table was converted and left in 
SUSPENDED status; resume it once the issue is resolved.",
+                            materializedTableIdentifier),
+                    e);
+        }
+    }
+
+    private void convertTableToMaterializedTableInFullMode(
+            OperationExecutor operationExecutor,
+            OperationHandle handle,
+            ConvertTableToMaterializedTableOperation convertOperation) {
+        if (workflowScheduler == null) {
+            throw new SqlExecutionException(
+                    "The workflow scheduler must be configured when converting 
a table to a materialized table in full refresh mode.");
+        }
+        // swap the catalog entry from a regular table to a materialized table 
first
+        operationExecutor.callExecutableOperation(handle, convertOperation);
+
+        ObjectIdentifier materializedTableIdentifier = 
convertOperation.getTableIdentifier();
+        ResolvedCatalogMaterializedTable catalogMaterializedTable =
+                convertOperation.getMaterializedTable();
+
+        try {
+            createPeriodicRefreshWorkflow(
+                    operationExecutor,
+                    handle,
+                    materializedTableIdentifier,
+                    catalogMaterializedTable);
+        } catch (Exception e) {
+            suspendMaterializedTable(
+                    operationExecutor,
+                    handle,
+                    materializedTableIdentifier,
+                    catalogMaterializedTable);
+            throw new SqlExecutionException(
+                    String.format(
+                            "Failed to create the refresh workflow when 
converting table %s to a materialized table. "
+                                    + "The table was converted and left in 
SUSPENDED status; resume it once the issue is resolved.",
+                            materializedTableIdentifier),
+                    e);
+        }
+    }
+
+    /**
+     * Creates the periodic refresh workflow for a full-mode materialized 
table and records the
+     * resulting handler with {@code ACTIVATED} status.
+     */
+    private void createPeriodicRefreshWorkflow(
+            OperationExecutor operationExecutor,
+            OperationHandle handle,
+            ObjectIdentifier materializedTableIdentifier,
+            ResolvedCatalogMaterializedTable catalogMaterializedTable)
+            throws Exception {
+        final IntervalFreshness freshness = 
catalogMaterializedTable.getDefinitionFreshness();
+        final String cronExpression = convertFreshnessToCron(freshness);
+        final CreateRefreshWorkflow createRefreshWorkflow =
+                new CreatePeriodicRefreshWorkflow(
+                        materializedTableIdentifier,
+                        catalogMaterializedTable.getExpandedQuery(),
+                        cronExpression,
+                        getSessionInitializationConf(operationExecutor),
+                        Map.of(),
+                        restEndpointUrl);
+
+        final RefreshHandler refreshHandler =
+                workflowScheduler.createRefreshWorkflow(createRefreshWorkflow);
+        final RefreshHandlerSerializer refreshHandlerSerializer =
+                workflowScheduler.getRefreshHandlerSerializer();
+        final byte[] serializedRefreshHandler = 
refreshHandlerSerializer.serialize(refreshHandler);
+
+        updateRefreshHandler(
+                operationExecutor,
+                handle,
+                materializedTableIdentifier,
+                catalogMaterializedTable,
+                RefreshStatus.ACTIVATED,
+                refreshHandler.asSummaryString(),
+                serializedRefreshHandler);
+    }
+
+    /** Sets a materialized table refresh status to {@code SUSPENDED}. */
+    private void suspendMaterializedTable(
+            OperationExecutor operationExecutor,
+            OperationHandle handle,
+            ObjectIdentifier materializedTableIdentifier,
+            ResolvedCatalogMaterializedTable catalogMaterializedTable) {
+        AlterMaterializedTableChangeOperation alterOperation =
+                new AlterMaterializedTableChangeOperation(
+                        materializedTableIdentifier,
+                        oldTable ->
+                                
List.of(TableChange.modifyRefreshStatus(RefreshStatus.SUSPENDED)),
+                        catalogMaterializedTable);
+        operationExecutor.callExecutableOperation(handle, alterOperation);
+    }
+
     private ResultFetcher callAlterMaterializedTableSuspend(
             OperationExecutor operationExecutor,
             OperationHandle handle,
@@ -600,7 +721,7 @@ public class MaterializedTableManager {
                 handle,
                 materializedTableIdentifier,
                 partitionSpec,
-                Collections.emptyMap(),
+                Map.of(),
                 false,
                 null);
     }
@@ -844,7 +965,7 @@ public class MaterializedTableManager {
                         handle,
                         alterMaterializedTableChangeOperation.getNewTable(),
                         tableIdentifier,
-                        Collections.emptyMap(),
+                        Map.of(),
                         Optional.empty());
             } catch (Exception e) {
                 // Roll back the changes to the materialized table and restore 
the continuous
@@ -869,7 +990,7 @@ public class MaterializedTableManager {
                         handle,
                         suspendMaterializedTable,
                         tableIdentifier,
-                        Collections.emptyMap(),
+                        Map.of(),
                         continuousRefreshHandler.getRestorePath());
 
                 throw new SqlExecutionException(
diff --git 
a/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/service/MaterializedTableConversionITCase.java
 
b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/service/MaterializedTableConversionITCase.java
new file mode 100644
index 00000000000..870648e9e96
--- /dev/null
+++ 
b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/service/MaterializedTableConversionITCase.java
@@ -0,0 +1,343 @@
+/*
+ * 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.flink.table.gateway.service;
+
+import org.apache.flink.api.common.JobID;
+import org.apache.flink.client.program.rest.RestClusterClient;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
+import org.apache.flink.table.api.config.TableConfigOptions;
+import org.apache.flink.table.catalog.CatalogMaterializedTable.RefreshMode;
+import org.apache.flink.table.catalog.CatalogMaterializedTable.RefreshStatus;
+import org.apache.flink.table.catalog.ObjectIdentifier;
+import org.apache.flink.table.catalog.ResolvedCatalogMaterializedTable;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.gateway.api.operation.OperationHandle;
+import org.apache.flink.table.gateway.api.session.SessionEnvironment;
+import org.apache.flink.table.gateway.api.session.SessionHandle;
+import org.apache.flink.table.gateway.api.utils.MockedEndpointVersion;
+import 
org.apache.flink.table.gateway.rest.util.SqlGatewayRestEndpointExtension;
+import org.apache.flink.table.gateway.service.utils.IgnoreExceptionHandler;
+import org.apache.flink.table.gateway.service.utils.SqlGatewayServiceExtension;
+import org.apache.flink.table.refresh.ContinuousRefreshHandler;
+import org.apache.flink.test.junit5.InjectClusterClient;
+import org.apache.flink.test.junit5.MiniClusterExtension;
+import org.apache.flink.testutils.executor.TestExecutorExtension;
+import org.apache.flink.util.concurrent.ExecutorThreadFactory;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static 
org.apache.flink.table.catalog.CommonCatalogOptions.TABLE_CATALOG_STORE_KIND;
+import static 
org.apache.flink.table.factories.FactoryUtil.WORKFLOW_SCHEDULER_TYPE;
+import static 
org.apache.flink.table.gateway.service.MaterializedTableTestUtils.executeStatement;
+import static 
org.apache.flink.table.gateway.service.MaterializedTableTestUtils.getContinuousRefreshHandler;
+import static 
org.apache.flink.table.gateway.service.MaterializedTableTestUtils.getTable;
+import static 
org.apache.flink.table.gateway.service.utils.SqlGatewayServiceTestUtil.awaitOperationTermination;
+import static 
org.apache.flink.table.gateway.service.utils.SqlGatewayServiceTestUtil.fetchAllResults;
+import static org.apache.flink.test.util.TestUtils.waitUntilAllTasksAreRunning;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * ITCase for converting a regular table in place to a materialized table via 
CREATE OR ALTER
+ * MATERIALIZED TABLE.
+ *
+ * <p>This case runs against its own gateway with {@link
+ * TableConfigOptions#MATERIALIZED_TABLE_CONVERSION_FROM_TABLE_ENABLED} turned 
on, so that the
+ * default-disabled behavior stays covered by the other materialized table 
cases and the planner
+ * unit tests.
+ */
+class MaterializedTableConversionITCase {
+
+    private static final String FILE_CATALOG_STORE = "file_store";
+    private static final String TEST_CATALOG_PREFIX = "test_catalog";
+    private static final String TEST_DEFAULT_DATABASE = "test_db";
+
+    private static final AtomicLong COUNTER = new AtomicLong(0);
+
+    @RegisterExtension
+    @Order(1)
+    static final MiniClusterExtension MINI_CLUSTER =
+            new MiniClusterExtension(
+                    new MiniClusterResourceConfiguration.Builder()
+                            .setNumberTaskManagers(2)
+                            .build());
+
+    @RegisterExtension
+    @Order(2)
+    static final SqlGatewayServiceExtension SQL_GATEWAY_SERVICE_EXTENSION =
+            new SqlGatewayServiceExtension(
+                    
MaterializedTableConversionITCase::getGatewayConfiguration);
+
+    @RegisterExtension
+    @Order(3)
+    static final TestExecutorExtension<ExecutorService> EXECUTOR_EXTENSION =
+            new TestExecutorExtension<>(
+                    () ->
+                            Executors.newCachedThreadPool(
+                                    new ExecutorThreadFactory(
+                                            "SqlGatewayService Test Pool",
+                                            IgnoreExceptionHandler.INSTANCE)));
+
+    @RegisterExtension
+    @Order(4)
+    static final SqlGatewayRestEndpointExtension 
SQL_GATEWAY_REST_ENDPOINT_EXTENSION =
+            new 
SqlGatewayRestEndpointExtension(SQL_GATEWAY_SERVICE_EXTENSION::getService);
+
+    private static SqlGatewayServiceImpl service;
+    private static SessionEnvironment defaultSessionEnvironment;
+    private static Path baseCatalogPath;
+
+    private String fileSystemCatalogPath;
+    private String fileSystemCatalogName;
+
+    private SessionHandle sessionHandle;
+    private RestClusterClient<?> restClusterClient;
+
+    private static Configuration getGatewayConfiguration() {
+        final Configuration configuration =
+                new Configuration(MINI_CLUSTER.getClientConfiguration());
+        configuration.set(
+                
TableConfigOptions.MATERIALIZED_TABLE_CONVERSION_FROM_TABLE_ENABLED, true);
+        return configuration;
+    }
+
+    @BeforeAll
+    static void setUp(@TempDir Path temporaryFolder) throws Exception {
+        service = (SqlGatewayServiceImpl) 
SQL_GATEWAY_SERVICE_EXTENSION.getService();
+
+        Path fileCatalogStore = temporaryFolder.resolve(FILE_CATALOG_STORE);
+        Files.createDirectory(fileCatalogStore);
+        Map<String, String> catalogStoreOptions = new HashMap<>();
+        catalogStoreOptions.put(TABLE_CATALOG_STORE_KIND.key(), "file");
+        catalogStoreOptions.put("table.catalog-store.file.path", 
fileCatalogStore.toString());
+
+        baseCatalogPath = temporaryFolder.resolve(TEST_CATALOG_PREFIX);
+        Files.createDirectory(baseCatalogPath);
+
+        Map<String, String> workflowSchedulerConfig = new HashMap<>();
+        workflowSchedulerConfig.put(WORKFLOW_SCHEDULER_TYPE.key(), "embedded");
+        workflowSchedulerConfig.put(
+                "sql-gateway.endpoint.rest.address",
+                SQL_GATEWAY_REST_ENDPOINT_EXTENSION.getTargetAddress());
+        workflowSchedulerConfig.put(
+                "sql-gateway.endpoint.rest.port",
+                
String.valueOf(SQL_GATEWAY_REST_ENDPOINT_EXTENSION.getTargetPort()));
+
+        defaultSessionEnvironment =
+                SessionEnvironment.newBuilder()
+                        .addSessionConfig(catalogStoreOptions)
+                        .addSessionConfig(workflowSchedulerConfig)
+                        .setSessionEndpointVersion(MockedEndpointVersion.V1)
+                        .build();
+    }
+
+    @BeforeEach
+    void before(@InjectClusterClient RestClusterClient<?> injectClusterClient) 
throws Exception {
+        String randomStr = String.valueOf(COUNTER.incrementAndGet());
+        Path fileCatalogPath = baseCatalogPath.resolve(randomStr);
+        Files.createDirectory(fileCatalogPath);
+        Files.createDirectory(fileCatalogPath.resolve(TEST_DEFAULT_DATABASE));
+
+        fileSystemCatalogPath = fileCatalogPath.toString();
+        fileSystemCatalogName = TEST_CATALOG_PREFIX + randomStr;
+        sessionHandle = initializeSession();
+
+        restClusterClient = injectClusterClient;
+    }
+
+    @Test
+    void testConvertTableToMaterializedTableInContinuousMode() throws 
Exception {
+        // pre-create a regular table that will be converted in place to a 
materialized table
+        String createRegularTableDDL =
+                "CREATE TABLE users_shops (\n"
+                        + "  user_id BIGINT,\n"
+                        + "  shop_id BIGINT,\n"
+                        + "  ds STRING,\n"
+                        + "  payed_buy_fee_sum BIGINT,\n"
+                        + "  pv INT NOT NULL\n"
+                        + ")\n"
+                        + "WITH (\n"
+                        + "  'connector' = 'datagen',\n"
+                        + "  'rows-per-second' = '10'\n"
+                        + ")";
+        OperationHandle createTableHandle =
+                executeStatement(service, sessionHandle, 
createRegularTableDDL);
+        awaitOperationTermination(service, sessionHandle, createTableHandle);
+
+        String convertDDL =
+                "CREATE OR ALTER MATERIALIZED TABLE users_shops"
+                        + " PARTITIONED BY (ds)\n"
+                        + " WITH(\n"
+                        + "   'format' = 'debezium-json'\n"
+                        + " )\n"
+                        + " FRESHNESS = INTERVAL '30' SECOND\n"
+                        + " AS SELECT \n"
+                        + "  user_id,\n"
+                        + "  shop_id,\n"
+                        + "  ds,\n"
+                        + "  SUM (payment_amount_cents) AS 
payed_buy_fee_sum,\n"
+                        + "  SUM (1) AS pv\n"
+                        + " FROM (\n"
+                        + "    SELECT user_id, shop_id, 
DATE_FORMAT(order_created_at, 'yyyy-MM-dd') AS ds, payment_amount_cents FROM 
datagenSource"
+                        + " ) AS tmp\n"
+                        + " GROUP BY (user_id, shop_id, ds)";
+        OperationHandle convertHandle = executeStatement(service, 
sessionHandle, convertDDL);
+        awaitOperationTermination(service, sessionHandle, convertHandle);
+
+        // the entry is now a materialized table with an active continuous 
refresh job
+        ObjectIdentifier userShopsIdentifier = 
getObjectIdentifier("users_shops");
+        ResolvedCatalogMaterializedTable actualMaterializedTable =
+                getTable(service, sessionHandle, userShopsIdentifier);
+        
assertThat(actualMaterializedTable.getRefreshMode()).isSameAs(RefreshMode.CONTINUOUS);
+        
assertThat(actualMaterializedTable.getRefreshStatus()).isSameAs(RefreshStatus.ACTIVATED);
+        
assertThat(actualMaterializedTable.getSerializedRefreshHandler()).isNotEmpty();
+
+        ContinuousRefreshHandler activeRefreshHandler =
+                getContinuousRefreshHandler(actualMaterializedTable, 
getClass().getClassLoader());
+        waitUntilAllTasksAreRunning(
+                restClusterClient, 
JobID.fromHexString(activeRefreshHandler.getJobId()));
+
+        // verify the background refresh job is running
+        String describeJobDDL = String.format("DESCRIBE JOB '%s'", 
activeRefreshHandler.getJobId());
+        OperationHandle describeJobHandle =
+                executeStatement(service, sessionHandle, describeJobDDL);
+        awaitOperationTermination(service, sessionHandle, describeJobHandle);
+        List<RowData> jobResults = fetchAllResults(service, sessionHandle, 
describeJobHandle);
+        
assertThat(jobResults.get(0).getString(2).toString()).isEqualTo("RUNNING");
+
+        dropMaterializedTable(userShopsIdentifier);
+    }
+
+    @Test
+    void testConvertTableLeftSuspendedWhenRefreshJobFailsToStart() throws 
Exception {
+        String createRegularTableDDL =
+                "CREATE TABLE users_shops (\n"
+                        + "  user_id BIGINT,\n"
+                        + "  shop_id BIGINT,\n"
+                        + "  ds STRING,\n"
+                        + "  payed_buy_fee_sum BIGINT,\n"
+                        + "  pv INT NOT NULL\n"
+                        + ")\n"
+                        + "WITH (\n"
+                        + "  'connector' = 'datagen',\n"
+                        + "  'rows-per-second' = '10'\n"
+                        + ")";
+        OperationHandle createTableHandle =
+                executeStatement(service, sessionHandle, 
createRegularTableDDL);
+        awaitOperationTermination(service, sessionHandle, createTableHandle);
+
+        // 'json' cannot encode the updating changelog produced by the GROUP 
BY, so the continuous
+        // refresh job fails to start after the catalog entry has already been 
swapped to a
+        // materialized table
+        String convertDDL =
+                "CREATE OR ALTER MATERIALIZED TABLE users_shops"
+                        + " PARTITIONED BY (ds)\n"
+                        + " WITH(\n"
+                        + "   'format' = 'json'\n"
+                        + " )\n"
+                        + " FRESHNESS = INTERVAL '30' SECOND\n"
+                        + " AS SELECT \n"
+                        + "  user_id,\n"
+                        + "  shop_id,\n"
+                        + "  ds,\n"
+                        + "  SUM (payment_amount_cents) AS 
payed_buy_fee_sum,\n"
+                        + "  SUM (1) AS pv\n"
+                        + " FROM (\n"
+                        + "    SELECT user_id, shop_id, 
DATE_FORMAT(order_created_at, 'yyyy-MM-dd') AS ds, payment_amount_cents FROM 
datagenSource"
+                        + " ) AS tmp\n"
+                        + " GROUP BY (user_id, shop_id, ds)";
+        OperationHandle convertHandle = executeStatement(service, 
sessionHandle, convertDDL);
+
+        assertThatThrownBy(() -> awaitOperationTermination(service, 
sessionHandle, convertHandle))
+                .cause()
+                .hasMessageContaining(
+                        "Failed to start the continuous refresh job when 
converting table")
+                .hasMessageContaining("left in SUSPENDED status");
+
+        // the conversion is not rolled back: the table stays a materialized 
table but suspended
+        ObjectIdentifier userShopsIdentifier = 
getObjectIdentifier("users_shops");
+        ResolvedCatalogMaterializedTable suspendedMaterializedTable =
+                getTable(service, sessionHandle, userShopsIdentifier);
+        
assertThat(suspendedMaterializedTable.getRefreshMode()).isSameAs(RefreshMode.CONTINUOUS);
+        
assertThat(suspendedMaterializedTable.getRefreshStatus()).isSameAs(RefreshStatus.SUSPENDED);
+    }
+
+    private SessionHandle initializeSession() {
+        SessionHandle sessionHandle = 
service.openSession(defaultSessionEnvironment);
+        String catalogDDL =
+                String.format(
+                        "CREATE CATALOG %s\n"
+                                + "WITH (\n"
+                                + "  'type' = 'test-filesystem',\n"
+                                + "  'path' = '%s',\n"
+                                + "  'default-database' = '%s'\n"
+                                + "  )",
+                        fileSystemCatalogName, fileSystemCatalogPath, 
TEST_DEFAULT_DATABASE);
+        service.configureSession(sessionHandle, catalogDDL, -1);
+        service.configureSession(
+                sessionHandle, String.format("USE CATALOG %s", 
fileSystemCatalogName), -1);
+
+        String dataGenSource =
+                "CREATE TABLE datagenSource (\n"
+                        + "  order_id BIGINT,\n"
+                        + "  order_number VARCHAR(20),\n"
+                        + "  user_id BIGINT,\n"
+                        + "  shop_id BIGINT,\n"
+                        + "  product_id BIGINT,\n"
+                        + "  status BIGINT,\n"
+                        + "  order_type BIGINT,\n"
+                        + "  order_created_at TIMESTAMP,\n"
+                        + "  payment_amount_cents BIGINT\n"
+                        + ")\n"
+                        + "WITH (\n"
+                        + "  'connector' = 'datagen',\n"
+                        + "  'rows-per-second' = '10'\n"
+                        + ")";
+        service.configureSession(sessionHandle, dataGenSource, -1);
+        return sessionHandle;
+    }
+
+    private ObjectIdentifier getObjectIdentifier(String name) {
+        return ObjectIdentifier.of(fileSystemCatalogName, 
TEST_DEFAULT_DATABASE, name);
+    }
+
+    private void dropMaterializedTable(ObjectIdentifier objectIdentifier) 
throws Exception {
+        String dropMaterializedTableDDL =
+                String.format(
+                        "DROP MATERIALIZED TABLE %s", 
objectIdentifier.asSerializableString());
+        OperationHandle dropHandle =
+                executeStatement(service, sessionHandle, 
dropMaterializedTableDDL);
+        awaitOperationTermination(service, sessionHandle, dropHandle);
+    }
+}
diff --git 
a/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/service/MaterializedTableStatementITCase.java
 
b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/service/MaterializedTableStatementITCase.java
index c3a64b5c8fe..134015c565d 100644
--- 
a/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/service/MaterializedTableStatementITCase.java
+++ 
b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/service/MaterializedTableStatementITCase.java
@@ -1872,14 +1872,14 @@ class MaterializedTableStatementITCase extends 
AbstractMaterializedTableStatemen
     }
 
     private OperationHandle executeStatement(String statement) {
-        return executeStatement(statement, -1, new Configuration());
+        return MaterializedTableTestUtils.executeStatement(service, 
sessionHandle, statement);
     }
 
     private ContinuousRefreshHandler getContinuousRefreshHandler(
             ResolvedCatalogMaterializedTable resolvedTable)
             throws IOException, ClassNotFoundException {
-        return ContinuousRefreshHandlerSerializer.INSTANCE.deserialize(
-                resolvedTable.getSerializedRefreshHandler(), 
getClass().getClassLoader());
+        return MaterializedTableTestUtils.getContinuousRefreshHandler(
+                resolvedTable, getClass().getClassLoader());
     }
 
     private List<Column> getAddedColumns(ResolvedSchema newSchema, 
ResolvedSchema oldSchema) {
@@ -1934,7 +1934,7 @@ class MaterializedTableStatementITCase extends 
AbstractMaterializedTableStatemen
     }
 
     private ResolvedCatalogMaterializedTable getTable(ObjectIdentifier 
identifier) {
-        return (ResolvedCatalogMaterializedTable) 
service.getTable(sessionHandle, identifier);
+        return MaterializedTableTestUtils.getTable(service, sessionHandle, 
identifier);
     }
 
     private static <M extends JobMessageParameters, R extends RequestBody, P 
extends ResponseBody>
diff --git 
a/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/service/MaterializedTableTestUtils.java
 
b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/service/MaterializedTableTestUtils.java
new file mode 100644
index 00000000000..66af0bd6202
--- /dev/null
+++ 
b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/service/MaterializedTableTestUtils.java
@@ -0,0 +1,53 @@
+/*
+ * 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.flink.table.gateway.service;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.catalog.ObjectIdentifier;
+import org.apache.flink.table.catalog.ResolvedCatalogMaterializedTable;
+import org.apache.flink.table.gateway.api.SqlGatewayService;
+import org.apache.flink.table.gateway.api.operation.OperationHandle;
+import org.apache.flink.table.gateway.api.session.SessionHandle;
+import org.apache.flink.table.refresh.ContinuousRefreshHandler;
+import org.apache.flink.table.refresh.ContinuousRefreshHandlerSerializer;
+
+import java.io.IOException;
+
+/** Helpers shared between the materialized table gateway ITCases. */
+final class MaterializedTableTestUtils {
+
+    private MaterializedTableTestUtils() {}
+
+    static OperationHandle executeStatement(
+            SqlGatewayService service, SessionHandle sessionHandle, String 
statement) {
+        return service.executeStatement(sessionHandle, statement, -1, new 
Configuration());
+    }
+
+    static ResolvedCatalogMaterializedTable getTable(
+            SqlGatewayService service, SessionHandle sessionHandle, 
ObjectIdentifier identifier) {
+        return (ResolvedCatalogMaterializedTable) 
service.getTable(sessionHandle, identifier);
+    }
+
+    static ContinuousRefreshHandler getContinuousRefreshHandler(
+            ResolvedCatalogMaterializedTable resolvedTable, ClassLoader 
classLoader)
+            throws IOException, ClassNotFoundException {
+        return ContinuousRefreshHandlerSerializer.INSTANCE.deserialize(
+                resolvedTable.getSerializedRefreshHandler(), classLoader);
+    }
+}
diff --git 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/config/TableConfigOptions.java
 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/config/TableConfigOptions.java
index 3c59b56162f..c7260508280 100644
--- 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/config/TableConfigOptions.java
+++ 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/config/TableConfigOptions.java
@@ -145,6 +145,21 @@ public class TableConfigOptions {
                                     + "If set this option to true and the 
underlying DynamicTableSink implements the SupportsStaging interface, "
                                     + "the statement is expected to be 
executed atomically, the behavior of which depends on the actual 
DynamicTableSink.");
 
+    @Documentation.TableOption(execMode = 
Documentation.ExecMode.BATCH_STREAMING)
+    public static final ConfigOption<Boolean> 
MATERIALIZED_TABLE_CONVERSION_FROM_TABLE_ENABLED =
+            key("table.materialized-table.conversion-from-table.enabled")
+                    .booleanType()
+                    .defaultValue(false)
+                    .withDescription(
+                            "If enabled, executing a CREATE OR ALTER 
MATERIALIZED TABLE on an existing regular table "
+                                    + "converts it in place to a materialized 
table, preserving identity and storage. "
+                                    + "The option is read at planning time 
from the session's root configuration, so it "
+                                    + "must be set when the TableEnvironment 
session is initialized (for example in the "
+                                    + "cluster configuration file config.yaml, 
or in the configuration used to create the "
+                                    + "session); a session-level SET statement 
has no effect. "
+                                    + "When disabled (the default), CREATE OR 
ALTER MATERIALIZED TABLE against a regular "
+                                    + "table is rejected.");
+
     @Documentation.TableOption(execMode = 
Documentation.ExecMode.BATCH_STREAMING)
     public static final ConfigOption<List<ColumnExpansionStrategy>>
             TABLE_COLUMN_EXPANSION_STRATEGY =
diff --git 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/catalog/CatalogManager.java
 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/catalog/CatalogManager.java
index cd375bc9554..ca31e17ebf4 100644
--- 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/catalog/CatalogManager.java
+++ 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/catalog/CatalogManager.java
@@ -1404,6 +1404,44 @@ public final class CatalogManager implements 
CatalogRegistry, AutoCloseable {
                 "AlterTable");
     }
 
+    /**
+     * Converts an existing regular table to a materialized table in place. 
Identity and storage are
+     * preserved; only the kind and the materialized-table specific metadata 
change.
+     *
+     * @param originalTable the existing regular table
+     * @param materializedTable the new materialized table definition
+     * @param changes describe the modification from originalTable to 
materializedTable
+     * @param objectIdentifier fully qualified path of the table being 
converted
+     */
+    public void convertTableToMaterializedTable(
+            CatalogTable originalTable,
+            CatalogMaterializedTable materializedTable,
+            List<TableChange> changes,
+            ObjectIdentifier objectIdentifier) {
+        execute(
+                (catalog, path) -> {
+                    final CatalogTable resolvedOriginal =
+                            (CatalogTable) 
resolveCatalogBaseTable(originalTable);
+                    final CatalogMaterializedTable resolvedMt =
+                            (CatalogMaterializedTable) 
resolveCatalogBaseTable(materializedTable);
+                    catalog.convertTableToMaterializedTable(
+                            path, resolvedOriginal, resolvedMt, changes);
+                    catalogModificationListeners.forEach(
+                            listener ->
+                                    listener.onEvent(
+                                            AlterTableEvent.createEvent(
+                                                    
CatalogContext.createContext(
+                                                            
objectIdentifier.getCatalogName(),
+                                                            catalog),
+                                                    objectIdentifier,
+                                                    resolvedMt,
+                                                    false)));
+                },
+                objectIdentifier,
+                false,
+                "ConvertTableToMaterializedTable");
+    }
+
     /**
      * Drops a table in a given fully qualified path.
      *
diff --git 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/catalog/GenericInMemoryCatalog.java
 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/catalog/GenericInMemoryCatalog.java
index bc678672d91..c353dbe2965 100644
--- 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/catalog/GenericInMemoryCatalog.java
+++ 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/catalog/GenericInMemoryCatalog.java
@@ -264,6 +264,23 @@ public class GenericInMemoryCatalog extends 
AbstractCatalog {
         }
     }
 
+    @Override
+    public void convertTableToMaterializedTable(
+            ObjectPath tablePath,
+            CatalogTable originalTable,
+            CatalogMaterializedTable materializedTable,
+            List<TableChange> tableChanges)
+            throws TableNotExistException {
+        checkNotNull(tablePath);
+        checkNotNull(materializedTable);
+
+        final CatalogBaseTable existing = tables.get(tablePath);
+        if (existing == null) {
+            throw new TableNotExistException(getName(), tablePath);
+        }
+        tables.put(tablePath, materializedTable.copy());
+    }
+
     // ------ tables and views ------
 
     @Override
diff --git 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/materializedtable/AlterMaterializedTableChangeOperation.java
 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/materializedtable/AlterMaterializedTableChangeOperation.java
index 823cacb4327..684518e99b0 100644
--- 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/materializedtable/AlterMaterializedTableChangeOperation.java
+++ 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/materializedtable/AlterMaterializedTableChangeOperation.java
@@ -232,7 +232,7 @@ public class AlterMaterializedTableChangeOperation extends 
AlterMaterializedTabl
                 position + 1, oldColumn, newColumn);
     }
 
-    private static String toString(TableChange tableChange) {
+    static String toString(TableChange tableChange) {
         if (tableChange instanceof ModifyRefreshStatus) {
             ModifyRefreshStatus refreshStatus = (ModifyRefreshStatus) 
tableChange;
             return String.format(
diff --git 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/materializedtable/ConvertTableToMaterializedTableOperation.java
 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/materializedtable/ConvertTableToMaterializedTableOperation.java
new file mode 100644
index 00000000000..8a83ea0f8e8
--- /dev/null
+++ 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/materializedtable/ConvertTableToMaterializedTableOperation.java
@@ -0,0 +1,107 @@
+/*
+ * 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.flink.table.operations.materializedtable;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.table.api.internal.TableResultImpl;
+import org.apache.flink.table.api.internal.TableResultInternal;
+import org.apache.flink.table.catalog.ObjectIdentifier;
+import org.apache.flink.table.catalog.ResolvedCatalogMaterializedTable;
+import org.apache.flink.table.catalog.ResolvedCatalogTable;
+import org.apache.flink.table.catalog.TableChange;
+import org.apache.flink.table.operations.ExecutableOperation;
+import org.apache.flink.table.operations.Operation;
+import org.apache.flink.table.operations.OperationUtils;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * Operation to describe an in-place conversion of an existing regular table 
to a materialized table
+ * via CREATE OR ALTER MATERIALIZED TABLE.
+ */
+@Internal
+public class ConvertTableToMaterializedTableOperation
+        implements MaterializedTableOperation, ExecutableOperation {
+
+    private final ObjectIdentifier tableIdentifier;
+    private final ResolvedCatalogTable originalTable;
+    private final ResolvedCatalogMaterializedTable materializedTable;
+    private final Function<ResolvedCatalogMaterializedTable, List<TableChange>>
+            tableChangesForTable;
+    private List<TableChange> tableChanges;
+
+    public ConvertTableToMaterializedTableOperation(
+            ObjectIdentifier tableIdentifier,
+            ResolvedCatalogTable originalTable,
+            ResolvedCatalogMaterializedTable materializedTable,
+            Function<ResolvedCatalogMaterializedTable, List<TableChange>> 
tableChangesBuilder) {
+        this.tableIdentifier = tableIdentifier;
+        this.originalTable = originalTable;
+        this.materializedTable = materializedTable;
+        this.tableChangesForTable = tableChangesBuilder;
+    }
+
+    @Override
+    public TableResultInternal execute(Context ctx) {
+        ctx.getCatalogManager()
+                .convertTableToMaterializedTable(
+                        originalTable, materializedTable, getTableChanges(), 
tableIdentifier);
+        return TableResultImpl.TABLE_RESULT_OK;
+    }
+
+    public ObjectIdentifier getTableIdentifier() {
+        return tableIdentifier;
+    }
+
+    public ResolvedCatalogTable getOriginalTable() {
+        return originalTable;
+    }
+
+    public ResolvedCatalogMaterializedTable getMaterializedTable() {
+        return materializedTable;
+    }
+
+    public List<TableChange> getTableChanges() {
+        if (tableChanges == null) {
+            tableChanges = tableChangesForTable.apply(materializedTable);
+        }
+        return tableChanges;
+    }
+
+    @Override
+    public String asSummaryString() {
+        final Map<String, Object> params = new LinkedHashMap<>();
+        params.put("identifier", tableIdentifier);
+        params.put("materializedTable", materializedTable);
+        params.put(
+                "tableChanges",
+                getTableChanges().stream()
+                        .map(AlterMaterializedTableChangeOperation::toString)
+                        .collect(Collectors.joining(",\n")));
+        return OperationUtils.formatWithChildren(
+                "CREATE OR ALTER MATERIALIZED TABLE",
+                params,
+                List.of(),
+                Operation::asSummaryString);
+    }
+}
diff --git 
a/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/catalog/GenericInMemoryCatalogTest.java
 
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/catalog/GenericInMemoryCatalogTest.java
index 403d94459fb..b7a57c7a664 100644
--- 
a/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/catalog/GenericInMemoryCatalogTest.java
+++ 
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/catalog/GenericInMemoryCatalogTest.java
@@ -18,7 +18,11 @@
 
 package org.apache.flink.table.catalog;
 
+import 
org.apache.flink.table.catalog.CatalogMaterializedTable.LogicalRefreshMode;
+import org.apache.flink.table.catalog.CatalogMaterializedTable.RefreshMode;
+import org.apache.flink.table.catalog.CatalogMaterializedTable.RefreshStatus;
 import org.apache.flink.table.catalog.exceptions.CatalogException;
+import org.apache.flink.table.catalog.exceptions.TableNotExistException;
 import org.apache.flink.table.catalog.stats.CatalogColumnStatistics;
 import org.apache.flink.table.catalog.stats.CatalogColumnStatisticsDataBase;
 import org.apache.flink.table.catalog.stats.CatalogColumnStatisticsDataBinary;
@@ -34,6 +38,7 @@ import org.apache.flink.table.functions.TestSimpleUDF;
 import org.apache.flink.table.utils.TableEnvironmentMock;
 
 import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 
 import java.util.Arrays;
@@ -42,6 +47,7 @@ import java.util.List;
 import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Test for GenericInMemoryCatalog. */
 class GenericInMemoryCatalogTest extends CatalogTestBase {
@@ -54,6 +60,51 @@ class GenericInMemoryCatalogTest extends CatalogTestBase {
 
     // ------ tables ------
 
+    @Nested
+    class ConvertTableToMaterializedTableTest {
+        @Test
+        void testConvertTableToMaterializedTable() throws Exception {
+            catalog.createDatabase(db1, createDb(), false);
+            catalog.createTable(path1, createTable(), false);
+            assertThat(catalog.getTable(path1).getTableKind())
+                    .isEqualTo(CatalogBaseTable.TableKind.TABLE);
+
+            catalog.convertTableToMaterializedTable(
+                    path1, createTable(), createMaterializedTable(), 
List.of());
+
+            final CatalogBaseTable converted = catalog.getTable(path1);
+            assertThat(converted).isInstanceOf(CatalogMaterializedTable.class);
+            assertThat(converted.getTableKind())
+                    .isEqualTo(CatalogBaseTable.TableKind.MATERIALIZED_TABLE);
+        }
+
+        @Test
+        void testConvertTableToMaterializedTable_tableNotExist() throws 
Exception {
+            catalog.createDatabase(db1, createDb(), false);
+            assertThatThrownBy(
+                            () ->
+                                    catalog.convertTableToMaterializedTable(
+                                            path1,
+                                            createTable(),
+                                            createMaterializedTable(),
+                                            List.of()))
+                    .isInstanceOf(TableNotExistException.class);
+        }
+
+        private CatalogMaterializedTable createMaterializedTable() {
+            return CatalogMaterializedTable.newBuilder()
+                    .schema(createTable().getUnresolvedSchema())
+                    .options(Map.of())
+                    .originalQuery("SELECT * FROM src")
+                    .expandedQuery("SELECT * FROM `builtin`.`default`.`src`")
+                    .freshness(IntervalFreshness.ofSecond(30))
+                    .logicalRefreshMode(LogicalRefreshMode.AUTOMATIC)
+                    .refreshMode(RefreshMode.CONTINUOUS)
+                    .refreshStatus(RefreshStatus.INITIALIZING)
+                    .build();
+        }
+    }
+
     @Test
     void testDropTable_partitionedTable() throws Exception {
         catalog.createDatabase(db1, createDb(), false);
diff --git 
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/catalog/Catalog.java
 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/catalog/Catalog.java
index 3db7d7cf2ee..2f3b478a62a 100644
--- 
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/catalog/Catalog.java
+++ 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/catalog/Catalog.java
@@ -383,6 +383,31 @@ public interface Catalog {
         alterTable(tablePath, newTable, ignoreIfNotExists);
     }
 
+    /**
+     * Converts an existing {@link CatalogTable} to a {@link 
CatalogMaterializedTable} in place,
+     * preserving the catalog entry's identity and storage.
+     *
+     * <p>The default throws {@link UnsupportedOperationException}; catalogs 
that support in-place
+     * conversion override it. Launching the refresh job for the new 
materialized table is the
+     * executor's responsibility, not the catalog's.
+     *
+     * @param tableChanges structured delta between {@code originalTable} and 
{@code
+     *     materializedTable}, useful for incremental storage migration.
+     * @throws TableNotExistException if the table does not exist
+     * @throws CatalogException in case of any runtime exception
+     */
+    default void convertTableToMaterializedTable(
+            ObjectPath tablePath,
+            CatalogTable originalTable,
+            CatalogMaterializedTable materializedTable,
+            List<TableChange> tableChanges)
+            throws TableNotExistException, CatalogException {
+        throw new CatalogException(
+                "Catalog "
+                        + getClass().getName()
+                        + " does not support converting tables to materialized 
tables.");
+    }
+
     // ------ partitions ------
 
     /**
diff --git 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/operations/converters/materializedtable/SqlCreateOrAlterMaterializedTableConverter.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/operations/converters/materializedtable/SqlCreateOrAlterMaterializedTableConverter.java
index c1e35838ead..eff024de3d1 100644
--- 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/operations/converters/materializedtable/SqlCreateOrAlterMaterializedTableConverter.java
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/operations/converters/materializedtable/SqlCreateOrAlterMaterializedTableConverter.java
@@ -22,14 +22,17 @@ import 
org.apache.flink.sql.parser.ddl.SqlTableColumn.SqlRegularColumn;
 import 
org.apache.flink.sql.parser.ddl.materializedtable.SqlCreateOrAlterMaterializedTable;
 import org.apache.flink.table.api.Schema;
 import org.apache.flink.table.api.ValidationException;
+import org.apache.flink.table.api.config.TableConfigOptions;
 import org.apache.flink.table.catalog.CatalogBaseTable.TableKind;
 import org.apache.flink.table.catalog.CatalogMaterializedTable;
 import 
org.apache.flink.table.catalog.CatalogMaterializedTable.LogicalRefreshMode;
 import org.apache.flink.table.catalog.CatalogMaterializedTable.RefreshMode;
+import org.apache.flink.table.catalog.CatalogMaterializedTable.RefreshStatus;
 import org.apache.flink.table.catalog.IntervalFreshness;
 import org.apache.flink.table.catalog.ObjectIdentifier;
 import org.apache.flink.table.catalog.ResolvedCatalogBaseTable;
 import org.apache.flink.table.catalog.ResolvedCatalogMaterializedTable;
+import org.apache.flink.table.catalog.ResolvedCatalogTable;
 import org.apache.flink.table.catalog.ResolvedSchema;
 import org.apache.flink.table.catalog.SchemaResolver;
 import org.apache.flink.table.catalog.StartMode;
@@ -38,6 +41,7 @@ import org.apache.flink.table.catalog.TableDistribution;
 import org.apache.flink.table.catalog.UniqueConstraint;
 import org.apache.flink.table.catalog.WatermarkSpec;
 import org.apache.flink.table.operations.Operation;
+import 
org.apache.flink.table.operations.materializedtable.ConvertTableToMaterializedTableOperation;
 import 
org.apache.flink.table.operations.materializedtable.CreateMaterializedTableOperation;
 import 
org.apache.flink.table.operations.materializedtable.FullAlterMaterializedTableOperation;
 import 
org.apache.flink.table.operations.materializedtable.MaterializedTableChangeHandler;
@@ -78,23 +82,30 @@ public class SqlCreateOrAlterMaterializedTableConverter
             final ObjectIdentifier identifier) {
         final Optional<ResolvedCatalogBaseTable<?>> resolvedBaseTable =
                 context.getCatalogManager().getCatalogBaseTable(identifier);
-        return resolvedBaseTable
-                .map(
-                        oldBaseTable -> {
-                            if (oldBaseTable.getTableKind() != 
TableKind.MATERIALIZED_TABLE) {
-                                throw new ValidationException(
-                                        String.format(
-                                                "Table %s is not a 
materialized table. Only materialized table support create or alter operation.",
-                                                identifier.asSummaryString()));
-                            }
-                            return handleAlter(
-                                    sqlCreateOrAlterMaterializedTable,
-                                    (ResolvedCatalogMaterializedTable) 
oldBaseTable,
-                                    context,
-                                    identifier);
-                        })
-                .orElseGet(
-                        () -> handleCreate(sqlCreateOrAlterMaterializedTable, 
context, identifier));
+        if (resolvedBaseTable.isEmpty()) {
+            return handleCreate(sqlCreateOrAlterMaterializedTable, context, 
identifier);
+        }
+        final ResolvedCatalogBaseTable<?> oldBaseTable = 
resolvedBaseTable.get();
+        final TableKind oldBaseTableKind = oldBaseTable.getTableKind();
+        switch (oldBaseTableKind) {
+            case MATERIALIZED_TABLE:
+                return handleAlter(
+                        sqlCreateOrAlterMaterializedTable,
+                        (ResolvedCatalogMaterializedTable) oldBaseTable,
+                        context,
+                        identifier);
+            case TABLE:
+                return handleConvert(
+                        sqlCreateOrAlterMaterializedTable,
+                        context,
+                        identifier,
+                        (ResolvedCatalogTable) oldBaseTable);
+            default:
+                throw new ValidationException(
+                        String.format(
+                                "Catalog object %s of kind %s does not support 
the CREATE OR ALTER MATERIALIZED TABLE operation.",
+                                identifier.asSummaryString(), 
oldBaseTableKind));
+        }
     }
 
     private static boolean createOrAlterOperation(
@@ -117,6 +128,82 @@ public class SqlCreateOrAlterMaterializedTableConverter
                 currentTable -> buildNewTable(currentTable, mergeContext, 
schemaResolver));
     }
 
+    private Operation handleConvert(
+            final SqlCreateOrAlterMaterializedTable 
sqlCreateOrAlterMaterializedTable,
+            final ConvertContext context,
+            final ObjectIdentifier identifier,
+            final ResolvedCatalogTable oldBaseTable) {
+        final boolean conversionEnabled =
+                context.getTableConfig()
+                        .getRootConfiguration()
+                        
.get(TableConfigOptions.MATERIALIZED_TABLE_CONVERSION_FROM_TABLE_ENABLED);
+        if (!conversionEnabled) {
+            throw new ValidationException(
+                    "Regular table does not support create or alter 
operation.");
+        }
+        final MergeContext baseMergeContext =
+                getMergeContext(sqlCreateOrAlterMaterializedTable, context);
+
+        final CatalogMaterializedTable newMaterializedTable =
+                CatalogMaterializedTable.newBuilder()
+                        .schema(baseMergeContext.getMergedSchema())
+                        .comment(baseMergeContext.getMergedComment())
+                        
.partitionKeys(baseMergeContext.getMergedPartitionKeys())
+                        .options(baseMergeContext.getMergedTableOptions())
+                        
.originalQuery(baseMergeContext.getMergedOriginalQuery())
+                        
.expandedQuery(baseMergeContext.getMergedExpandedQuery())
+                        
.distribution(baseMergeContext.getMergedTableDistribution().orElse(null))
+                        .freshness(baseMergeContext.getMergedFreshness())
+                        
.logicalRefreshMode(baseMergeContext.getMergedLogicalRefreshMode())
+                        .refreshMode(baseMergeContext.getMergedRefreshMode())
+                        .refreshStatus(RefreshStatus.INITIALIZING)
+                        .startMode(baseMergeContext.getMergedStartMode())
+                        .build();
+        final ResolvedCatalogMaterializedTable resolvedNewMaterializedTable =
+                
context.getCatalogManager().resolveCatalogMaterializedTable(newMaterializedTable);
+
+        return new ConvertTableToMaterializedTableOperation(
+                identifier,
+                oldBaseTable,
+                resolvedNewMaterializedTable,
+                resolvedCatalogMaterializedTable ->
+                        buildConversionTableChanges(
+                                oldBaseTable,
+                                resolvedCatalogMaterializedTable,
+                                baseMergeContext.hasSchemaDefinition(),
+                                baseMergeContext.hasConstraintDefinition()));
+    }
+
+    private List<TableChange> buildConversionTableChanges(
+            final ResolvedCatalogTable oldTable,
+            final ResolvedCatalogMaterializedTable newTable,
+            final boolean hasSchemaDefinition,
+            final boolean hasConstraintDefinition) {
+        final ResolvedSchema oldSchema = oldTable.getResolvedSchema();
+        final ResolvedSchema newSchema = newTable.getResolvedSchema();
+        final List<TableChange> changes =
+                new ArrayList<>(
+                        MaterializedTableUtils.validateAndExtractColumnChanges(
+                                oldSchema, newSchema, hasSchemaDefinition));
+
+        getConstraintChange(oldSchema, newSchema, 
hasConstraintDefinition).ifPresent(changes::add);
+        getWatermarkChange(oldSchema, newSchema, 
hasSchemaDefinition).ifPresent(changes::add);
+
+        changes.addAll(
+                getQueryTableChanges(
+                        null, null, newTable.getOriginalQuery(), 
newTable.getExpandedQuery()));
+        changes.addAll(getOptionsTableChanges(oldTable.getOptions(), 
newTable.getOptions()));
+        changes.addAll(
+                getDistributionTableChanges(
+                        oldTable.getDistribution().orElse(null),
+                        newTable.getDistribution().orElse(null)));
+
+        newTable.getStartMode()
+                .ifPresent(newStartMode -> 
changes.add(TableChange.modifyStartMode(newStartMode)));
+
+        return changes;
+    }
+
     private CatalogMaterializedTable buildNewTable(
             final ResolvedCatalogMaterializedTable currentTable,
             final MergeContext mergeContext,
@@ -161,9 +248,19 @@ public class SqlCreateOrAlterMaterializedTableConverter
         final List<TableChange> changes =
                 getSchemaTableChanges(mergeContext, schemaResolver, oldTable);
 
-        changes.addAll(getQueryTableChanges(mergeContext, oldTable));
-        changes.addAll(getOptionsTableChanges(mergeContext, oldTable));
-        changes.addAll(getDistributionTableChanges(mergeContext, oldTable));
+        changes.addAll(
+                getQueryTableChanges(
+                        oldTable.getOriginalQuery(),
+                        oldTable.getExpandedQuery(),
+                        mergeContext.getMergedOriginalQuery(),
+                        mergeContext.getMergedExpandedQuery()));
+        changes.addAll(
+                getOptionsTableChanges(
+                        oldTable.getOptions(), 
mergeContext.getMergedTableOptions()));
+        changes.addAll(
+                getDistributionTableChanges(
+                        oldTable.getDistribution().orElse(null),
+                        
mergeContext.getMergedTableDistribution().orElse(null)));
 
         final RefreshMode oldRefreshMode = oldTable.getRefreshMode();
         final RefreshMode newRefreshMode = mergeContext.getMergedRefreshMode();
@@ -188,10 +285,7 @@ public class SqlCreateOrAlterMaterializedTableConverter
     }
 
     private List<TableChange> getDistributionTableChanges(
-            final MergeContext mergeContext, final 
ResolvedCatalogMaterializedTable oldTable) {
-        final TableDistribution oldDistribution = 
oldTable.getDistribution().orElse(null);
-        final TableDistribution newDistribution =
-                mergeContext.getMergedTableDistribution().orElse(null);
+            final TableDistribution oldDistribution, final TableDistribution 
newDistribution) {
         if (!Objects.equals(oldDistribution, newDistribution)) {
             if (oldDistribution == null) {
                 return List.of(TableChange.add(newDistribution));
@@ -205,10 +299,8 @@ public class SqlCreateOrAlterMaterializedTableConverter
     }
 
     private List<TableChange> getOptionsTableChanges(
-            final MergeContext mergeContext, final 
ResolvedCatalogMaterializedTable oldTable) {
+            final Map<String, String> oldOptions, final Map<String, String> 
newOptions) {
         final List<TableChange> changes = new ArrayList<>();
-        final Map<String, String> oldOptions = oldTable.getOptions();
-        final Map<String, String> newOptions = 
mergeContext.getMergedTableOptions();
 
         for (Map.Entry<String, String> newOptionEntry : newOptions.entrySet()) 
{
             if 
(!newOptionEntry.getValue().equals(oldOptions.get(newOptionEntry.getKey()))) {
@@ -225,15 +317,13 @@ public class SqlCreateOrAlterMaterializedTableConverter
     }
 
     private List<TableChange> getQueryTableChanges(
-            final MergeContext mergeContext, final 
ResolvedCatalogMaterializedTable oldTable) {
-        final String originalQuery = oldTable.getOriginalQuery();
-        final String expandedQuery = oldTable.getExpandedQuery();
-        if (!originalQuery.equals(mergeContext.getMergedOriginalQuery())
-                || 
!expandedQuery.equals(mergeContext.getMergedExpandedQuery())) {
-            return List.of(
-                    TableChange.modifyDefinitionQuery(
-                            mergeContext.getMergedOriginalQuery(),
-                            mergeContext.getMergedExpandedQuery()));
+            final String oldOriginalQuery,
+            final String oldExpandedQuery,
+            final String newOriginalQuery,
+            final String newExpandedQuery) {
+        if (!Objects.equals(oldOriginalQuery, newOriginalQuery)
+                || !Objects.equals(oldExpandedQuery, newExpandedQuery)) {
+            return List.of(TableChange.modifyDefinitionQuery(newOriginalQuery, 
newExpandedQuery));
         }
         return List.of();
     }
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/operations/SqlNodeToOperationConversionTestBase.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/operations/SqlNodeToOperationConversionTestBase.java
index 0814e1556a5..a63aa416144 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/operations/SqlNodeToOperationConversionTestBase.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/operations/SqlNodeToOperationConversionTestBase.java
@@ -61,7 +61,7 @@ import static 
org.apache.calcite.jdbc.CalciteSchemaBuilder.asRootSchema;
 /** Test base for testing convert sql statement to operation. */
 class SqlNodeToOperationConversionTestBase {
     private final boolean isStreamingMode = false;
-    private final TableConfig tableConfig = TableConfig.getDefault();
+    protected final TableConfig tableConfig = TableConfig.getDefault();
     protected final Catalog catalog = new 
GenericInMemoryCatalog("MockCatalog", "default");
     protected final CatalogManager catalogManager =
             CatalogManagerMocks.preparedCatalogManager()
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/operations/SqlNodeToOperationConvertTableToMaterializedTableTest.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/operations/SqlNodeToOperationConvertTableToMaterializedTableTest.java
new file mode 100644
index 00000000000..3fcaef77618
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/operations/SqlNodeToOperationConvertTableToMaterializedTableTest.java
@@ -0,0 +1,663 @@
+/*
+ * 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.flink.table.planner.operations;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.Schema;
+import org.apache.flink.table.api.ValidationException;
+import org.apache.flink.table.api.config.TableConfigOptions;
+import org.apache.flink.table.catalog.CatalogMaterializedTable;
+import org.apache.flink.table.catalog.CatalogTable;
+import org.apache.flink.table.catalog.CatalogView;
+import org.apache.flink.table.catalog.Column;
+import org.apache.flink.table.catalog.ObjectPath;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import org.apache.flink.table.catalog.TableChange;
+import org.apache.flink.table.catalog.exceptions.DatabaseNotExistException;
+import org.apache.flink.table.catalog.exceptions.TableAlreadyExistException;
+import org.apache.flink.table.expressions.SqlCallExpression;
+import org.apache.flink.table.operations.Operation;
+import 
org.apache.flink.table.operations.materializedtable.ConvertTableToMaterializedTableOperation;
+import 
org.apache.flink.table.operations.materializedtable.CreateMaterializedTableOperation;
+import 
org.apache.flink.table.operations.materializedtable.FullAlterMaterializedTableOperation;
+import org.apache.flink.table.types.logical.LogicalTypeRoot;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for in-place conversion of a regular table to a materialized table 
via {@code CREATE OR
+ * ALTER MATERIALIZED TABLE}.
+ */
+class SqlNodeToOperationConvertTableToMaterializedTableTest
+        extends SqlNodeToOperationConversionTestBase {
+
+    private static final String SOURCE_REGULAR_TABLE_NAME = "src_table";
+
+    @BeforeEach
+    void before() throws TableAlreadyExistException, DatabaseNotExistException 
{
+        super.before();
+        sourceTable(SOURCE_REGULAR_TABLE_NAME).create();
+        sourceTable("t1_with_ts").create();
+    }
+
+    @Nested
+    class OperationSelection {
+        private static final String EXISTING_MT_NAME = "existing_mt";
+
+        @Test
+        void missingTargetCreatesMaterializedTable() {
+            final String sql =
+                    "CREATE OR ALTER MATERIALIZED TABLE brand_new" + " AS 
SELECT a, b FROM t1";
+            
assertThat(parse(sql)).isInstanceOf(CreateMaterializedTableOperation.class);
+        }
+
+        @Test
+        void existingMaterializedTableAlters()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            configureConversionEnabled(true);
+            createExistingMaterializedTable();
+            final String sql =
+                    "CREATE OR ALTER MATERIALIZED TABLE "
+                            + EXISTING_MT_NAME
+                            + " AS SELECT a, b FROM t1";
+            
assertThat(parse(sql)).isInstanceOf(FullAlterMaterializedTableOperation.class);
+        }
+
+        @Test
+        void regularTableWithConversionDisabledIsRejected() {
+            configureConversionEnabled(false);
+            final String sql =
+                    "CREATE OR ALTER MATERIALIZED TABLE "
+                            + SOURCE_REGULAR_TABLE_NAME
+                            + " AS SELECT a, b FROM t1";
+            assertThatThrownBy(() -> parse(sql))
+                    .isInstanceOf(ValidationException.class)
+                    .hasMessageContaining(
+                            "Regular table does not support create or alter 
operation.");
+        }
+
+        @Test
+        void regularTableWithConversionEnabledIsConverted() {
+            configureConversionEnabled(true);
+            final String sql =
+                    "CREATE OR ALTER MATERIALIZED TABLE "
+                            + SOURCE_REGULAR_TABLE_NAME
+                            + " AS SELECT a, b FROM t1";
+            
assertThat(parse(sql)).isInstanceOf(ConvertTableToMaterializedTableOperation.class);
+        }
+
+        @Test
+        void viewIsRejected() throws TableAlreadyExistException, 
DatabaseNotExistException {
+            // A view is rejected regardless of the conversion flag: only 
tables convert.
+            configureConversionEnabled(true);
+            final CatalogView view =
+                    CatalogView.of(
+                            Schema.newBuilder()
+                                    .column("a", DataTypes.BIGINT())
+                                    .column("b", DataTypes.STRING())
+                                    .build(),
+                            null,
+                            "SELECT a, b FROM t1",
+                            "SELECT a, b FROM t1",
+                            Map.of());
+            catalog.createTable(
+                    new ObjectPath(catalogManager.getCurrentDatabase(), 
"src_view"), view, false);
+
+            assertThatThrownBy(
+                            () ->
+                                    parse(
+                                            "CREATE OR ALTER MATERIALIZED 
TABLE src_view"
+                                                    + " AS SELECT a, b FROM 
t1"))
+                    .isInstanceOf(ValidationException.class)
+                    .hasMessageContaining(
+                            "VIEW does not support the CREATE OR ALTER 
MATERIALIZED TABLE operation.");
+        }
+
+        private void createExistingMaterializedTable()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            final String sql =
+                    "CREATE MATERIALIZED TABLE existing_mt (\n"
+                            + "  CONSTRAINT pk1 PRIMARY KEY(a) NOT ENFORCED\n"
+                            + ")\n"
+                            + "AS SELECT a, b FROM t1";
+            final Operation op = parse(sql);
+            
assertThat(op).isInstanceOf(CreateMaterializedTableOperation.class);
+            final CatalogMaterializedTable mt =
+                    ((CreateMaterializedTableOperation) 
op).getCatalogMaterializedTable();
+            catalog.createTable(
+                    new ObjectPath(catalogManager.getCurrentDatabase(), 
EXISTING_MT_NAME),
+                    mt,
+                    true);
+        }
+    }
+
+    @Nested
+    class ConfigScope {
+
+        @Test
+        void sessionOnlyEnableHasNoEffect() {
+            tableConfig.set(
+                    
TableConfigOptions.MATERIALIZED_TABLE_CONVERSION_FROM_TABLE_ENABLED, true);
+            // root configuration left default (false)
+
+            assertThatThrownBy(() -> parse(conversionSql()))
+                    .isInstanceOf(ValidationException.class)
+                    .hasMessageContaining(
+                            "Regular table does not support create or alter 
operation.");
+        }
+
+        @Test
+        void clusterRootEnableAllowsConversion() {
+            configureConversionEnabled(true);
+
+            assertThat(parse(conversionSql()))
+                    
.isInstanceOf(ConvertTableToMaterializedTableOperation.class);
+        }
+
+        @Test
+        void bothSessionAndClusterEnabledAllowsConversion() {
+            tableConfig.set(
+                    
TableConfigOptions.MATERIALIZED_TABLE_CONVERSION_FROM_TABLE_ENABLED, true);
+            configureConversionEnabled(true);
+
+            assertThat(parse(conversionSql()))
+                    
.isInstanceOf(ConvertTableToMaterializedTableOperation.class);
+        }
+
+        @Test
+        void neitherSessionNorClusterEnabledIsRejected() {
+            // nothing set
+            assertThatThrownBy(() -> parse(conversionSql()))
+                    .isInstanceOf(ValidationException.class)
+                    .hasMessageContaining(
+                            "Regular table does not support create or alter 
operation.");
+        }
+
+        private String conversionSql() {
+            return "CREATE OR ALTER MATERIALIZED TABLE "
+                    + SOURCE_REGULAR_TABLE_NAME
+                    + " AS SELECT a, b FROM t1";
+        }
+    }
+
+    @Nested
+    class WatermarkAndPrimaryKey {
+
+        @BeforeEach
+        void enableConversion() {
+            configureConversionEnabled(true);
+        }
+
+        @Test
+        void sourceWatermarkAndPrimaryKeyAreNotInherited()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_wm_pk").withWatermark().withPrimaryKey().create();
+
+            final ResolvedSchema newSchema =
+                    convertedMaterializedTableSchema(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_wm_pk"
+                                    + " AS SELECT a, b, ts FROM t1_with_ts");
+
+            assertThat(newSchema.getColumnNames()).containsExactly("a", "b", 
"ts");
+            assertThat(newSchema.getWatermarkSpecs()).isEmpty();
+            assertThat(newSchema.getPrimaryKey()).isEmpty();
+        }
+
+        @Test
+        void columnListWithoutWatermarkOrPrimaryKeyDropsThem()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_list").withWatermark().withPrimaryKey().create();
+
+            final ResolvedSchema newSchema =
+                    convertedMaterializedTableSchema(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_list ("
+                                    + " a BIGINT NOT NULL, b STRING, ts 
TIMESTAMP(3))"
+                                    + " AS SELECT a, b, ts FROM t1_with_ts");
+
+            assertThat(newSchema.getColumnNames()).containsExactly("a", "b", 
"ts");
+            assertThat(newSchema.getWatermarkSpecs()).isEmpty();
+            assertThat(newSchema.getPrimaryKey()).isEmpty();
+        }
+
+        @Test
+        void declaredWatermarkAndPrimaryKeyArePresent()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_neither").create();
+
+            final ResolvedSchema newSchema =
+                    convertedMaterializedTableSchema(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_neither ("
+                                    + " a BIGINT NOT NULL, b STRING, ts 
TIMESTAMP(3),"
+                                    + " WATERMARK FOR ts AS ts - INTERVAL '5' 
SECOND,"
+                                    + " PRIMARY KEY (a) NOT ENFORCED)"
+                                    + " AS SELECT a, b, ts FROM t1_with_ts");
+
+            assertThat(newSchema.getColumnNames()).containsExactly("a", "b", 
"ts");
+            assertThat(newSchema.getWatermarkSpecs()).isNotEmpty();
+            assertThat(newSchema.getPrimaryKey()).isPresent();
+        }
+
+        @Test
+        void watermarkAndPrimaryKeyAreKeptWhenDeclared()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            
sourceTable("src_keep_wm_pk").withPrimaryKey().withWatermark().create();
+
+            final ResolvedSchema newSchema =
+                    convertedMaterializedTableSchema(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_keep_wm_pk 
("
+                                    + " PRIMARY KEY (a) NOT ENFORCED,"
+                                    + " WATERMARK FOR ts AS ts - INTERVAL '5' 
SECOND)"
+                                    + " AS SELECT a, b, ts FROM t1_with_ts");
+
+            assertThat(newSchema.getColumnNames()).containsExactly("a", "b", 
"ts");
+            assertThat(newSchema.getPrimaryKey()).isPresent();
+            assertThat(newSchema.getWatermarkSpecs()).isNotEmpty();
+        }
+
+        @Test
+        void declaredWatermarkOnlyDropsSourcePrimaryKey()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_pk_only").withPrimaryKey().create();
+
+            final ResolvedSchema newSchema =
+                    convertedMaterializedTableSchema(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_pk_only ("
+                                    + " a BIGINT NOT NULL, b STRING, ts 
TIMESTAMP(3),"
+                                    + " WATERMARK FOR ts AS ts - INTERVAL '5' 
SECOND)"
+                                    + " AS SELECT a, b, ts FROM t1_with_ts");
+
+            assertThat(newSchema.getColumnNames()).containsExactly("a", "b", 
"ts");
+            assertThat(newSchema.getWatermarkSpecs()).isNotEmpty();
+            assertThat(newSchema.getPrimaryKey()).isEmpty();
+        }
+
+        @Test
+        void declaredPrimaryKeyOnlyDropsSourceWatermark()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_wm_only").withWatermark().create();
+
+            final ResolvedSchema newSchema =
+                    convertedMaterializedTableSchema(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_wm_only ("
+                                    + " a BIGINT NOT NULL, b STRING, ts 
TIMESTAMP(3),"
+                                    + " PRIMARY KEY (a) NOT ENFORCED)"
+                                    + " AS SELECT a, b, ts FROM t1_with_ts");
+
+            assertThat(newSchema.getColumnNames()).containsExactly("a", "b", 
"ts");
+            assertThat(newSchema.getWatermarkSpecs()).isEmpty();
+            assertThat(newSchema.getPrimaryKey()).isPresent();
+        }
+
+        @Test
+        void neitherSourceNorStatementDeclaresThem()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_plain").create();
+
+            final ResolvedSchema newSchema =
+                    convertedMaterializedTableSchema(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_plain"
+                                    + " AS SELECT a, b, ts FROM t1_with_ts");
+
+            assertThat(newSchema.getColumnNames()).containsExactly("a", "b", 
"ts");
+            assertThat(newSchema.getWatermarkSpecs()).isEmpty();
+            assertThat(newSchema.getPrimaryKey()).isEmpty();
+        }
+
+        private ResolvedSchema convertedMaterializedTableSchema(String sql) {
+            return 
convertedOperation(sql).getMaterializedTable().getResolvedSchema();
+        }
+    }
+
+    @Nested
+    class SchemaDerivation {
+
+        @BeforeEach
+        void enableConversion() {
+            configureConversionEnabled(true);
+        }
+
+        @Test
+        void declaredPhysicalColumnMissingFromQueryIsRejected()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_missing_col").create();
+
+            assertThatThrownBy(
+                            () ->
+                                    parse(
+                                            "CREATE OR ALTER MATERIALIZED 
TABLE src_missing_col ("
+                                                    + " a BIGINT NOT NULL, b 
STRING, ts TIMESTAMP(3), extra STRING)"
+                                                    + " AS SELECT a, b, ts 
FROM t1_with_ts"))
+                    .isInstanceOf(ValidationException.class)
+                    .hasMessageContaining("could not be found in the query");
+        }
+
+        @Test
+        void declaredCompatibleTypeGetsImplicitCast()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_implicit_cast").create();
+
+            // Query produces INT for `a`; the declared BIGINT is kept and an 
implicit cast is
+            // added.
+            final ResolvedSchema newSchema =
+                    convertedMaterializedTableSchema(
+                            "CREATE OR ALTER MATERIALIZED TABLE 
src_implicit_cast ("
+                                    + " a BIGINT, b STRING, ts TIMESTAMP(3))"
+                                    + " AS SELECT CAST(a AS INT) AS a, b, ts 
FROM t1_with_ts");
+
+            assertThat(newSchema.getColumnNames()).containsExactly("a", "b", 
"ts");
+            assertThat(newSchema.getColumn("a"))
+                    .hasValueSatisfying(
+                            column ->
+                                    
assertThat(column.getDataType().getLogicalType().getTypeRoot())
+                                            .isSameAs(LogicalTypeRoot.BIGINT));
+        }
+
+        @Test
+        void declaredIncompatibleTypeIsRejected()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_incompatible").create();
+
+            assertThatThrownBy(
+                            () ->
+                                    parse(
+                                            "CREATE OR ALTER MATERIALIZED 
TABLE src_incompatible ("
+                                                    + " a BIGINT NOT NULL, b 
INT, ts TIMESTAMP(3))"
+                                                    + " AS SELECT a, b, ts 
FROM t1_with_ts"))
+                    .isInstanceOf(ValidationException.class)
+                    .hasMessageContaining("Incompatible types for sink column 
'b'");
+        }
+
+        @Test
+        void computedAndVirtualMetadataColumnsAreAllowedAndNotPersisted()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_virtual").create();
+
+            final ResolvedSchema newSchema =
+                    convertedMaterializedTableSchema(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_virtual ("
+                                    + " a BIGINT NOT NULL, b STRING, ts 
TIMESTAMP(3),"
+                                    + " c AS current_timestamp,"
+                                    + " m STRING METADATA VIRTUAL)"
+                                    + " AS SELECT a, b, ts FROM t1_with_ts");
+
+            assertThat(newSchema.getColumnNames()).containsExactly("c", "m", 
"a", "b", "ts");
+            assertThat(newSchema.getColumn("c"))
+                    .hasValueSatisfying(
+                            column -> {
+                                
assertThat(column).isInstanceOf(Column.ComputedColumn.class);
+                                assertThat(column.isPersisted()).isFalse();
+                            });
+            assertThat(newSchema.getColumn("m"))
+                    .hasValueSatisfying(
+                            column -> {
+                                
assertThat(column).isInstanceOf(Column.MetadataColumn.class);
+                                assertThat(column.isPersisted()).isFalse();
+                            });
+        }
+
+        @Test
+        void columnIdentifiersOnlyReorderQueryColumns()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_reorder").create();
+
+            final ResolvedSchema newSchema =
+                    convertedMaterializedTableSchema(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_reorder 
(b, a, ts)"
+                                    + " AS SELECT a, b, ts FROM t1_with_ts");
+
+            assertThat(newSchema.getColumnNames()).containsExactly("b", "a", 
"ts");
+        }
+
+        @Test
+        void columnIdentifiersOnlyCountMismatchIsRejected()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            sourceTable("src_reorder_bad").create();
+
+            assertThatThrownBy(
+                            () ->
+                                    parse(
+                                            "CREATE OR ALTER MATERIALIZED 
TABLE src_reorder_bad (a, b)"
+                                                    + " AS SELECT a, b, ts 
FROM t1_with_ts"))
+                    .isInstanceOf(ValidationException.class)
+                    .hasMessageContaining("number of columns in the column 
list must match");
+        }
+
+        @Test
+        void nonPersistedSourceColumnsAreNotInConvertedSchema()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            // Source has physical columns plus a computed column `c` and a 
virtual metadata
+            // column `m`, neither produced by the query.
+            
sourceTable("src_non_persisted").withNonPersistedColumns().create();
+
+            final ConvertTableToMaterializedTableOperation op =
+                    convertedOperation(
+                            "CREATE OR ALTER MATERIALIZED TABLE 
src_non_persisted"
+                                    + " AS SELECT a, b, ts FROM t1_with_ts");
+
+            // The converted materialized table is built from the query, so 
the non-persisted
+            // source columns are not part of its schema. This is the schema 
the catalog persists.
+            
assertThat(op.getMaterializedTable().getResolvedSchema().getColumnNames())
+                    .containsExactly("a", "b", "ts");
+
+            // Without a DDL column list the change list keeps non-persisted 
columns: no drop is
+            // emitted for `c` or `m`.
+            assertThat(op.getTableChanges())
+                    .noneMatch(
+                            change ->
+                                    change instanceof TableChange.DropColumn
+                                            && List.of("c", "m")
+                                                    .contains(
+                                                            
((TableChange.DropColumn) change)
+                                                                    
.getColumnName()));
+        }
+
+        private ResolvedSchema convertedMaterializedTableSchema(String sql) {
+            return 
convertedOperation(sql).getMaterializedTable().getResolvedSchema();
+        }
+    }
+
+    @Nested
+    class QueryEvolution {
+
+        @BeforeEach
+        void enableConversion() {
+            configureConversionEnabled(true);
+        }
+
+        @Test
+        void queryAddsColumnNotInSourceAddsItToNewMaterializedTable()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            // src has columns a, b, ts
+            sourceTable("src_add_col").create();
+
+            final ConvertTableToMaterializedTableOperation op =
+                    convertedOperation(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_add_col"
+                                    + " AS SELECT a, b, ts, CAST('extra' AS 
STRING) AS c FROM t1_with_ts");
+
+            final ResolvedSchema newSchema = 
op.getMaterializedTable().getResolvedSchema();
+            assertThat(newSchema.getColumnNames()).containsExactly("a", "b", 
"ts", "c");
+            assertThat(op.getTableChanges())
+                    .anyMatch(
+                            change ->
+                                    change instanceof TableChange.AddColumn
+                                            && "c"
+                                                    .equals(
+                                                            
((TableChange.AddColumn) change)
+                                                                    
.getColumn()
+                                                                    
.getName()));
+        }
+
+        @Test
+        void queryDropsColumnFromSourceDropsItFromNewMaterializedTable()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            // src has columns a, b, ts
+            sourceTable("src_drop_col").create();
+
+            final ConvertTableToMaterializedTableOperation op =
+                    convertedOperation(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_drop_col"
+                                    + " AS SELECT a, b FROM t1_with_ts");
+
+            final ResolvedSchema newSchema = 
op.getMaterializedTable().getResolvedSchema();
+            assertThat(newSchema.getColumnNames()).containsExactly("a", "b");
+            assertThat(op.getTableChanges())
+                    .anyMatch(
+                            change ->
+                                    change instanceof TableChange.DropColumn
+                                            && "ts"
+                                                    .equals(
+                                                            
((TableChange.DropColumn) change)
+                                                                    
.getColumnName()));
+        }
+
+        @Test
+        void queryRenamesColumnViaAliasIsModeledAsDropAndAdd()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            // src has columns a, b, ts
+            sourceTable("src_rename_col").create();
+
+            final ConvertTableToMaterializedTableOperation op =
+                    convertedOperation(
+                            "CREATE OR ALTER MATERIALIZED TABLE src_rename_col"
+                                    + " AS SELECT a, b, ts AS event_time FROM 
t1_with_ts");
+
+            final ResolvedSchema newSchema = 
op.getMaterializedTable().getResolvedSchema();
+            assertThat(newSchema.getColumnNames()).containsExactly("a", "b", 
"event_time");
+            assertThat(op.getTableChanges())
+                    .anyMatch(
+                            change ->
+                                    change instanceof TableChange.DropColumn
+                                            && "ts"
+                                                    .equals(
+                                                            
((TableChange.DropColumn) change)
+                                                                    
.getColumnName()))
+                    .anyMatch(
+                            change ->
+                                    change instanceof TableChange.AddColumn
+                                            && "event_time"
+                                                    .equals(
+                                                            
((TableChange.AddColumn) change)
+                                                                    
.getColumn()
+                                                                    
.getName()));
+        }
+
+        @Test
+        void queryChangesColumnTypeIsModeledAsModifyPhysicalColumnType()
+                throws TableAlreadyExistException, DatabaseNotExistException {
+            // src has columns a BIGINT NOT NULL, b STRING, ts TIMESTAMP(3)
+            sourceTable("src_type_change").create();
+
+            final ConvertTableToMaterializedTableOperation op =
+                    convertedOperation(
+                            "CREATE OR ALTER MATERIALIZED TABLE 
src_type_change"
+                                    + " AS SELECT CAST(a AS INT) AS a, b, ts 
FROM t1_with_ts");
+
+            final ResolvedSchema newSchema = 
op.getMaterializedTable().getResolvedSchema();
+            assertThat(newSchema.getColumn("a"))
+                    .hasValueSatisfying(
+                            column ->
+                                    
assertThat(column.getDataType().getLogicalType().getTypeRoot())
+                                            
.isSameAs(LogicalTypeRoot.INTEGER));
+            assertThat(op.getTableChanges())
+                    .anyMatch(change -> change instanceof 
TableChange.ModifyPhysicalColumnType);
+        }
+    }
+
+    // 
--------------------------------------------------------------------------------------------
+    // Helpers
+    // 
--------------------------------------------------------------------------------------------
+
+    private void configureConversionEnabled(boolean enabled) {
+        final Configuration root = new Configuration();
+        
root.set(TableConfigOptions.MATERIALIZED_TABLE_CONVERSION_FROM_TABLE_ENABLED, 
enabled);
+        tableConfig.setRootConfiguration(root);
+    }
+
+    private ConvertTableToMaterializedTableOperation convertedOperation(String 
sql) {
+        final Operation op = parse(sql);
+        
assertThat(op).isInstanceOf(ConvertTableToMaterializedTableOperation.class);
+        return (ConvertTableToMaterializedTableOperation) op;
+    }
+
+    private SourceTableBuilder sourceTable(String name) {
+        return new SourceTableBuilder(name);
+    }
+
+    /** Fluent builder for registering a regular {@code (a, b, ts)} source 
table in the catalog. */
+    private final class SourceTableBuilder {
+
+        private final String name;
+        private boolean withWatermark;
+        private boolean withPrimaryKey;
+        private boolean withNonPersistedColumns;
+
+        private SourceTableBuilder(String name) {
+            this.name = name;
+        }
+
+        SourceTableBuilder withWatermark() {
+            this.withWatermark = true;
+            return this;
+        }
+
+        SourceTableBuilder withPrimaryKey() {
+            this.withPrimaryKey = true;
+            return this;
+        }
+
+        SourceTableBuilder withNonPersistedColumns() {
+            this.withNonPersistedColumns = true;
+            return this;
+        }
+
+        void create() throws TableAlreadyExistException, 
DatabaseNotExistException {
+            final Schema.Builder schema = Schema.newBuilder();
+            schema.column("a", DataTypes.BIGINT().notNull());
+            schema.column("b", DataTypes.STRING());
+            schema.column("ts", DataTypes.TIMESTAMP(3));
+            if (withNonPersistedColumns) {
+                schema.columnByExpression("c", "current_timestamp");
+                schema.columnByMetadata("m", DataTypes.STRING(), null, true);
+            }
+            if (withWatermark) {
+                schema.watermark("ts", new SqlCallExpression("ts - INTERVAL 
'5' SECOND"));
+            }
+            if (withPrimaryKey) {
+                schema.primaryKeyNamed("pk_src", List.of("a"));
+            }
+            final Map<String, String> options = new HashMap<>();
+            options.put("connector", "COLLECTION");
+            final CatalogTable table =
+                    
CatalogTable.newBuilder().schema(schema.build()).options(options).build();
+            catalog.createTable(
+                    new ObjectPath(catalogManager.getCurrentDatabase(), name), 
table, false);
+        }
+    }
+}
diff --git 
a/flink-test-utils-parent/flink-table-filesystem-test-utils/src/main/java/org/apache/flink/table/file/testutils/catalog/TestFileSystemCatalog.java
 
b/flink-test-utils-parent/flink-table-filesystem-test-utils/src/main/java/org/apache/flink/table/file/testutils/catalog/TestFileSystemCatalog.java
index 787178367f2..384c5162229 100644
--- 
a/flink-test-utils-parent/flink-table-filesystem-test-utils/src/main/java/org/apache/flink/table/file/testutils/catalog/TestFileSystemCatalog.java
+++ 
b/flink-test-utils-parent/flink-table-filesystem-test-utils/src/main/java/org/apache/flink/table/file/testutils/catalog/TestFileSystemCatalog.java
@@ -26,6 +26,7 @@ import org.apache.flink.core.fs.FSDataInputStream;
 import org.apache.flink.core.fs.FSDataOutputStream;
 import org.apache.flink.core.fs.FileStatus;
 import org.apache.flink.core.fs.FileSystem;
+import org.apache.flink.core.fs.FileSystem.WriteMode;
 import org.apache.flink.core.fs.Path;
 import org.apache.flink.table.catalog.AbstractCatalog;
 import org.apache.flink.table.catalog.CatalogBaseTable;
@@ -365,8 +366,7 @@ public class TestFileSystemCatalog extends AbstractCatalog {
             // write table schema
             Path tableSchemaFilePath =
                     tableSchemaFilePath(tableSchemaPath, 
tablePath.getObjectName());
-            try (FSDataOutputStream os =
-                    fs.create(tableSchemaFilePath, 
FileSystem.WriteMode.NO_OVERWRITE)) {
+            try (FSDataOutputStream os = fs.create(tableSchemaFilePath, 
WriteMode.NO_OVERWRITE)) {
                 os.write(jsonSchema.getBytes(StandardCharsets.UTF_8));
             }
 
@@ -393,9 +393,8 @@ public class TestFileSystemCatalog extends AbstractCatalog {
             return;
         }
 
-        Tuple4<Path, Path, Path, String> tableSchemaInfo = 
getTableJsonInfo(tablePath, newTable);
-        Path tableSchemaPath = tableSchemaInfo.f1;
-        String jsonSchema = tableSchemaInfo.f3;
+        Path tableSchemaPath = inferTableSchemaPath(catalogPathStr, tablePath);
+        String jsonSchema = serializeTableJson(newTable);
         try {
             if (!fs.exists(tableSchemaPath)) {
                 throw new CatalogException(
@@ -406,8 +405,7 @@ public class TestFileSystemCatalog extends AbstractCatalog {
             // write new table schema
             Path tableSchemaFilePath =
                     tableSchemaFilePath(tableSchemaPath, 
tablePath.getObjectName());
-            try (FSDataOutputStream os =
-                    fs.create(tableSchemaFilePath, 
FileSystem.WriteMode.OVERWRITE)) {
+            try (FSDataOutputStream os = fs.create(tableSchemaFilePath, 
WriteMode.OVERWRITE)) {
                 os.write(jsonSchema.getBytes(StandardCharsets.UTF_8));
             }
 
@@ -416,6 +414,37 @@ public class TestFileSystemCatalog extends AbstractCatalog 
{
         }
     }
 
+    @Override
+    public void convertTableToMaterializedTable(
+            ObjectPath tablePath,
+            CatalogTable originalTable,
+            CatalogMaterializedTable materializedTable,
+            List<TableChange> tableChanges)
+            throws TableNotExistException, CatalogException {
+        if (!tableExists(tablePath)) {
+            throw new TableNotExistException(getName(), tablePath);
+        }
+
+        Path tableSchemaPath = inferTableSchemaPath(catalogPathStr, tablePath);
+        String jsonSchema = serializeTableJson(materializedTable);
+        try {
+            if (!fs.exists(tableSchemaPath)) {
+                throw new CatalogException(
+                        String.format(
+                                "Table %s schema file %s doesn't exist.",
+                                tablePath, tableSchemaPath));
+            }
+            // overwrite the schema file with the materialized table definition
+            Path tableSchemaFilePath =
+                    tableSchemaFilePath(tableSchemaPath, 
tablePath.getObjectName());
+            try (FSDataOutputStream os = fs.create(tableSchemaFilePath, 
WriteMode.OVERWRITE)) {
+                os.write(jsonSchema.getBytes(StandardCharsets.UTF_8));
+            }
+        } catch (IOException e) {
+            throw new CatalogException(String.format("Error converting table 
%s.", tablePath), e);
+        }
+    }
+
     @Override
     public List<CatalogPartitionSpec> listPartitions(ObjectPath tablePath)
             throws TableNotExistException, TableNotPartitionedException, 
CatalogException {
@@ -588,16 +617,17 @@ public class TestFileSystemCatalog extends 
AbstractCatalog {
         final Path tableSchemaPath = inferTableSchemaPath(catalogPathStr, 
tablePath);
         final Path tableDataPathStr = inferTableDataPath(catalogPathStr, 
tablePath);
 
-        ResolvedCatalogBaseTable<?> resolvedCatalogBaseTable =
-                (ResolvedCatalogBaseTable<?>) catalogTable;
-        Map<String, String> catalogTableInfo = 
serializeTable(resolvedCatalogBaseTable);
-        CatalogBaseTable.TableKind tableKind = catalogTable.getTableKind();
-        FileSystemTableInfo tableInfo = new FileSystemTableInfo(tableKind, 
catalogTableInfo);
-
-        String jsonSchema = JsonSerdeUtil.toJson(tableInfo);
+        String jsonSchema = serializeTableJson(catalogTable);
         return Tuple4.of(path, tableSchemaPath, tableDataPathStr, jsonSchema);
     }
 
+    private String serializeTableJson(CatalogBaseTable catalogTable) {
+        ResolvedCatalogBaseTable<?> resolved = (ResolvedCatalogBaseTable<?>) 
catalogTable;
+        FileSystemTableInfo tableInfo =
+                new FileSystemTableInfo(catalogTable.getTableKind(), 
serializeTable(resolved));
+        return JsonSerdeUtil.toJson(tableInfo);
+    }
+
     /** Read file to UTF_8 decoding. */
     private String readFileUtf8(Path path) throws IOException {
         try (FSDataInputStream in = path.getFileSystem().open(path)) {

Reply via email to