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

Croway pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 13dc237f18b5 CAMEL-24044: camel-sql - Fix Postgres aggregation 
repositories binding version without a placeholder
13dc237f18b5 is described below

commit 13dc237f18b5ed99b03803a4417a9afe08ef2528
Author: croway <[email protected]>
AuthorDate: Mon Jul 13 11:38:56 2026 +0200

    CAMEL-24044: camel-sql - Fix Postgres aggregation repositories binding 
version without a placeholder
---
 components/camel-sql/pom.xml                       |  13 ++
 .../camel-sql/src/main/docs/sql-component.adoc     |   2 +-
 .../ClusteredPostgresAggregationRepository.java    |  14 +-
 .../jdbc/PostgresAggregationRepository.java        |   9 +-
 .../ClusteredPostgresAggregationRepositoryIT.java  | 135 ++++++++++++++++++
 .../jdbc/PostgresAggregationRepositoryIT.java      | 156 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc    |  14 ++
 7 files changed, 334 insertions(+), 9 deletions(-)

diff --git a/components/camel-sql/pom.xml b/components/camel-sql/pom.xml
index 2779857598e9..c64112851391 100644
--- a/components/camel-sql/pom.xml
+++ b/components/camel-sql/pom.xml
@@ -94,6 +94,19 @@
             <version>${mariadb-version}</version>
             <scope>test</scope>
         </dependency>
+        <!-- PostgreSQL only used for the aggregation repository integration 
tests -->
+        <dependency>
+            <groupId>org.postgresql</groupId>
+            <artifactId>postgresql</artifactId>
+            <version>${pgjdbc-driver-version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-test-infra-postgres</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
diff --git a/components/camel-sql/src/main/docs/sql-component.adoc 
b/components/camel-sql/src/main/docs/sql-component.adoc
index 5e61e31b8923..aa5d01253367 100644
--- a/components/camel-sql/src/main/docs/sql-component.adoc
+++ b/components/camel-sql/src/main/docs/sql-component.adoc
@@ -1040,7 +1040,7 @@ uses special `INSERT .. ON CONFLICT ..` statement to 
provide optimistic locking
 This statement is (with default aggregation table definition):
 [source,sql]
 ----
-INSERT INTO aggregation (id, exchange) values (?, ?) ON CONFLICT DO NOTHING
+INSERT INTO aggregation (exchange, id, version) VALUES (?, ?, ?) ON CONFLICT 
DO NOTHING
 ----
 
 Details can be found https://www.postgresql.org/docs/9.5/sql-insert.html[in 
PostgreSQL documentation].
diff --git 
a/components/camel-sql/src/main/java/org/apache/camel/processor/aggregate/jdbc/ClusteredPostgresAggregationRepository.java
 
b/components/camel-sql/src/main/java/org/apache/camel/processor/aggregate/jdbc/ClusteredPostgresAggregationRepository.java
index 1ee8289b4205..71e01e071c19 100644
--- 
a/components/camel-sql/src/main/java/org/apache/camel/processor/aggregate/jdbc/ClusteredPostgresAggregationRepository.java
+++ 
b/components/camel-sql/src/main/java/org/apache/camel/processor/aggregate/jdbc/ClusteredPostgresAggregationRepository.java
@@ -56,13 +56,14 @@ public class ClusteredPostgresAggregationRepository extends 
ClusteredJdbcAggrega
             final CamelContext camelContext, final String correlationId, final 
Exchange exchange, String repositoryName,
             final Long version, final boolean completed)
             throws Exception {
-        // The default totalParameterIndex is 2 for ID and Exchange. Depending 
on logic this will be increased
-        int totalParameterIndex = 2;
+        // The default totalParameterIndex is 3 for ID, Exchange and version. 
Depending on logic this will be increased
+        int totalParameterIndex = 3;
         StringBuilder queryBuilder = new StringBuilder(256)
                 .append("INSERT INTO ").append(repositoryName)
                 .append('(')
                 .append(EXCHANGE).append(", ")
-                .append(ID);
+                .append(ID).append(", ")
+                .append(VERSION);
 
         if (isStoreBodyAsText()) {
             queryBuilder.append(", ").append(BODY);
@@ -76,6 +77,11 @@ public class ClusteredPostgresAggregationRepository extends 
ClusteredJdbcAggrega
             }
         }
 
+        if (completed && isRecoveryByInstance()) {
+            queryBuilder.append(", ").append("instance_id");
+            totalParameterIndex++;
+        }
+
         queryBuilder.append(") VALUES (");
 
         queryBuilder.append("?, ".repeat(Math.max(0, totalParameterIndex - 
1)));
@@ -85,7 +91,7 @@ public class ClusteredPostgresAggregationRepository extends 
ClusteredJdbcAggrega
 
         String sql = queryBuilder.toString();
 
-        int updateCount = insertHelper(camelContext, correlationId, exchange, 
sql, 1L, completed);
+        int updateCount = insertHelper(camelContext, correlationId, exchange, 
sql, version, completed);
         if (updateCount == 0 && getRepositoryName().equals(repositoryName)) {
             throw new DataIntegrityViolationException("No row was inserted due 
to data violation");
         }
diff --git 
a/components/camel-sql/src/main/java/org/apache/camel/processor/aggregate/jdbc/PostgresAggregationRepository.java
 
b/components/camel-sql/src/main/java/org/apache/camel/processor/aggregate/jdbc/PostgresAggregationRepository.java
index 4970877f0846..1f876e45f035 100644
--- 
a/components/camel-sql/src/main/java/org/apache/camel/processor/aggregate/jdbc/PostgresAggregationRepository.java
+++ 
b/components/camel-sql/src/main/java/org/apache/camel/processor/aggregate/jdbc/PostgresAggregationRepository.java
@@ -56,13 +56,14 @@ public class PostgresAggregationRepository extends 
JdbcAggregationRepository {
             final CamelContext camelContext, final String correlationId, final 
Exchange exchange, String repositoryName,
             final Long version)
             throws Exception {
-        // The default totalParameterIndex is 2 for ID and Exchange. Depending 
on logic this will be increased
-        int totalParameterIndex = 2;
+        // The default totalParameterIndex is 3 for ID, Exchange and version. 
Depending on logic this will be increased
+        int totalParameterIndex = 3;
         StringBuilder queryBuilder = new StringBuilder(256)
                 .append("INSERT INTO ").append(repositoryName)
                 .append('(')
                 .append(EXCHANGE).append(", ")
-                .append(ID);
+                .append(ID).append(", ")
+                .append(VERSION);
 
         if (isStoreBodyAsText()) {
             queryBuilder.append(", ").append(BODY);
@@ -85,7 +86,7 @@ public class PostgresAggregationRepository extends 
JdbcAggregationRepository {
 
         String sql = queryBuilder.toString();
 
-        int updateCount = insertHelper(camelContext, correlationId, exchange, 
sql, 1L);
+        int updateCount = insertHelper(camelContext, correlationId, exchange, 
sql, version);
         if (updateCount == 0 && getRepositoryName().equals(repositoryName)) {
             throw new DataIntegrityViolationException("No row was inserted due 
to data violation");
         }
diff --git 
a/components/camel-sql/src/test/java/org/apache/camel/processor/aggregate/jdbc/ClusteredPostgresAggregationRepositoryIT.java
 
b/components/camel-sql/src/test/java/org/apache/camel/processor/aggregate/jdbc/ClusteredPostgresAggregationRepositoryIT.java
new file mode 100644
index 000000000000..12520c98bce9
--- /dev/null
+++ 
b/components/camel-sql/src/test/java/org/apache/camel/processor/aggregate/jdbc/ClusteredPostgresAggregationRepositoryIT.java
@@ -0,0 +1,135 @@
+/*
+ * 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.camel.processor.aggregate.jdbc;
+
+import java.util.Set;
+
+import javax.sql.DataSource;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.service.ServiceHelper;
+import org.apache.camel.test.infra.postgres.services.PostgresService;
+import org.apache.camel.test.infra.postgres.services.PostgresServiceFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.postgresql.ds.PGSimpleDataSource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies {@link ClusteredPostgresAggregationRepository} against a real 
PostgreSQL database, including instance-scoped
+ * recovery where each cluster node must only recover the exchanges it 
completed itself.
+ */
+public class ClusteredPostgresAggregationRepositoryIT {
+
+    private static final String REPO_NAME = "camel_clu_agg_it";
+
+    @RegisterExtension
+    public static PostgresService service = 
PostgresServiceFactory.createService();
+
+    private CamelContext context;
+    private DataSource dataSource;
+    private JdbcTemplate jdbcTemplate;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        PGSimpleDataSource ds = new PGSimpleDataSource();
+        ds.setUrl(service.jdbcUrl());
+        ds.setUser(service.userName());
+        ds.setPassword(service.password());
+        dataSource = ds;
+
+        jdbcTemplate = new JdbcTemplate(dataSource);
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS " + REPO_NAME
+                             + " (id VARCHAR(255) NOT NULL PRIMARY KEY, 
exchange BYTEA NOT NULL, version BIGINT NOT NULL)");
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS " + REPO_NAME + 
"_completed"
+                             + " (id VARCHAR(255) NOT NULL PRIMARY KEY, 
exchange BYTEA NOT NULL, version BIGINT NOT NULL,"
+                             + " instance_id VARCHAR(255))");
+        jdbcTemplate.execute("TRUNCATE TABLE " + REPO_NAME);
+        jdbcTemplate.execute("TRUNCATE TABLE " + REPO_NAME + "_completed");
+
+        context = new DefaultCamelContext();
+        context.start();
+    }
+
+    @AfterEach
+    public void tearDown() {
+        if (context != null) {
+            context.stop();
+        }
+    }
+
+    private ClusteredPostgresAggregationRepository createRepository(String 
instanceId) {
+        ClusteredPostgresAggregationRepository repo = new 
ClusteredPostgresAggregationRepository(
+                new DataSourceTransactionManager(dataSource), REPO_NAME, 
dataSource);
+        repo.setInstanceId(instanceId);
+        repo.setRecoveryByInstance(true);
+        return repo;
+    }
+
+    @Test
+    public void testInstanceScopedRecovery() throws Exception {
+        ClusteredPostgresAggregationRepository repoA = 
createRepository("nodeA");
+        ClusteredPostgresAggregationRepository repoB = 
createRepository("nodeB");
+        ServiceHelper.startService(repoA, repoB);
+        try {
+            // nodeA aggregates an exchange
+            Exchange exchange = new DefaultExchange(context);
+            exchange.getIn().setBody("payload");
+            assertNull(repoA.add(context, "foo", exchange));
+
+            Exchange stored = repoA.get(context, "foo");
+            assertNotNull(stored);
+            assertEquals("payload", stored.getIn().getBody());
+
+            // completing moves it to the completed table, tagged with nodeA's 
instance id
+            repoA.remove(context, "foo", stored);
+            assertNull(repoA.get(context, "foo"));
+
+            String exchangeId = stored.getExchangeId();
+            String instanceId = jdbcTemplate.queryForObject(
+                    "SELECT instance_id FROM " + REPO_NAME + "_completed WHERE 
id = ?", String.class, exchangeId);
+            assertEquals("nodeA", instanceId,
+                    "completed exchange must be tagged with the instance id of 
the node that completed it");
+
+            // only nodeA may recover it
+            Set<String> scannedByA = repoA.scan(context);
+            assertEquals(Set.of(exchangeId), scannedByA);
+            assertTrue(repoB.scan(context).isEmpty(), "nodeB must not recover 
exchanges completed by nodeA");
+
+            Exchange recovered = repoA.recover(context, exchangeId);
+            assertNotNull(recovered);
+            assertEquals("payload", recovered.getIn().getBody());
+
+            // confirm deletes it from the completed table
+            repoA.confirm(context, exchangeId);
+            assertTrue(repoA.scan(context).isEmpty());
+        } finally {
+            ServiceHelper.stopService(repoA, repoB);
+        }
+    }
+}
diff --git 
a/components/camel-sql/src/test/java/org/apache/camel/processor/aggregate/jdbc/PostgresAggregationRepositoryIT.java
 
b/components/camel-sql/src/test/java/org/apache/camel/processor/aggregate/jdbc/PostgresAggregationRepositoryIT.java
new file mode 100644
index 000000000000..93401144b3b2
--- /dev/null
+++ 
b/components/camel-sql/src/test/java/org/apache/camel/processor/aggregate/jdbc/PostgresAggregationRepositoryIT.java
@@ -0,0 +1,156 @@
+/*
+ * 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.camel.processor.aggregate.jdbc;
+
+import java.util.Set;
+
+import javax.sql.DataSource;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.service.ServiceHelper;
+import org.apache.camel.test.infra.postgres.services.PostgresService;
+import org.apache.camel.test.infra.postgres.services.PostgresServiceFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.postgresql.ds.PGSimpleDataSource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies {@link PostgresAggregationRepository} against a real PostgreSQL 
database.
+ */
+public class PostgresAggregationRepositoryIT {
+
+    private static final String REPO_NAME = "camel_agg_it";
+    private static final String REPO_NAME_TEXT = "camel_agg_text_it";
+
+    @RegisterExtension
+    public static PostgresService service = 
PostgresServiceFactory.createService();
+
+    private CamelContext context;
+    private DataSource dataSource;
+    private JdbcTemplate jdbcTemplate;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        PGSimpleDataSource ds = new PGSimpleDataSource();
+        ds.setUrl(service.jdbcUrl());
+        ds.setUser(service.userName());
+        ds.setPassword(service.password());
+        dataSource = ds;
+
+        jdbcTemplate = new JdbcTemplate(dataSource);
+        for (String table : new String[] { REPO_NAME, REPO_NAME + "_completed" 
}) {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS " + table
+                                 + " (id VARCHAR(255) NOT NULL PRIMARY KEY, 
exchange BYTEA NOT NULL, version BIGINT NOT NULL)");
+            jdbcTemplate.execute("TRUNCATE TABLE " + table);
+        }
+        for (String table : new String[] { REPO_NAME_TEXT, REPO_NAME_TEXT + 
"_completed" }) {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS " + table
+                                 + " (id VARCHAR(255) NOT NULL PRIMARY KEY, 
exchange BYTEA NOT NULL, version BIGINT NOT NULL, body TEXT)");
+            jdbcTemplate.execute("TRUNCATE TABLE " + table);
+        }
+
+        context = new DefaultCamelContext();
+        context.start();
+    }
+
+    @AfterEach
+    public void tearDown() {
+        if (context != null) {
+            context.stop();
+        }
+    }
+
+    private PostgresAggregationRepository createRepository(String 
repositoryName) {
+        return new PostgresAggregationRepository(
+                new DataSourceTransactionManager(dataSource), repositoryName, 
dataSource);
+    }
+
+    @Test
+    public void testAddGetUpdateRemoveRecoverConfirm() throws Exception {
+        PostgresAggregationRepository repo = createRepository(REPO_NAME);
+        ServiceHelper.startService(repo);
+        try {
+            // insert a new aggregation
+            Exchange exchange = new DefaultExchange(context);
+            exchange.getIn().setBody("counter:1");
+            assertNull(repo.add(context, "foo", exchange));
+
+            // read it back
+            Exchange actual = repo.get(context, "foo");
+            assertNotNull(actual);
+            assertEquals("counter:1", actual.getIn().getBody());
+
+            // update it using the version loaded by get()
+            actual.getIn().setBody("counter:2");
+            repo.add(context, "foo", actual);
+            actual = repo.get(context, "foo");
+            assertEquals("counter:2", actual.getIn().getBody());
+
+            // complete the aggregation: moves it to the completed table
+            repo.remove(context, "foo", actual);
+            assertNull(repo.get(context, "foo"));
+
+            Set<String> completed = repo.scan(context);
+            assertEquals(1, completed.size());
+
+            String exchangeId = completed.iterator().next();
+            Exchange recovered = repo.recover(context, exchangeId);
+            assertNotNull(recovered);
+            assertEquals("counter:2", recovered.getIn().getBody());
+
+            // confirm deletes it from the completed table
+            repo.confirm(context, exchangeId);
+            assertTrue(repo.scan(context).isEmpty());
+        } finally {
+            ServiceHelper.stopService(repo);
+        }
+    }
+
+    @Test
+    public void testStoreBodyAsText() throws Exception {
+        PostgresAggregationRepository repo = createRepository(REPO_NAME_TEXT);
+        repo.setStoreBodyAsText(true);
+        ServiceHelper.startService(repo);
+        try {
+            Exchange exchange = new DefaultExchange(context);
+            exchange.getIn().setBody("myBody");
+            repo.add(context, "foo", exchange);
+
+            // the body column must contain the message body as text
+            String body = jdbcTemplate.queryForObject(
+                    "SELECT body FROM " + REPO_NAME_TEXT + " WHERE id = ?", 
String.class, "foo");
+            assertEquals("myBody", body);
+
+            Exchange actual = repo.get(context, "foo");
+            assertEquals("myBody", actual.getIn().getBody());
+        } finally {
+            ServiceHelper.stopService(repo);
+        }
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index c74ef56633b7..44014f2466d3 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -333,3 +333,17 @@ getters and setters of 
`SupervisingRouteControllerConfiguration` and
 The clustered route controller property 
`camel.clustered.controller.initial-delay` is unchanged:
 it keeps its `String` type because it supports Camel time patterns (for 
example `10 seconds`)
 that `Duration` binding does not.
+
+=== camel-sql - PostgreSQL aggregation repositories fixed
+
+`PostgresAggregationRepository` and `ClusteredPostgresAggregationRepository` 
were broken since
+Camel 3.4: every insert failed with a parameter-index error because the 
`version` column was
+bound but not included in the generated `INSERT ... ON CONFLICT DO NOTHING` 
statement. Both
+repositories now include the `version` column in the insert, and the clustered 
variant also
+writes the `instance_id` column to the completed table when 
`recoveryByInstance` is enabled
+(previously instance-scoped recovery could never match any row).
+
+The aggregation tables used with these repositories must have the `version 
BIGINT` column, as
+already required by `JdbcAggregationRepository` for reading and updating 
aggregates. When
+`recoveryByInstance` is enabled, the completed table must have the 
`instance_id VARCHAR(255)`
+column as documented.

Reply via email to