Copilot commented on code in PR #4367:
URL: https://github.com/apache/flink-cdc/pull/4367#discussion_r3055245469


##########
flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/main/java/org/apache/flink/cdc/connectors/postgres/source/PostgresDialect.java:
##########
@@ -196,13 +200,64 @@ public Map<TableId, TableChange> 
discoverDataCollectionSchemas(JdbcSourceConfig
         try (JdbcConnection jdbc = openJdbcConnection(sourceConfig)) {
             // fetch table schemas
             Map<TableId, TableChange> tableSchemas = queryTableSchema(jdbc, 
capturedTableIds);
+            // validate REPLICA IDENTITY for tables without primary key
+            validateReplicaIdentityForNoPkTables(jdbc, tableSchemas);
             return tableSchemas;

Review Comment:
   `discoverDataCollectionSchemas` always calls 
`validateReplicaIdentityForNoPkTables(...)`. If REPLICA IDENTITY FULL is only 
required for incremental snapshot mode, this should be gated by the 
corresponding config; otherwise the validation/error message should be updated 
to avoid implying this is incremental-snapshot-only.



##########
flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/main/java/org/apache/flink/cdc/connectors/postgres/source/PostgresDialect.java:
##########
@@ -196,13 +200,64 @@ public Map<TableId, TableChange> 
discoverDataCollectionSchemas(JdbcSourceConfig
         try (JdbcConnection jdbc = openJdbcConnection(sourceConfig)) {
             // fetch table schemas
             Map<TableId, TableChange> tableSchemas = queryTableSchema(jdbc, 
capturedTableIds);
+            // validate REPLICA IDENTITY for tables without primary key
+            validateReplicaIdentityForNoPkTables(jdbc, tableSchemas);
             return tableSchemas;
         } catch (Exception e) {
             throw new FlinkRuntimeException(
                     "Error to discover table schemas: " + e.getMessage(), e);
         }
     }
 
+    /**
+     * Validates that tables without primary key have REPLICA IDENTITY FULL 
set. This is required
+     * for PostgreSQL to include the full before image in WAL for UPDATE and 
DELETE events.
+     */
+    private void validateReplicaIdentityForNoPkTables(
+            JdbcConnection jdbc, Map<TableId, TableChange> tableSchemas) 
throws SQLException {
+        for (Map.Entry<TableId, TableChange> entry : tableSchemas.entrySet()) {
+            TableId tableId = entry.getKey();
+            Table table = entry.getValue().getTable();
+            if (table.primaryKeyColumnNames().isEmpty()) {
+                String replicaIdentity = queryReplicaIdentity(jdbc, tableId);
+                if (!"f".equalsIgnoreCase(replicaIdentity)) {
+                    throw new FlinkRuntimeException(
+                            String.format(
+                                    "Table '%s.%s' has no primary key. "
+                                            + "To use incremental snapshot for 
tables without primary key, "
+                                            + "REPLICA IDENTITY FULL must be 
set. "
+                                            + "Please execute: ALTER TABLE 
%s.%s REPLICA IDENTITY FULL",
+                                    tableId.schema(),
+                                    tableId.table(),
+                                    tableId.schema(),
+                                    tableId.table()));
+                }
+                LOG.info(
+                        "Table '{}.{}' has no primary key but has REPLICA 
IDENTITY FULL set.",
+                        tableId.schema(),
+                        tableId.table());
+            }
+        }
+    }
+
+    private String queryReplicaIdentity(JdbcConnection jdbc, TableId tableId) 
throws SQLException {
+        String query =
+                String.format(
+                        "SELECT relreplident FROM pg_class c "
+                                + "JOIN pg_namespace n ON c.relnamespace = 
n.oid "
+                                + "WHERE n.nspname = '%s' AND c.relname = 
'%s'",
+                        tableId.schema(), tableId.table());
+        final String[] result = new String[1];
+        jdbc.query(
+                query,
+                rs -> {
+                    if (rs.next()) {
+                        result[0] = rs.getString(1);
+                    }
+                });

Review Comment:
   `queryReplicaIdentity(...)` returns `null` when the table isn't found; the 
caller then throws the REPLICA IDENTITY FULL exception, which becomes 
misleading. Consider explicitly handling the "no row returned" case (e.g., 
throw a dedicated exception indicating the table lookup failed).
   ```suggestion
                   });
           if (result[0] == null) {
               throw new FlinkRuntimeException(
                       String.format(
                               "Failed to query replica identity for table 
'%s.%s': table was not found.",
                               tableId.schema(), tableId.table()));
           }
   ```



##########
flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/main/java/org/apache/flink/cdc/connectors/postgres/source/PostgresDialect.java:
##########
@@ -196,13 +200,64 @@ public Map<TableId, TableChange> 
discoverDataCollectionSchemas(JdbcSourceConfig
         try (JdbcConnection jdbc = openJdbcConnection(sourceConfig)) {
             // fetch table schemas
             Map<TableId, TableChange> tableSchemas = queryTableSchema(jdbc, 
capturedTableIds);
+            // validate REPLICA IDENTITY for tables without primary key
+            validateReplicaIdentityForNoPkTables(jdbc, tableSchemas);
             return tableSchemas;
         } catch (Exception e) {
             throw new FlinkRuntimeException(
                     "Error to discover table schemas: " + e.getMessage(), e);
         }
     }
 
+    /**
+     * Validates that tables without primary key have REPLICA IDENTITY FULL 
set. This is required
+     * for PostgreSQL to include the full before image in WAL for UPDATE and 
DELETE events.
+     */
+    private void validateReplicaIdentityForNoPkTables(
+            JdbcConnection jdbc, Map<TableId, TableChange> tableSchemas) 
throws SQLException {
+        for (Map.Entry<TableId, TableChange> entry : tableSchemas.entrySet()) {
+            TableId tableId = entry.getKey();
+            Table table = entry.getValue().getTable();
+            if (table.primaryKeyColumnNames().isEmpty()) {
+                String replicaIdentity = queryReplicaIdentity(jdbc, tableId);
+                if (!"f".equalsIgnoreCase(replicaIdentity)) {
+                    throw new FlinkRuntimeException(
+                            String.format(
+                                    "Table '%s.%s' has no primary key. "
+                                            + "To use incremental snapshot for 
tables without primary key, "
+                                            + "REPLICA IDENTITY FULL must be 
set. "
+                                            + "Please execute: ALTER TABLE 
%s.%s REPLICA IDENTITY FULL",
+                                    tableId.schema(),
+                                    tableId.table(),
+                                    tableId.schema(),
+                                    tableId.table()));
+                }
+                LOG.info(
+                        "Table '{}.{}' has no primary key but has REPLICA 
IDENTITY FULL set.",
+                        tableId.schema(),
+                        tableId.table());
+            }
+        }
+    }
+
+    private String queryReplicaIdentity(JdbcConnection jdbc, TableId tableId) 
throws SQLException {
+        String query =
+                String.format(
+                        "SELECT relreplident FROM pg_class c "
+                                + "JOIN pg_namespace n ON c.relnamespace = 
n.oid "
+                                + "WHERE n.nspname = '%s' AND c.relname = 
'%s'",
+                        tableId.schema(), tableId.table());

Review Comment:
   `queryReplicaIdentity(...)` builds SQL by string interpolation of 
schema/table names. This can break for identifiers containing 
quotes/case-sensitive names and is also vulnerable to SQL injection if table 
identifiers come from user configuration; prefer a parameterized query and/or 
proper identifier quoting.



##########
flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/test/resources/ddl/inventory_no_pk.sql:
##########
@@ -0,0 +1,36 @@
+-- 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.
+
+SET search_path TO inventory;
+
+-- Create table without primary key for no-PK table tests
+CREATE TABLE products_no_pk (
+  id INTEGER NOT NULL,
+  name VARCHAR(255) NOT NULL DEFAULT 'flink',
+  description VARCHAR(512),
+  weight FLOAT
+);
+ALTER TABLE products_no_pk REPLICA IDENTITY FULL;
+
+INSERT INTO products_no_pk
+VALUES (101,'scooter','Small 2-wheel scooter',3.14),
+       (102,'car battery','12V car battery',8.1),
+       (103,'12-pack drill bits','12-pack of drill bits with sizes ranging 
from #40 to #3',0.8),
+       (104,'hammer','12oz carpenter''s hammer',0.75),
+       (105,'hammer','14oz carpenter''s hammer',0.875),
+       (106,'hammer','16oz carpenter''s hammer',1.0),
+       (107,'rocks','box of assorted rocks',5.3),
+       (108,'jacket','water resistent black wind breaker',0.1),

Review Comment:
   Spelling: "water resistent" should be "water resistant" in test data to 
avoid propagating typos into expected results/examples.
   ```suggestion
          (108,'jacket','water resistant black wind breaker',0.1),
   ```



##########
flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base/src/main/java/org/apache/flink/cdc/connectors/base/source/reader/external/JdbcSourceFetchTaskContext.java:
##########
@@ -49,6 +51,8 @@
 @Internal
 public abstract class JdbcSourceFetchTaskContext implements FetchTask.Context {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(JdbcSourceFetchTaskContext.class);
+

Review Comment:
   `LOG` is declared but never used in this class; please remove it (or use it) 
to avoid unused-field/static-analysis violations.



##########
flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/test/java/org/apache/flink/cdc/connectors/postgres/table/PostgreSQLConnectorITCase.java:
##########
@@ -1160,4 +1160,148 @@ void testUniqueIndexIncludingFunction(boolean 
parallelismSnapshot) throws Except
         tableResult.getJobClient().get().cancel().get();
         RowUtils.USE_LEGACY_TO_STRING = true;
     }
+
+    @Test
+    void testNoPKTableWithChunkKey() throws SQLException, ExecutionException, 
InterruptedException {
+        setup(true);
+        initializePostgresTable(POSTGRES_CONTAINER, "inventory");
+        initializePostgresTable(POSTGRES_CONTAINER, "inventory_no_pk");
+        String sourceDDL =
+                String.format(
+                        "CREATE TABLE no_pk_source ("
+                                + " id INT NOT NULL,"
+                                + " name STRING,"
+                                + " description STRING,"
+                                + " weight DECIMAL(10,3)"
+                                + ") WITH ("
+                                + " 'connector' = 'postgres-cdc',"
+                                + " 'hostname' = '%s',"
+                                + " 'port' = '%s',"
+                                + " 'username' = '%s',"
+                                + " 'password' = '%s',"
+                                + " 'database-name' = '%s',"
+                                + " 'schema-name' = '%s',"
+                                + " 'table-name' = '%s',"
+                                + " 'scan.incremental.snapshot.enabled' = 
'true',"
+                                + " 
'scan.incremental.snapshot.chunk.key-column' = 'id',"
+                                + " 'scan.incremental.snapshot.chunk.size' = 
'4',"
+                                + " 'decoding.plugin.name' = 'pgoutput', "
+                                + " 'slot.name' = '%s'"
+                                + ")",
+                        POSTGRES_CONTAINER.getHost(),
+                        POSTGRES_CONTAINER.getMappedPort(POSTGRESQL_PORT),
+                        POSTGRES_CONTAINER.getUsername(),
+                        POSTGRES_CONTAINER.getPassword(),
+                        POSTGRES_CONTAINER.getDatabaseName(),
+                        "inventory",
+                        "products_no_pk",
+                        getSlotName());
+        String sinkDDL =
+                "CREATE TABLE sink ("
+                        + " name STRING,"
+                        + " weightSum DECIMAL(10,3),"
+                        + " PRIMARY KEY (name) NOT ENFORCED"
+                        + ") WITH ("
+                        + " 'connector' = 'values',"
+                        + " 'sink-insert-only' = 'false'"
+                        + ")";
+        tEnv.executeSql(sourceDDL);
+        tEnv.executeSql(sinkDDL);
+
+        // async submit job
+        TableResult result =
+                tEnv.executeSql(
+                        "INSERT INTO sink SELECT name, SUM(weight) FROM 
no_pk_source GROUP BY name");
+
+        waitForSnapshotStarted("sink");
+
+        // wait a bit to make sure the replication slot is ready
+        Thread.sleep(5000);
+
+        // generate WAL
+        try (Connection connection = getJdbcConnection(POSTGRES_CONTAINER);
+                Statement statement = connection.createStatement()) {
+            statement.execute(
+                    "UPDATE inventory.products_no_pk SET description='18oz 
carpenter hammer' WHERE id=106;");
+            statement.execute("UPDATE inventory.products_no_pk SET 
weight='5.1' WHERE id=107;");
+            statement.execute(
+                    "INSERT INTO inventory.products_no_pk VALUES 
(110,'jacket','water resistent white wind breaker',0.2);");
+            statement.execute(
+                    "INSERT INTO inventory.products_no_pk VALUES 
(111,'scooter','Big 2-wheel scooter',5.18);");
+            statement.execute(
+                    "UPDATE inventory.products_no_pk SET description='new 
water resistent white wind breaker', weight='0.5' WHERE id=110;");

Review Comment:
   Spelling: "water resistent" should be "water resistant" in the UPDATE 
statement string to keep test data clean/consistent.
   ```suggestion
                       "INSERT INTO inventory.products_no_pk VALUES 
(110,'jacket','water resistant white wind breaker',0.2);");
               statement.execute(
                       "INSERT INTO inventory.products_no_pk VALUES 
(111,'scooter','Big 2-wheel scooter',5.18);");
               statement.execute(
                       "UPDATE inventory.products_no_pk SET description='new 
water resistant white wind breaker', weight='0.5' WHERE id=110;");
   ```



##########
flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base/src/main/java/org/apache/flink/cdc/connectors/base/utils/SplitKeyUtils.java:
##########
@@ -237,7 +238,29 @@ public static Object[] getSplitKey(
             RowType splitBoundaryType, SourceRecord dataRecord, 
SchemaNameAdjuster nameAdjuster) {
         // the split key field contains single field now
         String splitFieldName = 
nameAdjuster.adjust(splitBoundaryType.getFieldNames().get(0));
-        Struct key = (Struct) dataRecord.key();
-        return new Object[] {key.get(splitFieldName)};
+        Struct target;
+        if (dataRecord.key() != null) {
+            target = (Struct) dataRecord.key();
+        } else {
+            // For tables without primary key, extract chunk key from value 
struct
+            target = getStructContainingChunkKey(dataRecord);
+        }
+        return new Object[] {target.get(splitFieldName)};
+    }
+
+    /**
+     * For tables without primary key, the chunk key is not in the record key 
(which is null).
+     * Instead, extract it from the value's after struct (for CREATE/READ) or 
before struct (for
+     * UPDATE/DELETE).
+     */
+    public static Struct getStructContainingChunkKey(SourceRecord record) {
+        Struct value = (Struct) record.value();
+        Envelope.Operation op =
+                
Envelope.Operation.forCode(value.getString(Envelope.FieldName.OPERATION));
+        if (op == Envelope.Operation.CREATE || op == Envelope.Operation.READ) {
+            return value.getStruct(Envelope.FieldName.AFTER);
+        } else {
+            return value.getStruct(Envelope.FieldName.BEFORE);
+        }

Review Comment:
   `getStructContainingChunkKey(...)` is only used inside `SplitKeyUtils`; 
consider making it `private` to avoid expanding the public API surface 
unnecessarily.



##########
flink-cdc-connect/flink-cdc-source-connectors/flink-connector-postgres-cdc/src/test/java/org/apache/flink/cdc/connectors/postgres/table/PostgreSQLConnectorITCase.java:
##########
@@ -1160,4 +1160,148 @@ void testUniqueIndexIncludingFunction(boolean 
parallelismSnapshot) throws Except
         tableResult.getJobClient().get().cancel().get();
         RowUtils.USE_LEGACY_TO_STRING = true;
     }
+
+    @Test
+    void testNoPKTableWithChunkKey() throws SQLException, ExecutionException, 
InterruptedException {
+        setup(true);
+        initializePostgresTable(POSTGRES_CONTAINER, "inventory");
+        initializePostgresTable(POSTGRES_CONTAINER, "inventory_no_pk");
+        String sourceDDL =
+                String.format(
+                        "CREATE TABLE no_pk_source ("
+                                + " id INT NOT NULL,"
+                                + " name STRING,"
+                                + " description STRING,"
+                                + " weight DECIMAL(10,3)"
+                                + ") WITH ("
+                                + " 'connector' = 'postgres-cdc',"
+                                + " 'hostname' = '%s',"
+                                + " 'port' = '%s',"
+                                + " 'username' = '%s',"
+                                + " 'password' = '%s',"
+                                + " 'database-name' = '%s',"
+                                + " 'schema-name' = '%s',"
+                                + " 'table-name' = '%s',"
+                                + " 'scan.incremental.snapshot.enabled' = 
'true',"
+                                + " 
'scan.incremental.snapshot.chunk.key-column' = 'id',"
+                                + " 'scan.incremental.snapshot.chunk.size' = 
'4',"
+                                + " 'decoding.plugin.name' = 'pgoutput', "
+                                + " 'slot.name' = '%s'"
+                                + ")",
+                        POSTGRES_CONTAINER.getHost(),
+                        POSTGRES_CONTAINER.getMappedPort(POSTGRESQL_PORT),
+                        POSTGRES_CONTAINER.getUsername(),
+                        POSTGRES_CONTAINER.getPassword(),
+                        POSTGRES_CONTAINER.getDatabaseName(),
+                        "inventory",
+                        "products_no_pk",
+                        getSlotName());
+        String sinkDDL =
+                "CREATE TABLE sink ("
+                        + " name STRING,"
+                        + " weightSum DECIMAL(10,3),"
+                        + " PRIMARY KEY (name) NOT ENFORCED"
+                        + ") WITH ("
+                        + " 'connector' = 'values',"
+                        + " 'sink-insert-only' = 'false'"
+                        + ")";
+        tEnv.executeSql(sourceDDL);
+        tEnv.executeSql(sinkDDL);
+
+        // async submit job
+        TableResult result =
+                tEnv.executeSql(
+                        "INSERT INTO sink SELECT name, SUM(weight) FROM 
no_pk_source GROUP BY name");
+
+        waitForSnapshotStarted("sink");
+
+        // wait a bit to make sure the replication slot is ready
+        Thread.sleep(5000);
+
+        // generate WAL
+        try (Connection connection = getJdbcConnection(POSTGRES_CONTAINER);
+                Statement statement = connection.createStatement()) {
+            statement.execute(
+                    "UPDATE inventory.products_no_pk SET description='18oz 
carpenter hammer' WHERE id=106;");
+            statement.execute("UPDATE inventory.products_no_pk SET 
weight='5.1' WHERE id=107;");
+            statement.execute(
+                    "INSERT INTO inventory.products_no_pk VALUES 
(110,'jacket','water resistent white wind breaker',0.2);");
+            statement.execute(
+                    "INSERT INTO inventory.products_no_pk VALUES 
(111,'scooter','Big 2-wheel scooter',5.18);");
+            statement.execute(
+                    "UPDATE inventory.products_no_pk SET description='new 
water resistent white wind breaker', weight='0.5' WHERE id=110;");

Review Comment:
   Spelling: "water resistent" should be "water resistant" in the inserted test 
row (and adjust the subsequent UPDATE string accordingly).
   ```suggestion
                       "INSERT INTO inventory.products_no_pk VALUES 
(110,'jacket','water resistant white wind breaker',0.2);");
               statement.execute(
                       "INSERT INTO inventory.products_no_pk VALUES 
(111,'scooter','Big 2-wheel scooter',5.18);");
               statement.execute(
                       "UPDATE inventory.products_no_pk SET description='new 
water resistant white wind breaker', weight='0.5' WHERE id=110;");
   ```



-- 
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]

Reply via email to