laskoviymishka commented on code in PR #17653:
URL: https://github.com/apache/iceberg/pull/17653#discussion_r3986975601


##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/IcebergSinkConfig.java:
##########
@@ -179,6 +181,12 @@ private static ConfigDef newConfigDef() {
         false,
         Importance.MEDIUM,
         "Set to true to add any missing record fields to the table schema, 
false otherwise");
+    configDef.define(
+        TABLES_REPLACE_NULL_WITH_DEFAULT_PROP,
+        ConfigDef.Type.BOOLEAN,
+        true,

Review Comment:
   Worth settling the default before this ships, since the key becomes a public 
contract. Defaulting to `true` keeps today's behavior — an explicit null 
silently written as the column default — for everyone who doesn't read the 
release notes and opt out, which is the behavior this PR frames as the bug. A 
`false` default with a one-line migration note would land users on the correct 
semantics by default; the catch is the required-column failure above, so the 
two decisions are linked.
   
   I lean toward keeping `true` for back-compat and documenting loudly, but I'd 
like this to be a deliberate call rather than a default-by-omission. wdyt?



##########
docs/docs/kafka-connect.md:
##########
@@ -71,6 +71,7 @@ for exactly-once semantics. This requires Kafka 2.5 or later.
 | iceberg.tables.evolve-schema-enabled       | Set to `true` to add any 
missing record fields to the table schema, default is `false`                   
        |
 | iceberg.tables.schema-force-optional       | Set to `true` to set columns as 
optional during table create and evolution, default is `false` to respect 
schema |
 | iceberg.tables.schema-case-insensitive     | Set to `true` to look up table 
columns by case-insensitive name, default is `false` for case-sensitive         
  |
+| iceberg.tables.replace-null-with-default   | Set to `false` to preserve 
explicit null values instead of replacing them with the record schema default 
value, default is `true` |

Review Comment:
   One thing missing from the docs: with `replace-null-with-default=false`, an 
explicit null flowing into a required Iceberg column doesn't get rescued by the 
table's write-default (that only fills fields omitted from the write, not 
explicit nulls), so the null reaches the Parquet/ORC writer and fails with an 
opaque internal error. That's exactly the common auto-created-table + Debezium 
CDC case.
   
   I'd add a note that required columns can't take explicit nulls under this 
setting, and point at `iceberg.tables.schema-force-optional=true` (or 
pre-altering the columns) as the mitigation. A `LOG.warn` when a null lands on 
a required field would make the failure diagnosable too, though that part's 
optional.



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/RecordConverter.java:
##########
@@ -259,7 +259,7 @@ private GenericRecord convertToStruct(
                     hasSchemaUpdates = true;
                   }
                 }
-                Object recordFieldValue = struct.get(recordField);
+                Object recordFieldValue = fieldValue(struct, recordField);

Review Comment:
   I think there's a subtle issue here once `replace-null-with-default=false`.
   
   Before this change, `struct.get(recordField)` returned the Connect schema 
default for a stored-null field, so `recordFieldValue` was non-null and the 
null-gated branch on the next line never fired. Now `getWithoutDefault` hands 
back the real null, so an explicit null on a field that carries a schema 
default drops straight into `evolveSchemaFromConnectSchema` whenever a 
schema-update consumer is present. Scalars no-op, but a nullable struct-typed 
field recurses and can emit addColumn/makeOptional off a value that isn't 
actually there — spurious, and those schema mutations aren't reversible.
   
   None of the current tests exercise this: `testReplaceNullWithDefault` 
doesn't pass a `SchemaUpdate.Consumer`, so line 263 is never hit with the new 
path. I'd add a case that enables both `evolve-schema-enabled` and 
`replace-null-with-default=false` with a nullable struct column and asserts no 
evolution events fire. Either the branch needs a guard for the preserve-null 
case, or we confirm the recursion is genuinely a no-op and lock it down with 
that test. wdyt?



##########
kafka-connect/kafka-connect-transforms/src/main/java/org/apache/iceberg/connect/transforms/CopyValue.java:
##########
@@ -90,9 +90,10 @@ private R applyWithSchema(R record) {
     Struct updatedValue = new Struct(updatedSchema);
 
     for (Field field : value.schema().fields()) {
-      updatedValue.put(field.name(), value.get(field));
+      // getWithoutDefault so an explicit null is not replaced by the schema 
default value
+      updatedValue.put(field.name(), value.getWithoutDefault(field.name()));

Review Comment:
   The SMT switch is unconditional — unlike the sink path there's no 
`replace-null-with-default` knob here, so anyone using these public transforms 
(CopyValue, DebeziumTransform, KafkaMetadataTransform) downstream of a 
non-Iceberg consumer gets a silent behavioral change: a stored null that used 
to surface as the schema default now propagates as raw null, with no way to 
restore the old behavior.
   
   That's defensible as the correct semantics, but it's a public-API change 
with no migration note. I'd at least call out in kafka-connect.md that the 
bundled SMTs always preserve explicit nulls regardless of the sink option. 
While we're here, `MongoDebeziumTransform` still uses `get(...)` and wasn't 
updated — worth the same treatment so the SMTs stay consistent.



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/IcebergSinkConfig.java:
##########
@@ -179,6 +181,12 @@ private static ConfigDef newConfigDef() {
         false,
         Importance.MEDIUM,
         "Set to true to add any missing record fields to the table schema, 
false otherwise");
+    configDef.define(
+        TABLES_REPLACE_NULL_WITH_DEFAULT_PROP,
+        ConfigDef.Type.BOOLEAN,
+        true,
+        Importance.MEDIUM,
+        "Set to false to preserve explicit null values instead of replacing 
them with the record schema default value, true otherwise");

Review Comment:
   Every other boolean config here leads with the `true` case; this one leads 
with `false`, which reads as a double negative. Minor, but for consistency:
   
   ```suggestion
           "Set to true to replace null struct field values with the record 
schema default value, false to preserve explicit nulls (default is true)");
   ```



##########
docs/docs/kafka-connect.md:
##########
@@ -94,6 +95,11 @@ If `iceberg.tables.dynamic-enabled` is `false` (the default) 
then you must speci
 `iceberg.tables.dynamic-enabled` is `true` then you must specify 
`iceberg.tables.route-field` which will
 contain the name of the table.
 
+When `iceberg.tables.replace-null-with-default` is set to `false`, a record 
whose route field is

Review Comment:
   Two small things on this paragraph. It only describes the route-field side 
effect, so a reader scanning the prose (not the table) might think the option 
is purely about routing — I'd open with a sentence on the general purpose 
(preserving explicit nulls end-to-end) before the routing caveat.
   
   And on the JSON converter note: worth saying its `replace.null.with.default` 
has defaulted to `true` since Kafka 3.6.0, so the substitution already happens 
at the converter layer by default — setting only the sink option leaves the new 
behavior silently ineffective.



##########
kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/TestSinkWriter.java:
##########
@@ -155,6 +159,51 @@ public void testDynamicRoute() {
     
assertThat(writerResult.tableReference().identifier()).isEqualTo(TABLE_IDENTIFIER);
   }
 
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testDynamicRouteReplaceNullWithDefault(boolean 
replaceNullWithDefault) {

Review Comment:
   This covers the dynamic route path, but `extractRouteValue` is also called 
from `routeRecordStatically`, and that path isn't tested with the new config 
plumbed through. I'd add a static-route counterpart so we know the config 
actually reaches both.



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/RecordConverter.java:
##########
@@ -362,6 +362,18 @@ private void logMismatchedType(
         "Record schema of type {} does not match table of type {}", 
recordSchemaType, tableType);
   }
 
+  /**
+   * Reads a struct field value. {@link Struct#get(Field)} substitutes the 
schema default value when
+   * the stored value is null, which turns an explicit null into the default; 
whether that
+   * substitution happens is controlled by the {@code 
iceberg.tables.replace-null-with-default}
+   * setting.
+   */
+  private Object fieldValue(Struct struct, Field field) {
+    return config.replaceNullWithDefault()

Review Comment:
   Non-blocker: this is the same abstraction as 
`RecordUtils.fieldValueFromStruct` under a different name — I'd match them 
(`fieldValue` → `fieldValueFromStruct`) so the two read as the pair they are. 
Also `config.replaceNullWithDefault()` does a map lookup per field on every 
struct and variant recursion; the value is fixed for the converter's lifetime, 
so caching it in a `final boolean` in the constructor would keep the hot path 
clean.



##########
kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/TestRecordUtils.java:
##########
@@ -61,17 +74,37 @@ public void testExtractFromRecordValueStructNull() {
     Schema valSchema = SchemaBuilder.struct().field("key", 
Schema.INT64_SCHEMA).build();
     Struct val = new Struct(valSchema).put("key", 123L);
 
-    Object result = RecordUtils.extractFromRecordValue(val, "");
+    Object result = RecordUtils.extractFromRecordValue(val, "", config);
     assertThat(result).isNull();
 
-    result = RecordUtils.extractFromRecordValue(val, "xkey");
+    result = RecordUtils.extractFromRecordValue(val, "xkey", config);
     assertThat(result).isNull();
   }
 
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testExtractFromRecordValueStructReplaceNullWithDefault(

Review Comment:
   This only exercises a flat single-field struct, but `valueFromStruct` walks 
dotted paths (`data.id.key`). I'd add a two-level case where the leaf field has 
a schema default and a stored null, so the preserve-null behavior is covered 
through the nested walk and not just the top level.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to