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

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


The following commit(s) were added to refs/heads/master by this push:
     new 7e6f6cb3a34 [fix](fe) Resolve DEFAULT(column) on Iceberg tables from 
the write schema (#69488)
7e6f6cb3a34 is described below

commit 7e6f6cb3a34ab11ad0399cb10219c18f96763a93
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Mon Nov 2 09:47:01 2026 +0000

    [fix](fe) Resolve DEFAULT(column) on Iceberg tables from the write schema 
(#69488)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #66538, #57531, #65851
    
    Problem Summary:
    
    **In short.** On an Iceberg table, `DEFAULT(column)` anywhere except
    `UPDATE ... SET` and `MERGE ... VALUES` reads the default from the read
    schema, which carries no Iceberg write-default. This PR resolves it
    against the write schema that the statement pinned for its target, as
    branch-4.1 and branch-4.2 do.
    
    **Background.** An Iceberg `write-default` applies to rows written after
    it is set. So it lives in the writer schema that a write statement pins
    (`IcebergWriteSchemaContext`), not in the columns of the read schema.
    #65851 resolves `DEFAULT(column)` against that pinned schema in `UPDATE
    ... SET`, `MERGE ... UPDATE SET` and `MERGE ... INSERT VALUES`
    (`ConnectorWriteSchemaUtils`). Every other `DEFAULT(column)` either
    fails at binding or reaches `RewriteDefaultExpression` (#57531), which
    reads the default from the bound column. branch-4.1 #66538, also in
    branch-4.2, resolves these against the pinned write target too. It
    rejects `DEFAULT(column)` on an Iceberg column in a statement that does
    not write the table.
    
    **The problem, and what it cost.** Reproduced on a local cluster with a
    `file://` format-version 3 table: `CREATE TABLE t (id INT, c INT)`,
    `ALTER TABLE t ADD COLUMN d INT DEFAULT 42`, one row `(0, 0)`, so `d =
    42`.
    
    | Statement | Master today | After this PR (same as branch-4.2) |
    |---|---|---|
    | `INSERT INTO t (id, c, d) VALUES (1, 1, DEFAULT(d))` | `Unknown column
    'd' in 'table list'` | writes 42 |
    | `INSERT INTO t (id, c, d) SELECT 2, 2, DEFAULT(d) FROM t WHERE id = 0`
    | writes NULL | writes 42 |
    | `INSERT INTO t (id, c, d) SELECT 3, 3, DEFAULT(d)` | `Unknown column
    'd'` | writes 42 |
    | `UPDATE t SET c = 100 WHERE d = DEFAULT(d)` | updates nothing: `d =
    NULL` makes the plan an empty set | updates every row, `d = 42` is
    pushed down to the scan |
    | `MERGE INTO t USING (SELECT 0 AS id) s ON t.id = s.id AND t.d =
    DEFAULT(t.d) WHEN MATCHED THEN UPDATE SET c = 300` | matches nothing |
    updates row 0 |
    | `SELECT DEFAULT(d) FROM t` | NULL | `DEFAULT(d) must reference a
    column of the table that this statement writes` |
    
    The `UPDATE` and `MERGE` cases fail silently: the statement succeeds and
    changes nothing.
    
    **How this PR fixes it.**
    
    - fe-core cannot tell from a column whether its connector keeps defaults
    in the read schema. Hive does: an HMS default shows up in both schemas.
    Iceberg does not. The new connector capability
    `SUPPORTS_WRITE_ONLY_DEFAULTS` says which kind a table is. The Iceberg
    connector declares it. The Hive connector passes it on to iceberg-on-HMS
    tables, like the other per-table capabilities, but only through the
    two-argument `getTableSchema`. Since #69299, a statement that pins the
    table at its latest snapshot reads the schema from the snapshot
    overload, which reflects none of them, so on an iceberg-on-HMS table
    such a statement keeps master's results (for example, `UPDATE t SET c =
    100 WHERE d = DEFAULT(d)` still changes no row). Reflecting the
    sibling's capabilities there changes planning for all of them and is
    left to a follow-up PR.
    - For a target that declares it, `InsertUtils.normalizePlan` resolves
    `DEFAULT(column)` in `INSERT ... VALUES` and in the select lists of
    `INSERT ... SELECT`, including the replacements of `SELECT *
    REPLACE(...)`, against the pinned write schema. It does this by name,
    before the source relations are bound, so the name always refers to a
    target column, as in branch-4.2. In MySQL, `DEFAULT(column)` in a
    `SELECT` refers to a source column instead. A derived table in `FROM` is
    a query of its own and is left alone: `DEFAULT(column)` in it still
    names a column of its own `FROM` clause.
    - `RewriteDefaultExpression` takes the default for a `DEFAULT(column)`
    bound to such a table from the pinned write column with the same field
    id. That covers the `WHERE` of `UPDATE` and the `ON` of `MERGE`. A MERGE
    resolves it whatever its WHEN branches, also when they all delete, as
    branch-4.2 does. If the statement pinned no write schema for that table,
    it fails. A `DELETE` pins the writer schema too, for its delete sink,
    but it writes no row values, so `RowLevelDmlCommand` marks its target
    and `DEFAULT(column)` on it fails with `DEFAULT(d) cannot be used in a
    DELETE: the defaults of this table apply only to the rows that a
    statement writes`, as in branch-4.2, whose DELETE installs no write
    schema context. Master reads NULL there, so `DELETE FROM t WHERE d =
    DEFAULT(d)` deletes nothing; resolved to the write-default it would
    delete every row with `d = 42`.
    - A resolved default is cast to the type of its writer column before the
    expression around it is bound, as `RewriteDefaultExpression` already
    does for a bound `DEFAULT(column)`. The parsed literal `-1` is a
    TINYINT, so without the cast `bit_count(DEFAULT(c))` on an INT column
    would count 8 bits instead of 32. The external `UPDATE`, `MERGE` and
    changelog paths use the same helper. `RewriteDefaultExpression` rejects
    a bound `DEFAULT(column)` whose writer column has another type than the
    read column the statement was bound with, which happens only when the
    table changed outside Doris after Doris cached it, and asks to refresh
    the table: the expressions around it were bound against the read type.
    - Hive, Paimon and internal tables do not declare the capability, so
    they behave as before.
    - The new enum constant changes the connector plugin API surface, so
    `connector.plugin.api.version` goes from 26.0 to 27.0, as for the other
    recent changes to that surface (#69299, #69588, #69298, #69683 and
    #69835 took 22.0 to 26.0).
    
    **Where `DEFAULT(column)` resolves on an Iceberg target (direct Iceberg
    catalogs).**
    
    | Statement | Position of `DEFAULT(column)` | Result | branch-4.2 |
    |---|---|---|---|
    | `INSERT` / `INSERT OVERWRITE` | `VALUES` rows | the target column's
    write-default, typed as the column | same |
    | | the INSERT's own select lists (with or without `FROM`, set operation
    branches, `SELECT * REPLACE`) | the target column's write-default, by
    name, before binding | same, except `* REPLACE`, which branch-4.2 binds
    to the source |
    | | `GROUP BY` / `ROLLUP` outputs, a derived table in `FROM`, `WITH`
    definitions, subqueries, `WHERE` / `ON` | bound like any
    `DEFAULT(column)` (last row) | same, except derived tables, which
    branch-4.2 resolves against the target |
    | | a `VALUES` branch of a set operation | fails with an unknown column,
    as on master | same |
    | | a qualifier other than the target (`DEFAULT(src.c)`,
    `DEFAULT(ctl.db.t.c)`) in the positions above | fails: `column in
    assignment list is invalid` | same |
    | `UPDATE` | `SET` / `WHERE` | write-default | same |
    | `MERGE` | `ON` and `WHEN ... AND` conditions, whatever the WHEN
    branches, also when they all delete | write-default | same |
    | | `UPDATE SET` / `INSERT VALUES` | write-default | same |
    | `DELETE` (also `DELETE ... USING`) | anywhere | fails: `cannot be used
    in a DELETE` | fails: its DELETE has no write schema context |
    | any | a bound `DEFAULT(x.c)` | `x` is the written table: its
    write-default by field id; an OLAP or Hive table: that column's default;
    another Iceberg table: fails with `must reference a column of the table
    that this statement writes` | same |
    
    A bound `DEFAULT(column)` whose writer column has another type than the
    read column fails and asks to refresh the table. Where `DEFAULT(column)`
    is bound rather than resolved by name (the third row), it keeps its
    meaning from before this PR and names a column of its own `FROM` clause,
    as on master and branch-4.2: for example in `INSERT INTO t SELECT id,
    DEFAULT(d) FROM src GROUP BY id, d` with an OLAP or Hive `src`, it takes
    the default of `src.d`.
    
    **Results.**
    
    - Local cluster, with the table above: the table shows what each
    statement did before and after. `EXPLAIN UPDATE t SET c = 5 WHERE d =
    DEFAULT(d)` goes from an empty set to a scan with `d = 42` pushed down
    to Iceberg.
    - Unit tests: 6 new tests in `InsertUtilsTest`, 6 in the new
    `RewriteDefaultExpressionTest` and 1 in `RowLevelDmlCommandTest`. They
    cover VALUES, and INSERT ... SELECT both from a staging relation whose
    own column is also `name` and without FROM: each resolves to the pinned
    default, and an unknown column fails. `DEFAULT(column)` inside a derived
    table, qualified or not and also under a UNION branch, is left to
    binding. A `SELECT * REPLACE(DEFAULT(name) AS name)` replacement
    resolves like the rest of the select list and keeps the star's qualifier
    and `EXCEPT` list. The pinned write column wins over the read column. A
    statement that writes nothing fails. A table without the capability is
    unchanged. With master's `InsertUtils` and `RewriteDefaultExpression`,
    the 5 tests of the fix fail and the 2 tests of the unchanged path pass.
    The `SELECT * REPLACE` test fails with this PR's previous `InsertUtils`,
    where the replacement stays `DEFAULT(name)`. `IcebergConnectorTest`,
    `HiveConnectorMetadataSiblingDelegationTest` and
    `ConnectorPluginSurfaceTest` pass. So do the fe-core tests around the
    write schema: `ConnectorWriteSchemaUtilsTest`,
    `ExternalRowLevelUpdatePlanBuilderTest`,
    `ExternalRowLevelMergePlanBuilderTest`,
    `ChangelogRowLevelDmlTransformTest` and `PluginDrivenExternalTableTest`.
    Two of the tests pin the typing: `bit_count(DEFAULT(bits))` gets the
    default as an INT in VALUES and in SELECT, and fails without the cast; a
    BIGINT writer column over an INT read column is rejected, directly and
    under an IF bound to INT. `DEFAULT(column)` on the target of a DELETE
    fails even with a pinned write-default, and only a DELETE marks its
    target (an UPDATE does not); skipping the check, or marking UPDATE
    instead of DELETE, makes exactly these two tests fail.
    `PositionDeleteRowLevelDmlTransformTest`,
    `PhysicalPlanTranslatorIcebergRowLevelDmlTest`, `DeleteFromCommandTest`
    and `UpdateCommandTest` pass.
    - Regression: `test_iceberg_initial_defaults` now writes rows 20 to 22
    with `DEFAULT(default_int)` in VALUES and in INSERT ... SELECT with and
    without FROM. It then updates them with `WHERE default_int =
    DEFAULT(default_int)` and `MERGE ... ON ... = DEFAULT(...)`, for Parquet
    and ORC, and checks the rows in the `.out`. A plain `SELECT
    DEFAULT(default_int)` must fail, and so must a `DELETE ... WHERE
    default_int = DEFAULT(default_int)` on rows 20 to 22, which hold the
    write-default. The suite passed locally before the DELETE block was
    added, and the whole suite, DELETE block included, has since passed in
    two External regression runs on CI. With master's FE it fails at the
    first of these statements: `Unknown column 'default_int' in 'table
    list'`.
---
 .../connector/hive/HiveConnectorMetadata.java      |   5 +-
 ...HiveConnectorMetadataSiblingDelegationTest.java |   6 +-
 .../doris/connector/iceberg/IcebergConnector.java  |   7 +-
 .../connector/iceberg/IcebergConnectorTest.java    |  12 ++
 .../doris/connector/spi/ConnectorCapability.java   |  17 +-
 .../connector/spi/ConnectorPluginSurfaceTest.java  |   5 +-
 .../test/resources/connector-plugin-surface.txt    |   1 +
 fe/fe-connector/pom.xml                            |   2 +-
 .../plugin/PluginDrivenExternalTable.java          |  11 ++
 .../org/apache/doris/nereids/StatementContext.java |  12 ++
 .../expression/rules/RewriteDefaultExpression.java |  37 ++++
 .../plans/commands/ConnectorWriteSchemaUtils.java  |   6 +-
 .../trees/plans/commands/RowLevelDmlCommand.java   |  15 +-
 .../trees/plans/commands/insert/InsertUtils.java   |  92 ++++++++-
 .../rules/RewriteDefaultExpressionTest.java        | 152 +++++++++++++++
 .../commands/ConnectorWriteSchemaUtilsTest.java    |  11 +-
 .../plans/commands/RowLevelDmlCommandTest.java     |  21 ++
 .../plans/commands/insert/InsertUtilsTest.java     | 212 +++++++++++++++++++++
 .../iceberg/test_iceberg_initial_defaults.out      |  10 +
 .../iceberg/test_iceberg_initial_defaults.groovy   |  39 ++++
 20 files changed, 648 insertions(+), 25 deletions(-)

diff --git 
a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
index a588b804927..3fefe4fddde 100644
--- 
a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
+++ 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
@@ -623,7 +623,8 @@ public class HiveConnectorMetadata implements 
ConnectorMetadata {
                     ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE,
                     ConnectorCapability.SUPPORTS_FIELD_ID_ACCESS_PATH,
                     ConnectorCapability.SUPPORTS_STORAGE_PREDICATE_PRUNING,
-                    ConnectorCapability.SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE));
+                    ConnectorCapability.SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE,
+                    ConnectorCapability.SUPPORTS_WRITE_ONLY_DEFAULTS));
 
     /**
      * Reflects the {@link #SIBLING_INHERITABLE_CAPABILITIES} subset of the 
owning sibling connector's
@@ -633,7 +634,7 @@ public class HiveConnectorMetadata implements 
ConnectorMetadata {
      * (hive) connector-wide set OR the table's own set, and NEVER consults 
the sibling connector directly, so
      * without this reflection an iceberg-on-HMS table would silently lose 
every such capability the iceberg
      * sibling declares connector-wide (auto-analyze / Top-N lazy / 
nested-column prune / storage predicate
-     * pruning / nested column DDL).
+     * pruning / nested column DDL / write-only defaults).
      * Returns the sibling schema unchanged when nothing is inherited and the 
sibling declared nothing itself
      * (e.g. a hudi sibling, which declares no capabilities at all).
      */
diff --git 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
index d2f340f0f31..77a7b1529eb 100644
--- 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
+++ 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
@@ -525,7 +525,8 @@ public class HiveConnectorMetadataSiblingDelegationTest {
                 ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE,
                 ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE,
                 ConnectorCapability.SUPPORTS_FIELD_ID_ACCESS_PATH,
-                ConnectorCapability.SUPPORTS_STORAGE_PREDICATE_PRUNING);
+                ConnectorCapability.SUPPORTS_STORAGE_PREDICATE_PRUNING,
+                ConnectorCapability.SUPPORTS_WRITE_ONLY_DEFAULTS);
         HiveConnectorMetadata md = new HiveConnectorMetadata(null, 
HiveTestProperties.minimal(), new FakeConnectorContext(),
                 SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED,
                 handle -> new SiblingOwner(new 
CapabilityDeclaringSiblingConnector(siblingCaps),
@@ -533,6 +534,9 @@ public class HiveConnectorMetadataSiblingDelegationTest {
 
         ConnectorTableSchema schema = md.getTableSchema(session, 
foreignHandle);
         Set<ConnectorCapability> reflected = schema.getTableCapabilities();
+        
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_WRITE_ONLY_DEFAULTS),
+                "an iceberg-on-HMS table must inherit write-only defaults, or 
DEFAULT(column) on it answers "
+                        + "NULL from the read schema");
         
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE),
                 "auto-analyze must survive the delegation as a per-table 
capability");
         
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE),
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
index 5d3c3ef3095..0d21e2a27bc 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
@@ -897,6 +897,10 @@ public class IcebergConnector implements Connector {
         // (CREATE TABLE ... ORDER BY (...)). fe-core admits the ORDER BY 
clause for a plugin-driven CREATE TABLE
         // ONLY under this capability (replacing the legacy engine-name 
"iceberg" gate); the sort-column validation
         // (existence / type / duplicates) runs connector-side in 
IcebergConnectorMetadata.createTable.
+        // SUPPORTS_WRITE_ONLY_DEFAULTS: an iceberg write-default applies to 
new rows only, so it lives in the
+        // writer schema (IcebergWriteSchemaContext) and not in the read 
schema. fe-core resolves DEFAULT(column)
+        // against the writer schema pinned for the write target and rejects 
it in a plain SELECT, instead of
+        // answering NULL from the read schema.
         EnumSet<ConnectorCapability> capabilities = 
EnumSet.of(ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT,
                 ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE,
                 ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE,
@@ -907,7 +911,8 @@ public class IcebergConnector implements Connector {
                 ConnectorCapability.SUPPORTS_STORAGE_PREDICATE_PRUNING,
                 ConnectorCapability.SUPPORTS_METADATA_PRELOAD,
                 ConnectorCapability.SUPPORTS_SORT_ORDER,
-                ConnectorCapability.SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE);
+                ConnectorCapability.SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE,
+                ConnectorCapability.SUPPORTS_WRITE_ONLY_DEFAULTS);
         // SUPPORTS_USER_SESSION: only a REST catalog configured 
iceberg.rest.session=user projects the querying
         // user's delegated credential onto a per-request Iceberg REST 
SessionCatalog (#63068 re-migration). This
         // gates FE credential injection + shared-cache bypass; every other 
flavor/config authenticates with a
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTest.java
index ee671040606..09c99edc77a 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTest.java
@@ -200,6 +200,18 @@ public class IcebergConnectorTest {
                         + "materialization");
     }
 
+    @Test
+    public void declaresWriteOnlyDefaultsCapability() {
+        // WHY: an iceberg write-default is carried by the writer schema only, 
so the read schema's columns have no
+        // default. fe-core resolves DEFAULT(column) against the pinned writer 
schema (and rejects it in a plain
+        // SELECT) ONLY under this capability. MUTATION: dropping the 
capability -> INSERT ... SELECT DEFAULT(c)
+        // writes NULL and UPDATE ... WHERE c = DEFAULT(c) silently matches 
nothing -> red.
+        IcebergConnector connector = new 
IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext());
+        Assertions.assertTrue(connector.getCapabilities()
+                        
.contains(ConnectorCapability.SUPPORTS_WRITE_ONLY_DEFAULTS),
+                "iceberg must declare SUPPORTS_WRITE_ONLY_DEFAULTS so 
DEFAULT(column) resolves to the write default");
+    }
+
     @Test
     public void declaresShowCreateDdlCapability() {
         // WHY: legacy IcebergExternalTable rendered LOCATION + PROPERTIES + 
PARTITION BY + ORDER BY in SHOW
diff --git 
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorCapability.java
 
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorCapability.java
index 97e8d546154..2c626b5fd1d 100644
--- 
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorCapability.java
+++ 
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorCapability.java
@@ -304,5 +304,20 @@ public enum ConnectorCapability {
      * data tables while declining it on the subset of system tables whose 
readers cannot observe a
      * selected snapshot.</p>
      */
-    SUPPORTS_SCAN_PARAM_OPTIONS
+    SUPPORTS_SCAN_PARAM_OPTIONS,
+    /**
+     * Indicates the connector's column defaults apply to writes only: they 
are carried by the
+     * request-scoped writer schema ({@code 
ConnectorWritePlanProvider.getWriteColumns}), not by the read
+     * schema (e.g. Iceberg {@code write-default}).
+     *
+     * <p>fe-core then resolves {@code DEFAULT(column)} against the writer 
schema a statement pinned for its
+     * write target: in INSERT VALUES, by name against the target in INSERT 
... SELECT, and in the
+     * predicates of UPDATE / MERGE on the target. Anywhere else, e.g. in a 
plain SELECT, {@code
+     * DEFAULT(column)} on such a table is rejected instead of answering NULL 
from the read schema.
+     * Connectors whose read schema carries the column defaults (hive) must 
NOT declare it.</p>
+     *
+     * <p><b>Scope: catalog-wide OR per-table.</b> An iceberg-on-HMS table 
inherits it through the per-table
+     * set the gateway reflects from its sibling, exactly like {@link 
#SUPPORTS_NESTED_COLUMN_PRUNE}.</p>
+     */
+    SUPPORTS_WRITE_ONLY_DEFAULTS
 }
diff --git 
a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
 
b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
index 6ea3635b1a2..4f1218df17f 100644
--- 
a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
+++ 
b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
@@ -88,8 +88,9 @@ public class ConnectorPluginSurfaceTest {
             Assertions.assertNotNull(in, "missing connector plugin API version 
resource");
             version.load(in);
         }
-        // Major 26 adds scan cancellation notifications and task removal to 
the scheduler surface.
-        Assertions.assertEquals("26.0", version.getProperty("api.version"));
+        // Major 26 adds scan cancellation notifications and task removal to 
the scheduler surface;
+        // major 27 adds the ConnectorCapability#SUPPORTS_WRITE_ONLY_DEFAULTS 
constant.
+        Assertions.assertEquals("27.0", version.getProperty("api.version"));
     }
 
     /** Root entry points plus provider/handle types returned to connector 
plugins. */
diff --git 
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
 
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
index 725d612521c..35ad6b851f1 100644
--- 
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
+++ 
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
@@ -35,6 +35,7 @@ 
org.apache.doris.connector.spi.ConnectorCapability#enum:SUPPORTS_SYS_TABLE_NESTE
 
org.apache.doris.connector.spi.ConnectorCapability#enum:SUPPORTS_TOPN_LAZY_MATERIALIZE
 org.apache.doris.connector.spi.ConnectorCapability#enum:SUPPORTS_USER_SESSION
 org.apache.doris.connector.spi.ConnectorCapability#enum:SUPPORTS_VIEW
+org.apache.doris.connector.spi.ConnectorCapability#enum:SUPPORTS_WRITE_ONLY_DEFAULTS
 org.apache.doris.connector.spi.ConnectorColumn#equals(java.lang.Object):boolean
 org.apache.doris.connector.spi.ConnectorColumn#getComment():java.lang.String
 
org.apache.doris.connector.spi.ConnectorColumn#getDefaultValue():java.lang.String
diff --git a/fe/fe-connector/pom.xml b/fe/fe-connector/pom.xml
index cbc125e18ac..f5394110e54 100644
--- a/fe/fe-connector/pom.xml
+++ b/fe/fe-connector/pom.xml
@@ -55,7 +55,7 @@ under the License.
           of the latter two means bumping this property as well (and 
fe-extension-spi means bumping
           all five families).
         -->
-        <connector.plugin.api.version>26.0</connector.plugin.api.version>
+        <connector.plugin.api.version>27.0</connector.plugin.api.version>
     </properties>
 
     <modules>
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java
index 6e3115668d0..47b1d6fc8cf 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java
@@ -475,6 +475,17 @@ public class PluginDrivenExternalTable extends 
ExternalTable {
         return hasCapability(ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE);
     }
 
+    /**
+     * Returns whether THIS table's column defaults apply to writes only, i.e. 
only the request-scoped writer
+     * schema ({@link #resolveWriteColumns}) carries them. {@code InsertUtils} 
and {@code RewriteDefaultExpression}
+     * then resolve {@code DEFAULT(column)} against the writer schema a write 
statement pinned, and reject it
+     * anywhere else instead of answering NULL from the read schema. Resolved 
per-table via {@link #hasCapability}
+     * so an iceberg-on-HMS table inherits it, mirroring {@link 
#supportsNestedColumnSchemaChange}.
+     */
+    public boolean hasWriteOnlyDefaults() {
+        return hasCapability(ConnectorCapability.SUPPORTS_WRITE_ONLY_DEFAULTS);
+    }
+
     /**
      * Whether this table supports a table-scoped capability, resolved 
connector-wide OR per-table. A
      * uniform-format connector (iceberg — every table orc/parquet) declares 
the capability for all its tables
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
index 2a74e0b7cb1..bf30f1d4949 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
@@ -293,6 +293,9 @@ public class StatementContext implements Closeable {
     // The identity is captured by the same provider call chain as 
connectorWriteSchemas. Keeping it alongside
     // the columns prevents bind-time expressions and the later sink fence 
from naming different generations.
     private final Map<Long, String> connectorWriteMetadataIdentities = new 
HashMap<>();
+    // Connector targets of a DELETE. Their writer schema is pinned for the 
delete sink, but the statement writes no
+    // row values, so a write-only default in that schema does not apply to 
DEFAULT(column) on them.
+    private final Set<Long> connectorDeleteTargets = new HashSet<>();
 
     // for create view support in nereids
     // key is the start and end position of the sql substring that needs to be
@@ -591,6 +594,14 @@ public class StatementContext implements Closeable {
         return Optional.ofNullable(connectorWriteSchemas.get(tableId));
     }
 
+    public void addConnectorDeleteTarget(long tableId) {
+        connectorDeleteTargets.add(tableId);
+    }
+
+    public boolean isConnectorDeleteTarget(long tableId) {
+        return connectorDeleteTargets.contains(tableId);
+    }
+
     public void setConnectorWriteMetadataIdentity(long tableId, String 
identity) {
         connectorWriteMetadataIdentities.put(tableId, identity);
     }
@@ -914,6 +925,7 @@ public class StatementContext implements Closeable {
         this.connectorStatementScope = null;
         this.connectorWriteSchemas.clear();
         this.connectorWriteMetadataIdentities.clear();
+        this.connectorDeleteTargets.clear();
         changelogReferenceTsoRequired = false;
         changelogReferenceTso = null;
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/RewriteDefaultExpression.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/RewriteDefaultExpression.java
index 6dc8ab7c72c..d0a3a455097 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/RewriteDefaultExpression.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/RewriteDefaultExpression.java
@@ -18,7 +18,10 @@
 package org.apache.doris.nereids.rules.expression.rules;
 
 import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.TableIf;
+import org.apache.doris.datasource.plugin.PluginDrivenExternalTable;
 import org.apache.doris.nereids.CascadesContext;
+import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.analyzer.UnboundFunction;
 import org.apache.doris.nereids.exceptions.AnalysisException;
 import org.apache.doris.nereids.parser.NereidsParser;
@@ -46,6 +49,9 @@ import java.util.Optional;
  * 1. DEFAULT(column) with default value -> the actual default value expression
  * 2. DEFAULT(column) with no default value but nullable -> NULL
  * 3. DEFAULT(column) with no default value and not nullable -> error
+ * A table whose defaults apply to writes only takes them from the writer 
schema that the statement
+ * pinned for its write target; DEFAULT(column) on any other such table, or on 
the target of a DELETE,
+ * which writes no row values, is an error.
  */
 public class RewriteDefaultExpression implements ExpressionPatternRuleFactory {
 
@@ -82,6 +88,29 @@ public class RewriteDefaultExpression implements 
ExpressionPatternRuleFactory {
                     + column.getName() + "'");
         }
 
+        Optional<TableIf> originalTable = slotRef.getOriginalTable();
+        if (originalTable.filter(PluginDrivenExternalTable.class::isInstance)
+                .filter(table -> ((PluginDrivenExternalTable) 
table).hasWriteOnlyDefaults())
+                .isPresent()) {
+            StatementContext statementContext = 
context.cascadesContext.getStatementContext();
+            if 
(statementContext.isConnectorDeleteTarget(originalTable.get().getId())) {
+                throw new AnalysisException("DEFAULT(" + slotRef.toSql() + ") 
cannot be used in a DELETE: the"
+                        + " defaults of this table apply only to the rows that 
a statement writes");
+            }
+            column = pinnedWriteColumn(statementContext, originalTable.get(), 
column)
+                    .orElseThrow(() -> new AnalysisException("DEFAULT(" + 
slotRef.toSql()
+                            + ") must reference a column of the table that 
this statement writes"));
+            // The expressions around DEFAULT(column) were bound against the 
read column, so a default of another type
+            // cannot stand in for it. The types differ when the table changed 
outside Doris after its read schema was
+            // cached, e.g. by a type promotion.
+            DataType writeType = DataType.fromCatalogType(column.getType());
+            if (!writeType.equals(targetType)) {
+                throw new AnalysisException("DEFAULT(" + slotRef.toSql() + ") 
reads the column as "
+                        + targetType.toSql() + " but the table now writes it 
as " + writeType.toSql()
+                        + "; the table changed outside Doris, refresh it and 
retry");
+            }
+        }
+
         String defaultValueSql = column.getDefaultValueSql();
         if (defaultValueSql == null) {
             if (column.isAllowNull()) {
@@ -101,4 +130,12 @@ public class RewriteDefaultExpression implements 
ExpressionPatternRuleFactory {
 
         return TypeCoercionUtils.castIfNotSameType(defaultValueExpr, 
targetType);
     }
+
+    private static Optional<Column> pinnedWriteColumn(StatementContext 
statementContext, TableIf table,
+            Column readColumn) {
+        return statementContext.getConnectorWriteSchema(table.getId())
+                .flatMap(columns -> columns.stream()
+                        .filter(column -> column.getUniqueId() == 
readColumn.getUniqueId())
+                        .findFirst());
+    }
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtils.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtils.java
index c4a8d94f9b2..a7f063eaba6 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtils.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtils.java
@@ -28,6 +28,8 @@ import org.apache.doris.nereids.trees.expressions.Default;
 import org.apache.doris.nereids.trees.expressions.DefaultValueSlot;
 import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.types.DataType;
+import org.apache.doris.nereids.util.TypeCoercionUtils;
 import org.apache.doris.qe.ConnectContext;
 
 import com.google.common.collect.ImmutableList;
@@ -101,7 +103,9 @@ public final class ConnectorWriteSchemaUtils {
                 throw new AnalysisException("Cannot find column information 
for DEFAULT("
                         + reference.toSql() + ")");
             }
-            return resolveDefault(column);
+            // The parsed default takes the literal's own type (-1 is a 
TINYINT); the expression around DEFAULT(column)
+            // must bind against the column's type, as the bound DEFAULT 
rewrite does.
+            return TypeCoercionUtils.castUnbound(resolveDefault(column), 
DataType.fromCatalogType(column.getType()));
         });
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java
index cb1884eb572..5917710bc9e 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommand.java
@@ -82,7 +82,7 @@ public class RowLevelDmlCommand {
     public void run(ConnectContext ctx, StmtExecutor stmtExecutor) throws 
Exception {
         TableIf table = args.getTable();
         transform.checkMode(table, op);
-        ConnectorWriteSchemaUtils.pinAndGet(ctx, (ExternalTable) table);
+        pinWriteSchema(ctx, table);
         long previousTargetTableId = ctx.getSyntheticWriteColTargetTableId();
         ctx.setSyntheticWriteColTargetTableId(table.getId());
         try {
@@ -154,7 +154,7 @@ public class RowLevelDmlCommand {
     public Plan getExplainPlan(ConnectContext ctx) {
         TableIf table = args.getTable();
         transform.checkMode(table, op);
-        ConnectorWriteSchemaUtils.pinAndGet(ctx, (ExternalTable) table);
+        pinWriteSchema(ctx, table);
         long previousTargetTableId = ctx.getSyntheticWriteColTargetTableId();
         ctx.setSyntheticWriteColTargetTableId(table.getId());
         try {
@@ -164,6 +164,17 @@ public class RowLevelDmlCommand {
         }
     }
 
+    /**
+     * Pin the target's writer schema for the statement. A DELETE needs it for 
its delete sink but writes no row
+     * values, so its target is also marked: DEFAULT(column) there gets no 
write-only default from the schema.
+     */
+    private void pinWriteSchema(ConnectContext ctx, TableIf table) {
+        ConnectorWriteSchemaUtils.pinAndGet(ctx, (ExternalTable) table);
+        if (op == RowLevelDmlOp.DELETE) {
+            ctx.getStatementContext().addConnectorDeleteTarget(table.getId());
+        }
+    }
+
     /**
      * The begin→finalize window, guarded like {@code 
InsertIntoTableCommand}'s prepare step:
      * {@code beginTransaction} registers the transaction with the connector 
transaction manager and the
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtils.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtils.java
index a4f1d1600ed..7f5803b0ef7 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtils.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtils.java
@@ -29,6 +29,7 @@ import org.apache.doris.catalog.Table;
 import org.apache.doris.catalog.TableIf;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.util.DebugPointUtil;
+import org.apache.doris.datasource.ExternalTable;
 import 
org.apache.doris.datasource.connector.converter.ConnectorWriteValueConverter;
 import org.apache.doris.datasource.plugin.PluginDrivenExternalTable;
 import org.apache.doris.foundation.format.FormatOptions;
@@ -41,6 +42,7 @@ import 
org.apache.doris.nereids.analyzer.UnboundConnectorTableSink;
 import org.apache.doris.nereids.analyzer.UnboundDictionarySink;
 import org.apache.doris.nereids.analyzer.UnboundFunction;
 import org.apache.doris.nereids.analyzer.UnboundInlineTable;
+import org.apache.doris.nereids.analyzer.UnboundOneRowRelation;
 import org.apache.doris.nereids.analyzer.UnboundSlot;
 import org.apache.doris.nereids.analyzer.UnboundStar;
 import org.apache.doris.nereids.analyzer.UnboundTableSink;
@@ -62,9 +64,12 @@ import 
org.apache.doris.nereids.trees.expressions.literal.Literal;
 import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
 import org.apache.doris.nereids.trees.plans.Plan;
 import org.apache.doris.nereids.trees.plans.algebra.InlineTable;
+import org.apache.doris.nereids.trees.plans.commands.ConnectorWriteSchemaUtils;
 import org.apache.doris.nereids.trees.plans.commands.info.DMLCommandType;
 import org.apache.doris.nereids.trees.plans.logical.LogicalInlineTable;
 import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSubQueryAlias;
 import org.apache.doris.nereids.trees.plans.logical.UnboundLogicalSink;
 import org.apache.doris.nereids.types.AggStateType;
 import org.apache.doris.nereids.types.DataType;
@@ -370,7 +375,12 @@ public class InsertUtils {
         }
         Plan query = unboundLogicalSink.child();
         checkGeneratedColumnForInsertIntoSelect(table, unboundLogicalSink, 
insertCtx);
+        Optional<List<Column>> writeOnlyDefaultColumns = 
writeOnlyDefaultColumns(table);
         if (!(query instanceof UnboundInlineTable)) {
+            if (writeOnlyDefaultColumns.isPresent()) {
+                return plan.withChildren(resolveSelectDefaultReferences(
+                        query, writeOnlyDefaultColumns, table, 
unboundLogicalSink.getNameParts()));
+            }
             return plan;
         }
 
@@ -446,7 +456,8 @@ public class InsertUtils {
                                     null, rewriteContext, strictCast);
                         } else {
                             DataType targetType = 
DataType.fromCatalogType(sameNameColumn.getType());
-                            NamedExpression value = values.get(i);
+                            NamedExpression value = 
resolveDefaultReferences(values.get(i),
+                                    writeOnlyDefaultColumns, table, 
unboundLogicalSink.getNameParts());
                             if (sameNameColumn.isTtlColumn() && 
targetType.isBigIntType()) {
                                 value = new UnboundAlias(new RowTtlExpiration(
                                         value instanceof Alias || value 
instanceof UnboundAlias
@@ -473,7 +484,9 @@ public class InsertUtils {
                                     null, rewriteContext, strictCast);
                         } else {
                             DataType targetType = 
DataType.fromCatalogType(columns.get(i).getType());
-                            addColumnValue(analyzer, optimizedRowConstructor, 
values.get(i), targetType,
+                            NamedExpression value = 
resolveDefaultReferences(values.get(i),
+                                    writeOnlyDefaultColumns, table, 
unboundLogicalSink.getNameParts());
+                            addColumnValue(analyzer, optimizedRowConstructor, 
value, targetType,
                                     rewriteContext, strictCast, 
columns.get(i));
                         }
                     }
@@ -485,15 +498,76 @@ public class InsertUtils {
     }
 
     static List<Column> connectorWriteSchema(TableIf table, boolean full) {
+        return pinnedConnectorWriteSchema(table).orElseGet(() -> 
table.getBaseSchema(full));
+    }
+
+    private static Optional<List<Column>> pinnedConnectorWriteSchema(TableIf 
table) {
         ConnectContext context = ConnectContext.get();
-        if (context != null && context.getStatementContext() != null) {
-            Optional<List<Column>> pinned =
-                    
context.getStatementContext().getConnectorWriteSchema(table.getId());
-            if (pinned.isPresent()) {
-                return pinned.get();
-            }
+        if (context == null || context.getStatementContext() == null) {
+            return Optional.empty();
+        }
+        return 
context.getStatementContext().getConnectorWriteSchema(table.getId());
+    }
+
+    /**
+     * The pinned writer schema of a target whose column defaults apply to 
writes only. The read schema of such a
+     * table carries no defaults, so DEFAULT(column) in an INSERT into it must 
resolve against this schema.
+     */
+    private static Optional<List<Column>> writeOnlyDefaultColumns(TableIf 
table) {
+        if (!(table instanceof PluginDrivenExternalTable)
+                || !((PluginDrivenExternalTable) 
table).hasWriteOnlyDefaults()) {
+            return Optional.empty();
         }
-        return table.getBaseSchema(full);
+        return pinnedConnectorWriteSchema(table);
+    }
+
+    /** Resolve DEFAULT(column) in one inserted value by name against the 
target's pinned writer schema. */
+    private static NamedExpression resolveDefaultReferences(NamedExpression 
value,
+            Optional<List<Column>> writeOnlyDefaultColumns, TableIf table, 
List<String> targetNameParts) {
+        if (!writeOnlyDefaultColumns.isPresent()) {
+            return value;
+        }
+        if (value instanceof UnboundStar) {
+            // SELECT * REPLACE(expr AS c) keeps its replacements in the star, 
not as expression children, so
+            // resolving the star's expression tree would leave a DEFAULT(c) 
there to bind against the source.
+            UnboundStar star = (UnboundStar) value;
+            return new UnboundStar(star.getQualifier(), 
star.getExceptedSlots(), star.getReplacedAlias().stream()
+                    .map(replace -> resolveDefaultReferences(replace, 
writeOnlyDefaultColumns, table, targetNameParts))
+                    .collect(ImmutableList.toImmutableList()), 
star.getIndexInSqlString());
+        }
+        return (NamedExpression) 
ConnectorWriteSchemaUtils.resolveDefaultReferences(value,
+                writeOnlyDefaultColumns.get(), (ExternalTable) table, 
ConnectContext.get(), targetNameParts, null);
+    }
+
+    /**
+     * Resolve DEFAULT(column) in the select lists of an INSERT ... SELECT, 
including the replacements of
+     * SELECT * REPLACE, by name against the target's pinned writer schema, 
before the source relations are
+     * bound, so it names a target column whatever the source is.
+     * A derived table in FROM is a query of its own: DEFAULT(column) in it 
names a column of its own FROM clause,
+     * as without this resolution, so it is left alone.
+     */
+    private static Plan resolveSelectDefaultReferences(Plan query, 
Optional<List<Column>> writeOnlyDefaultColumns,
+            TableIf table, List<String> targetNameParts) {
+        if (query instanceof LogicalSubQueryAlias) {
+            return query;
+        }
+        Plan plan = query.withChildren(child -> resolveSelectDefaultReferences(
+                child, writeOnlyDefaultColumns, table, targetNameParts));
+        if (plan instanceof LogicalProject) {
+            LogicalProject<?> project = (LogicalProject<?>) plan;
+            return project.withProjects(project.getProjects().stream()
+                    .map(projection -> resolveDefaultReferences(
+                            projection, writeOnlyDefaultColumns, table, 
targetNameParts))
+                    .collect(ImmutableList.toImmutableList()));
+        }
+        if (plan instanceof UnboundOneRowRelation) {
+            UnboundOneRowRelation relation = (UnboundOneRowRelation) plan;
+            return new UnboundOneRowRelation(relation.getRelationId(), 
relation.getProjects().stream()
+                    .map(projection -> resolveDefaultReferences(
+                            projection, writeOnlyDefaultColumns, table, 
targetNameParts))
+                    .collect(ImmutableList.toImmutableList()));
+        }
+        return plan;
     }
 
     static void pinConnectorWriteSchema(StatementContext statementContext, 
TableIf targetTableIf,
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/expression/rules/RewriteDefaultExpressionTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/expression/rules/RewriteDefaultExpressionTest.java
new file mode 100644
index 00000000000..11a7705a393
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/expression/rules/RewriteDefaultExpressionTest.java
@@ -0,0 +1,152 @@
+// 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.doris.nereids.rules.expression.rules;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.datasource.plugin.PluginDrivenExternalTable;
+import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.rules.expression.ExpressionRewriteTestHelper;
+import org.apache.doris.nereids.rules.expression.ExpressionRuleExecutor;
+import org.apache.doris.nereids.trees.expressions.Default;
+import org.apache.doris.nereids.trees.expressions.ExprId;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.If;
+import org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
+import org.apache.doris.nereids.types.IntegerType;
+import org.apache.doris.nereids.util.TypeCoercionUtils;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+public class RewriteDefaultExpressionTest extends ExpressionRewriteTestHelper {
+
+    private static final long TABLE_ID = 17L;
+    private static final int FIELD_ID = 3;
+
+    @BeforeEach
+    public void setUp() {
+        executor = new 
ExpressionRuleExecutor(ImmutableList.of(bottomUp(RewriteDefaultExpression.INSTANCE)));
+    }
+
+    @Test
+    public void writeOnlyDefaultResolvesAgainstPinnedWriterSchema() {
+        // UPDATE t ... WHERE score = DEFAULT(score): the scanned column 
carries no write-only default, the writer
+        // schema pinned for the statement's target does.
+        SlotReference score = scoreSlot(true);
+        pinWriterColumn("9");
+
+        Expression rewritten = executor.rewrite(new Default(score), context);
+
+        Assertions.assertEquals(
+                
TypeCoercionUtils.castIfNotSameType(PARSER.parseExpression("9"), 
IntegerType.INSTANCE), rewritten);
+    }
+
+    @Test
+    public void writeOnlyDefaultOfAWriterColumnOfAnotherTypeIsRejected() {
+        // The read schema can still hold the INT column after it was promoted 
to BIGINT outside Doris, while the
+        // writer schema pinned for the statement has the BIGINT column and 
its new default, which an INT cannot hold.
+        SlotReference score = scoreSlot(true);
+        pinWriterColumn(PrimitiveType.BIGINT, "4900000001");
+
+        AnalysisException e = Assertions.assertThrows(AnalysisException.class,
+                () -> executor.rewrite(new Default(score), context));
+
+        Assertions.assertTrue(e.getMessage().contains("DEFAULT(score) reads 
the column as INT but the table now writes"
+                + " it as BIGINT; the table changed outside Doris, refresh it 
and retry"), e.getMessage());
+    }
+
+    @Test
+    public void 
writeOnlyDefaultOfAnotherTypeIsRejectedInsideAnExpressionBoundToTheReadType() {
+        // UPDATE t ... WHERE IF(TRUE, DEFAULT(score), 0) = 4900000001: IF was 
bound with the INT of the read column,
+        // so substituting the BIGINT default would later fold into 
CAST(4900000001 AS INT), NULL in non-strict mode.
+        SlotReference score = scoreSlot(true);
+        pinWriterColumn(PrimitiveType.BIGINT, "4900000001");
+        If ifExpr = new If(BooleanLiteral.TRUE, new Default(score), new 
IntegerLiteral(0));
+        Assertions.assertEquals(IntegerType.INSTANCE, ifExpr.getDataType());
+
+        AnalysisException e = Assertions.assertThrows(AnalysisException.class,
+                () -> executor.rewrite(ifExpr, context));
+
+        Assertions.assertTrue(e.getMessage().contains("refresh it and retry"), 
e.getMessage());
+    }
+
+    @Test
+    public void writeOnlyDefaultOutsideTheWriteTargetIsRejected() {
+        // SELECT DEFAULT(score) FROM t writes nothing, so nothing carries the 
write-only default.
+        SlotReference score = scoreSlot(true);
+
+        AnalysisException e = Assertions.assertThrows(AnalysisException.class,
+                () -> executor.rewrite(new Default(score), context));
+
+        Assertions.assertTrue(e.getMessage().contains(
+                "DEFAULT(score) must reference a column of the table that this 
statement writes"), e.getMessage());
+    }
+
+    @Test
+    public void writeOnlyDefaultInADeleteIsRejected() {
+        // DELETE FROM t WHERE score = DEFAULT(score): the statement pins the 
writer schema for its delete sink, but
+        // it writes no row values, so the write-only default 9 must not match 
the rows it deletes.
+        SlotReference score = scoreSlot(true);
+        pinWriterColumn("9");
+        
cascadesContext.getStatementContext().addConnectorDeleteTarget(TABLE_ID);
+
+        AnalysisException e = Assertions.assertThrows(AnalysisException.class,
+                () -> executor.rewrite(new Default(score), context));
+
+        Assertions.assertTrue(e.getMessage().contains("DEFAULT(score) cannot 
be used in a DELETE: the defaults of"
+                + " this table apply only to the rows that a statement 
writes"), e.getMessage());
+    }
+
+    @Test
+    public void readSchemaDefaultIsKeptWithoutWriteOnlyDefaults() {
+        // A connector whose read schema carries its defaults (hive) resolves 
from the scanned column as before.
+        SlotReference score = scoreSlot(false);
+        pinWriterColumn("9");
+
+        Expression rewritten = executor.rewrite(new Default(score), context);
+
+        Assertions.assertEquals(new NullLiteral(IntegerType.INSTANCE), 
rewritten);
+    }
+
+    private SlotReference scoreSlot(boolean writeOnlyDefaults) {
+        PluginDrivenExternalTable table = 
Mockito.mock(PluginDrivenExternalTable.class);
+        Mockito.when(table.getId()).thenReturn(TABLE_ID);
+        
Mockito.when(table.hasWriteOnlyDefaults()).thenReturn(writeOnlyDefaults);
+        Column readColumn = new Column("score", PrimitiveType.INT, true);
+        readColumn.setUniqueId(FIELD_ID);
+        return SlotReference.fromColumn(new ExprId(1), table, readColumn, 
ImmutableList.of());
+    }
+
+    private void pinWriterColumn(String defaultSql) {
+        pinWriterColumn(PrimitiveType.INT, defaultSql);
+    }
+
+    private void pinWriterColumn(PrimitiveType type, String defaultSql) {
+        Column writeColumn = new Column("score", type, true);
+        writeColumn.setUniqueId(FIELD_ID);
+        writeColumn.setConnectorDefaultValueSql(defaultSql);
+        
cascadesContext.getStatementContext().setConnectorWriteSchema(TABLE_ID, 
ImmutableList.of(writeColumn));
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtilsTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtilsTest.java
index b61a51c1ead..32ce0f8827c 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtilsTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtilsTest.java
@@ -36,8 +36,8 @@ import 
org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.DecimalV3Literal;
 import org.apache.doris.nereids.trees.expressions.literal.DoubleLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.FloatLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.Literal;
-import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral;
 import org.apache.doris.nereids.types.DataType;
 import org.apache.doris.nereids.types.DecimalV3Type;
 import org.apache.doris.nereids.util.TypeCoercionUtils;
@@ -96,12 +96,13 @@ public class ConnectorWriteSchemaUtilsTest extends 
ParserTestBase {
 
     @Test
     public void defaultReferencesResolveAgainstPinnedWriteColumns() {
-        // DEFAULT(column) in DML resolves the statement-pinned write default; 
a name absent from the pinned writer
-        // schema is an error rather than a guess.
+        // DEFAULT(column) in DML resolves the statement-pinned write default, 
typed as its INT column rather than
+        // as the TINYINT the literal parses to; a name absent from the pinned 
writer schema is an error rather than
+        // a guess.
         ExternalTable target = targetTable();
         List<Column> pinned = ImmutableList.of(pinnedColumn("score", 1, "9"));
 
-        Assertions.assertEquals(new TinyIntLiteral((byte) 9), 
ConnectorWriteSchemaUtils.resolveDefaultReferences(
+        Assertions.assertEquals(new IntegerLiteral(9), 
ConnectorWriteSchemaUtils.resolveDefaultReferences(
                 new Default(new UnboundSlot("score")), pinned, target, 
ConnectContext.get(),
                 TARGET_NAME_PARTS, null));
         AnalysisException exception = 
Assertions.assertThrows(AnalysisException.class,
@@ -121,7 +122,7 @@ public class ConnectorWriteSchemaUtilsTest extends 
ParserTestBase {
         Column replacement = new Column("reused_name", 
ScalarType.createType(PrimitiveType.INT));
         replacement.setUniqueId(8);
 
-        Assertions.assertEquals(new TinyIntLiteral((byte) 9), 
ConnectorWriteSchemaUtils.resolveDefaultReferences(
+        Assertions.assertEquals(new IntegerLiteral(9), 
ConnectorWriteSchemaUtils.resolveDefaultReferences(
                 new Default(boundSlot(target, pinnedField)), pinned, target, 
ConnectContext.get(),
                 TARGET_NAME_PARTS, null));
         AnalysisException exception = 
Assertions.assertThrows(AnalysisException.class,
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommandTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommandTest.java
index 3d168db1ce2..d323e41e66e 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommandTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/RowLevelDmlCommandTest.java
@@ -20,6 +20,7 @@ package org.apache.doris.nereids.trees.plans.commands;
 import org.apache.doris.catalog.TableIf;
 import org.apache.doris.common.Status;
 import org.apache.doris.common.UserException;
+import org.apache.doris.datasource.ExternalTable;
 import org.apache.doris.nereids.CascadesContext;
 import org.apache.doris.nereids.NereidsPlanner;
 import org.apache.doris.nereids.PlannerHook;
@@ -34,6 +35,7 @@ import org.apache.doris.qe.Coordinator;
 import org.apache.doris.qe.StmtExecutor;
 import org.apache.doris.thrift.TStatusCode;
 
+import com.google.common.collect.ImmutableList;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
@@ -137,6 +139,25 @@ public class RowLevelDmlCommandTest {
         Mockito.verify(planner, 
Mockito.never()).setAnalyzedPlan(Mockito.any());
     }
 
+    @Test
+    public void onlyADeleteMarksItsTargetAsWritingNoRowValues() {
+        // Every row-level DML pins the target's writer schema, a DELETE for 
its delete sink. Only the DELETE writes
+        // no row values, so only its target is marked and keeps 
DEFAULT(column) off the write-only defaults.
+        ExternalTable target = Mockito.mock(ExternalTable.class);
+        Mockito.when(target.getId()).thenReturn(7L);
+        StatementContext statementContext = new StatementContext();
+        ConnectContext ctx = Mockito.mock(ConnectContext.class);
+        Mockito.when(ctx.getStatementContext()).thenReturn(statementContext);
+
+        new RowLevelDmlCommand(transform, RowLevelDmlArgs.forUpdate(target, 
ImmutableList.of("t"), null,
+                ImmutableList.of(), null), 
RowLevelDmlOp.UPDATE).getExplainPlan(ctx);
+        Assertions.assertFalse(statementContext.isConnectorDeleteTarget(7L));
+
+        new RowLevelDmlCommand(transform, RowLevelDmlArgs.forDelete(target, 
ImmutableList.of("t"), null, false,
+                ImmutableList.of(), null), 
RowLevelDmlOp.DELETE).getExplainPlan(ctx);
+        Assertions.assertTrue(statementContext.isConnectorDeleteTarget(7L));
+    }
+
     @Test
     public void retainedCancellationIsReplayedBeforeTheMetadataDelete() {
         // The row-level path replays the retained cancellation when the 
coordinator is published. The metadata
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java
index e624fecc4b0..1c88a19d093 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertUtilsTest.java
@@ -28,17 +28,36 @@ import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.analyzer.UnboundAlias;
 import org.apache.doris.nereids.analyzer.UnboundConnectorTableSink;
 import org.apache.doris.nereids.analyzer.UnboundFunction;
+import org.apache.doris.nereids.analyzer.UnboundInlineTable;
+import org.apache.doris.nereids.analyzer.UnboundOneRowRelation;
+import org.apache.doris.nereids.analyzer.UnboundSlot;
+import org.apache.doris.nereids.analyzer.UnboundStar;
 import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.parser.NereidsParser;
+import org.apache.doris.nereids.trees.expressions.Default;
+import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.RelationId;
+import org.apache.doris.nereids.trees.plans.logical.LogicalInlineTable;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSubQueryAlias;
+import org.apache.doris.nereids.trees.plans.logical.LogicalUnion;
 import org.apache.doris.nereids.trees.plans.logical.UnboundLogicalSink;
+import org.apache.doris.nereids.types.IntegerType;
+import org.apache.doris.nereids.util.MemoTestUtils;
+import org.apache.doris.nereids.util.TypeCoercionUtils;
+import org.apache.doris.qe.ConnectContext;
 
 import com.google.common.collect.ImmutableList;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 
+import java.util.List;
 import java.util.Optional;
 
 /**
@@ -47,6 +66,7 @@ import java.util.Optional;
 public class InsertUtilsTest {
 
     private static final int MAX_TOTAL_BYTES = 512;
+    private static final NereidsParser PARSER = new NereidsParser();
 
     private String generateString(int length) {
         return generateString(length, "X");
@@ -434,6 +454,198 @@ public class InsertUtilsTest {
                 "Cannot specify invisible column '_row_id' in INSERT 
statement", error.getMessage());
     }
 
+    @Test
+    public void writeOnlyDefaultInValuesResolvesAgainstPinnedWriterSchema() {
+        // insert into t (score, id) values (DEFAULT(score), 4): the read 
schema of a table with write-only
+        // defaults has no default, so DEFAULT(score) must take the pinned 
writer column's default.
+        ConnectContext ctx = MemoTestUtils.createConnectContext();
+        try {
+            PluginDrivenExternalTable table = pinWriterSchema(ctx, true);
+            Plan normalized = 
InsertUtils.normalizePlan(connectorSink(ImmutableList.of("score", "id"),
+                    new UnboundInlineTable(ImmutableList.of(ImmutableList.of(
+                            new UnboundAlias(new Default(new 
UnboundSlot("score"))),
+                            new UnboundAlias(new IntegerLiteral(4)))))),
+                    table, Optional.empty(), Optional.empty());
+
+            NamedExpression score = ((LogicalInlineTable) 
normalized.child(0)).getConstantExprsList().get(0).get(0);
+            
Assertions.assertEquals(TypeCoercionUtils.castUnbound(PARSER.parseExpression("9"),
 IntegerType.INSTANCE),
+                    score.child(0));
+
+            AnalysisException unknown = 
Assertions.assertThrows(AnalysisException.class,
+                    () -> 
InsertUtils.normalizePlan(connectorSink(ImmutableList.of("id", "score"),
+                            new 
UnboundInlineTable(ImmutableList.of(ImmutableList.of(
+                                    new UnboundAlias(new IntegerLiteral(6)),
+                                    new UnboundAlias(new Default(new 
UnboundSlot("no_such_column"))))))),
+                            table, Optional.empty(), Optional.empty()));
+            
Assertions.assertTrue(unknown.getMessage().contains("no_such_column"), 
unknown.getMessage());
+        } finally {
+            ConnectContext.remove();
+        }
+    }
+
+    @Test
+    public void writeOnlyDefaultInSelectResolvesByTargetColumnName() {
+        // DEFAULT(name) in INSERT ... SELECT names the target column whatever 
the source is: here a staging
+        // relation whose own column is also called name, and a SELECT without 
FROM.
+        ConnectContext ctx = MemoTestUtils.createConnectContext();
+        try {
+            PluginDrivenExternalTable table = pinWriterSchema(ctx, true);
+            Expression writeDefault = PARSER.parseExpression("'write-name'");
+
+            Plan fromStaging = 
InsertUtils.normalizePlan(connectorSink(ImmutableList.of("id", "name"),
+                    PARSER.parseSingle("select id, DEFAULT(name) from "
+                            + "(select 7 as id, 'source-name' as name) 
staging_source").child(0)),
+                    table, Optional.empty(), Optional.empty());
+            LogicalProject<?> project = (LogicalProject<?>) 
fromStaging.child(0);
+            Assertions.assertEquals(writeDefault, 
project.getProjects().get(1).child(0));
+
+            Plan noFrom = 
InsertUtils.normalizePlan(connectorSink(ImmutableList.of("id", "name"),
+                    new UnboundOneRowRelation(new RelationId(1), 
ImmutableList.of(
+                            new UnboundAlias(new IntegerLiteral(8)),
+                            new UnboundAlias(new Default(new 
UnboundSlot("name")))))),
+                    table, Optional.empty(), Optional.empty());
+            UnboundOneRowRelation oneRow = (UnboundOneRowRelation) 
noFrom.child(0);
+            Assertions.assertEquals(writeDefault, 
oneRow.getProjects().get(1).child(0));
+        } finally {
+            ConnectContext.remove();
+        }
+    }
+
+    @Test
+    public void writeOnlyDefaultInStarReplacementResolvesByTargetColumnName() {
+        // INSERT INTO t(id, name) SELECT src.* EXCEPT(score) 
REPLACE(DEFAULT(name) AS name) FROM src: the
+        // replacement belongs to the INSERT's own select list, so 
DEFAULT(name) names the target column whatever
+        // src.name's default is. UnboundStar keeps its replacements outside 
its expression children.
+        ConnectContext ctx = MemoTestUtils.createConnectContext();
+        try {
+            PluginDrivenExternalTable table = pinWriterSchema(ctx, true);
+            Plan normalized = 
InsertUtils.normalizePlan(connectorSink(ImmutableList.of("id", "name"),
+                    PARSER.parseSingle("select src.* except(score) 
replace(DEFAULT(name) as name) from src")
+                            .child(0)),
+                    table, Optional.empty(), Optional.empty());
+            UnboundStar star = (UnboundStar) ((LogicalProject<?>) 
normalized.child(0)).getProjects().get(0);
+            Assertions.assertEquals(PARSER.parseExpression("'write-name'"), 
star.getReplacedAlias().get(0).child(0));
+            Assertions.assertEquals(ImmutableList.of("src"), 
star.getQualifier());
+            Assertions.assertEquals(ImmutableList.of(new 
UnboundSlot("score")), star.getExceptedSlots());
+        } finally {
+            ConnectContext.remove();
+        }
+    }
+
+    @Test
+    public void writeOnlyDefaultInDerivedTableNamesItsOwnSource() {
+        // INSERT INTO t(score, name) SELECT v, DEFAULT(name) FROM (SELECT 
DEFAULT(score) AS v FROM src) q: the
+        // derived table is a query of its own, so its DEFAULT(score) names 
src.score, whose default can differ from
+        // the target's 9, and is left to binding, qualified or not. DEFAULT 
in the INSERT's own select lists, set
+        // operation branches included, still names the target column.
+        ConnectContext ctx = MemoTestUtils.createConnectContext();
+        try {
+            PluginDrivenExternalTable table = pinWriterSchema(ctx, true);
+            Expression writeDefault = PARSER.parseExpression("'write-name'");
+            for (String derived : ImmutableList.of("(select DEFAULT(score) as 
v from src) q",
+                    "(select DEFAULT(s.score) as v from src s) q")) {
+                Plan normalized = 
InsertUtils.normalizePlan(connectorSink(ImmutableList.of("score", "name"),
+                        PARSER.parseSingle("select v, DEFAULT(name) from " + 
derived).child(0)),
+                        table, Optional.empty(), Optional.empty());
+                LogicalProject<?> outer = (LogicalProject<?>) 
normalized.child(0);
+                Assertions.assertEquals(writeDefault, 
outer.getProjects().get(1).child(0), derived);
+                assertDerivedDefaultLeftToBinding(outer.child(0), derived);
+            }
+
+            Plan union = 
InsertUtils.normalizePlan(connectorSink(ImmutableList.of("name"),
+                    PARSER.parseSingle("select DEFAULT(name) union all "
+                            + "select v from (select DEFAULT(name) as v from 
src) q").child(0)),
+                    table, Optional.empty(), Optional.empty());
+            LogicalUnion setOperation = (LogicalUnion) union.child(0);
+            UnboundOneRowRelation oneRow = (UnboundOneRowRelation) 
setOperation.child(0);
+            Assertions.assertEquals(writeDefault, 
oneRow.getProjects().get(0).child(0));
+            assertDerivedDefaultLeftToBinding(setOperation.child(1).child(0), 
"union branch");
+        } finally {
+            ConnectContext.remove();
+        }
+    }
+
+    private static void assertDerivedDefaultLeftToBinding(Plan derivedTable, 
String message) {
+        Assertions.assertInstanceOf(LogicalSubQueryAlias.class, derivedTable, 
message);
+        LogicalProject<?> inner = (LogicalProject<?>) derivedTable.child(0);
+        Assertions.assertInstanceOf(Default.class, 
inner.getProjects().get(0).child(0), message);
+    }
+
+    @Test
+    public void defaultReferenceIsLeftToBindingWithoutWriteOnlyDefaults() {
+        // A connector whose read schema carries its defaults (hive) keeps the 
existing DEFAULT(column) handling.
+        ConnectContext ctx = MemoTestUtils.createConnectContext();
+        try {
+            PluginDrivenExternalTable table = pinWriterSchema(ctx, false);
+            Plan normalized = 
InsertUtils.normalizePlan(connectorSink(ImmutableList.of("id", "name"),
+                    new UnboundOneRowRelation(new RelationId(1), 
ImmutableList.of(
+                            new UnboundAlias(new IntegerLiteral(8)),
+                            new UnboundAlias(new Default(new 
UnboundSlot("name")))))),
+                    table, Optional.empty(), Optional.empty());
+
+            UnboundOneRowRelation oneRow = (UnboundOneRowRelation) 
normalized.child(0);
+            Assertions.assertInstanceOf(Default.class, 
oneRow.getProjects().get(1).child(0));
+        } finally {
+            ConnectContext.remove();
+        }
+    }
+
+    @Test
+    public void writeOnlyDefaultIsTypedAsItsColumnBeforeBinding() {
+        // bit_count(DEFAULT(bits)) on an INT column whose default is -1 must 
count the 32 bits of an INT, not the 8
+        // of the TINYINT that -1 parses to; casting the finished value to the 
INT target cannot restore them.
+        ConnectContext ctx = MemoTestUtils.createConnectContext();
+        try {
+            PluginDrivenExternalTable table = pinWriterSchema(ctx, true);
+            Expression intDefault = 
TypeCoercionUtils.castUnbound(PARSER.parseExpression("-1"), 
IntegerType.INSTANCE);
+
+            Expression bitCount = new UnboundFunction("bit_count",
+                    ImmutableList.of(new Default(new UnboundSlot("bits"))));
+            Plan values = 
InsertUtils.normalizePlan(connectorSink(ImmutableList.of("bits"),
+                    new 
UnboundInlineTable(ImmutableList.of(ImmutableList.of(new 
UnboundAlias(bitCount))))),
+                    table, Optional.empty(), Optional.empty());
+            Assertions.assertEquals(intDefault, bitCountArgument(
+                    ((LogicalInlineTable) 
values.child(0)).getConstantExprsList().get(0).get(0)));
+
+            Plan select = 
InsertUtils.normalizePlan(connectorSink(ImmutableList.of("bits"),
+                    PARSER.parseSingle("select 
bit_count(DEFAULT(bits))").child(0)),
+                    table, Optional.empty(), Optional.empty());
+            Assertions.assertEquals(intDefault, bitCountArgument(
+                    ((UnboundOneRowRelation) 
select.child(0)).getProjects().get(0)));
+            Assertions.assertEquals(IntegerType.INSTANCE, 
intDefault.getDataType());
+        } finally {
+            ConnectContext.remove();
+        }
+    }
+
+    private static Expression bitCountArgument(Expression value) {
+        UnboundFunction bitCount = 
value.<UnboundFunction>collectFirst(UnboundFunction.class::isInstance).get();
+        return bitCount.child(0);
+    }
+
+    private static PluginDrivenExternalTable pinWriterSchema(ConnectContext 
ctx, boolean writeOnlyDefaults) {
+        PluginDrivenExternalTable table = 
Mockito.mock(PluginDrivenExternalTable.class);
+        Mockito.when(table.getId()).thenReturn(17L);
+        Mockito.when(table.getName()).thenReturn("t");
+        
Mockito.when(table.hasWriteOnlyDefaults()).thenReturn(writeOnlyDefaults);
+        Column id = new Column("id", PrimitiveType.INT, true);
+        id.setConnectorDefaultValueSql("NULL");
+        Column name = new Column("name", PrimitiveType.STRING, true);
+        name.setConnectorDefaultValueSql("'write-name'");
+        Column score = new Column("score", PrimitiveType.INT, true);
+        score.setConnectorDefaultValueSql("9");
+        Column bits = new Column("bits", PrimitiveType.INT, true);
+        bits.setConnectorDefaultValueSql("-1");
+        MemoTestUtils.createStatementContext(ctx, "")
+                .setConnectorWriteSchema(17L, ImmutableList.of(id, name, 
score, bits));
+        return table;
+    }
+
+    private static UnboundConnectorTableSink<Plan> connectorSink(List<String> 
columnNames, Plan query) {
+        return new UnboundConnectorTableSink<>(ImmutableList.of("ctl", "db", 
"t"), columnNames,
+                ImmutableList.of(), ImmutableList.of(), query);
+    }
+
     @Test
     public void nativeExplicitInvisibleColumnRemainsSupported() {
         Column id = new Column("id", PrimitiveType.INT);
diff --git 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_initial_defaults.out
 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_initial_defaults.out
index d2945f45cf8..a5ddf7fb1a8 100644
--- 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_initial_defaults.out
+++ 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_initial_defaults.out
@@ -387,6 +387,16 @@ missing_map_col    
map<text,struct<missing_map_default_int:int>>   Yes     true    \N
 3      123e4567-e89b-12d3-a456-426614174000    0A0B0C0D        2A2B2C  
2024-12-17 23:59:59.123456+00:00
 4      123e4567-e89b-12d3-a456-426614174000    0A0B0C0D        2A2B2C  
2024-12-17 23:59:59.123456+00:00
 
+-- !parquet_default_references --
+20     35      update-where-default
+21     35      update-where-default
+22     35      merge-on-default
+
+-- !orc_default_references --
+20     35      update-where-default
+21     35      update-where-default
+22     35      merge-on-default
+
 -- !parquet_branch_overwrite_default --
 14     36
 
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_initial_defaults.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_initial_defaults.groovy
index e03347121cf..8d4ec700a90 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_initial_defaults.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_initial_defaults.groovy
@@ -600,6 +600,45 @@ suite("test_iceberg_initial_defaults", "p0,external") {
                         sparkUpdateMergeVerification)
     }
 
+    // DEFAULT(column) elsewhere in a write must also resolve to the pinned 
write-default (35); the read
+    // schema carries none. Rows 20-22 come from VALUES and INSERT ... SELECT 
with and without FROM, then the
+    // UPDATE and MERGE predicates must match all of them. A plain SELECT 
writes nothing and is rejected.
+    tableNames.each { String format, String currentTable ->
+        sql """INSERT INTO ${currentTable} (id, default_int) VALUES (20, 
DEFAULT(default_int))"""
+        sql """
+            INSERT INTO ${currentTable} (id, default_int)
+            SELECT 21, DEFAULT(default_int) FROM ${currentTable} WHERE id = 7
+        """
+        sql """INSERT INTO ${currentTable} (id, default_int) SELECT 22, 
DEFAULT(default_int)"""
+        sql """
+            UPDATE ${currentTable}
+            SET default_string = 'update-where-default'
+            WHERE id IN (20, 21, 22) AND default_int = DEFAULT(default_int)
+        """
+        sql """
+            MERGE INTO ${currentTable} t
+            USING (SELECT 22 AS id) s
+            ON t.id = s.id AND t.default_int = DEFAULT(t.default_int)
+            WHEN MATCHED THEN UPDATE SET default_string = 'merge-on-default'
+        """
+        "order_qt_${format}_default_references" """
+            SELECT id, default_int, default_string
+            FROM ${currentTable}
+            WHERE id IN (20, 21, 22)
+            ORDER BY id
+        """
+    }
+    test {
+        sql """SELECT DEFAULT(default_int) FROM ${parquetTable}"""
+        exception "must reference a column of the table that this statement 
writes"
+    }
+    // A DELETE writes no row values, so the write-default must not decide 
which rows it deletes: rows 20-22
+    // hold 35 and would all match.
+    test {
+        sql """DELETE FROM ${parquetTable} WHERE id IN (20, 21, 22) AND 
default_int = DEFAULT(default_int)"""
+        exception "cannot be used in a DELETE"
+    }
+
     // Pin a branch while default_int still has write-default 35, then evolve 
the table-current
     // schema to 36. Iceberg selects the branch as the parent snapshot but 
writes the new snapshot
     // with the table-current schema, so VALUES(DEFAULT) normalization and the 
writer must both use


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to