diff --git a/Jenkinsfile b/Jenkinsfile
index 8919ba10f4..8b0dcdd33c 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -9,7 +9,7 @@ pipeline {
 
 	triggers {
 		pollSCM 'H/10 * * * *'
-		upstream(upstreamProjects: "spring-data-commons/main", threshold: hudson.model.Result.SUCCESS)
+		upstream(upstreamProjects: "spring-data-commons/4.0.x", threshold: hudson.model.Result.SUCCESS)
 	}
 
 	options {
diff --git a/pom.xml b/pom.xml
index ec709663b5..7e7021e437 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,11 +1,14 @@
 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
 
 	<modelVersion>4.0.0</modelVersion>
 
 	<groupId>org.springframework.data</groupId>
 	<artifactId>spring-data-relational-parent</artifactId>
-	<version>4.0.0-SNAPSHOT</version>
+	<version>4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT
+	</version>
 	<packaging>pom</packaging>
 
 	<name>Spring Data Relational Parent</name>
@@ -175,68 +178,6 @@
 					<scope>test</scope>
 				</dependency>
 			</dependencies>
-			<build>
-				<plugins>
-					<plugin>
-						<groupId>org.codehaus.mojo</groupId>
-						<artifactId>build-helper-maven-plugin</artifactId>
-						<version>3.3.0</version>
-						<executions>
-							<execution>
-								<id>add-source</id>
-								<phase>generate-sources</phase>
-								<goals>
-									<goal>add-test-source</goal>
-								</goals>
-								<configuration>
-									<sources>
-										<source>src/jmh/java</source>
-									</sources>
-								</configuration>
-							</execution>
-						</executions>
-					</plugin>
-					<plugin>
-						<groupId>org.apache.maven.plugins</groupId>
-						<artifactId>maven-surefire-plugin</artifactId>
-						<configuration>
-							<skip>true</skip>
-						</configuration>
-					</plugin>
-
-					<plugin>
-						<groupId>org.apache.maven.plugins</groupId>
-						<artifactId>maven-failsafe-plugin</artifactId>
-						<configuration>
-							<skip>true</skip>
-						</configuration>
-					</plugin>
-					<plugin>
-						<groupId>org.codehaus.mojo</groupId>
-						<artifactId>exec-maven-plugin</artifactId>
-						<version>3.1.0</version>
-						<executions>
-							<execution>
-								<id>run-benchmarks</id>
-								<phase>pre-integration-test</phase>
-								<goals>
-									<goal>exec</goal>
-								</goals>
-								<configuration>
-									<classpathScope>test</classpathScope>
-									<executable>java</executable>
-									<arguments>
-										<argument>-classpath</argument>
-										<classpath/>
-										<argument>org.openjdk.jmh.Main</argument>
-										<argument>.*</argument>
-									</arguments>
-								</configuration>
-							</execution>
-						</executions>
-					</plugin>
-				</plugins>
-			</build>
 			<repositories>
 				<repository>
 					<id>jitpack.io</id>
diff --git a/spring-data-jdbc-distribution/pom.xml b/spring-data-jdbc-distribution/pom.xml
index b3c39e64c3..8d5696607e 100644
--- a/spring-data-jdbc-distribution/pom.xml
+++ b/spring-data-jdbc-distribution/pom.xml
@@ -14,7 +14,7 @@
 	<parent>
 		<groupId>org.springframework.data</groupId>
 		<artifactId>spring-data-relational-parent</artifactId>
-		<version>4.0.0-SNAPSHOT</version>
+		<version>4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT</version>
 		<relativePath>../pom.xml</relativePath>
 	</parent>
 
diff --git a/spring-data-jdbc/pom.xml b/spring-data-jdbc/pom.xml
index e61fd64020..9f7d6c308e 100644
--- a/spring-data-jdbc/pom.xml
+++ b/spring-data-jdbc/pom.xml
@@ -6,7 +6,7 @@
 	<modelVersion>4.0.0</modelVersion>
 
 	<artifactId>spring-data-jdbc</artifactId>
-	<version>4.0.0-SNAPSHOT</version>
+	<version>4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT</version>
 
 	<name>Spring Data JDBC</name>
 	<description>Spring Data module for JDBC repositories.</description>
@@ -15,7 +15,7 @@
 	<parent>
 		<groupId>org.springframework.data</groupId>
 		<artifactId>spring-data-relational-parent</artifactId>
-		<version>4.0.0-SNAPSHOT</version>
+		<version>4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT</version>
 	</parent>
 
 	<properties>
diff --git a/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/BenchmarkSettings.java b/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/BenchmarkSettings.java
new file mode 100644
index 0000000000..2e056565d4
--- /dev/null
+++ b/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/BenchmarkSettings.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2023-2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.jdbc;
+
+import java.util.concurrent.TimeUnit;
+
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Global benchmark settings.
+ *
+ * @author Mark Paluch
+ */
+@Warmup(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS)
+@Measurement(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS)
+@Fork(value = 1, warmups = 0)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+public abstract class BenchmarkSettings {
+
+}
diff --git a/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/CompositeIdBenchmarks.java b/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/CompositeIdBenchmarks.java
new file mode 100644
index 0000000000..f84792b20f
--- /dev/null
+++ b/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/CompositeIdBenchmarks.java
@@ -0,0 +1,245 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.jdbc;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
+
+import javax.sql.DataSource;
+
+import org.junit.platform.commons.annotation.Testable;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.jdbc.core.JdbcAggregateTemplate;
+import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration;
+import org.springframework.data.relational.core.mapping.Embedded;
+import org.springframework.data.relational.core.mapping.Table;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.datasource.ConnectionHolder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+/**
+ * Benchmarks for Composite Ids in Spring Data JDBC.
+ *
+ * @author Mark Paluch
+ */
+@Testable
+public class CompositeIdBenchmarks extends BenchmarkSettings {
+
+	@Configuration
+	static class BenchmarkConfiguration extends AbstractJdbcConfiguration {
+
+		@Bean
+		NamedParameterJdbcTemplate namedParameterJdbcTemplate(DataSource dataSource) {
+			return new NamedParameterJdbcTemplate(dataSource);
+		}
+
+		@Bean
+		DataSource dataSource() {
+
+			return new EmbeddedDatabaseBuilder() //
+					.generateUniqueName(true) //
+					.setType(EmbeddedDatabaseType.HSQL) //
+					.setScriptEncoding("UTF-8") //
+					.ignoreFailedDrops(true) //
+					.addScript(
+							"classpath:/org.springframework.data.jdbc.core/CompositeIdAggregateTemplateHsqlIntegrationTests-hsql.sql") //
+					.build();
+		}
+	}
+
+	@State(Scope.Benchmark)
+	public static class BenchmarkState {
+
+		AnnotationConfigApplicationContext context;
+		JdbcAggregateTemplate template;
+		NamedParameterJdbcTemplate named;
+		AtomicLong l = new AtomicLong();
+		SimpleEntity alpha;
+
+		@Setup
+		public void setup() throws SQLException {
+
+			context = new AnnotationConfigApplicationContext();
+			context.register(BenchmarkConfiguration.class);
+			context.refresh();
+			context.start();
+
+			template = context.getBean(JdbcAggregateTemplate.class);
+			named = context.getBean(NamedParameterJdbcTemplate.class);
+			DataSource dataSource = context.getBean(DataSource.class);
+
+			Connection connection = dataSource.getConnection();
+			ConnectionHolder holder = new ConnectionHolder(connection, true);
+			holder.setSynchronizedWithTransaction(true);
+			TransactionSynchronizationManager.bindResource(dataSource, holder);
+
+			alpha = template.insert(new SimpleEntity(new WrappedPk(l.incrementAndGet()), "alpha"));
+		}
+
+		@TearDown
+		public void cleanup() {
+			context.close();
+		}
+	}
+
+	@Benchmark
+	public Object namedTemplate(BenchmarkState state) {
+		return state.named.query("SELECT * FROM SIMPLE_ENTITY WHERE id = :id", Map.of("id", state.alpha.wrappedPk.id),
+				(rs, rowNum) -> 1);
+	}
+
+	@Benchmark
+	public Object jdbcTemplate(BenchmarkState state) {
+		return state.named.getJdbcOperations().query("SELECT * FROM SIMPLE_ENTITY WHERE id = " + state.alpha.wrappedPk.id,
+				(rs, rowNum) -> 1);
+	}
+
+	@Benchmark
+	public Object baselineInsert(BenchmarkState state) {
+		return state.template.insert(new BaselineEntity(state.l.incrementAndGet(), "alpha"));
+	}
+
+	@Benchmark
+	public Object loadBaselineEntity(BenchmarkState state) {
+		return state.template.findById(state.alpha.wrappedPk, BaselineEntity.class);
+	}
+
+	@Benchmark
+	public Object insert(BenchmarkState state) {
+		return state.template.insert(new SimpleEntity(new WrappedPk(state.l.incrementAndGet()), "alpha"));
+	}
+
+	@Benchmark
+	public Object loadSimpleEntity(BenchmarkState state) {
+		return state.template.findById(state.alpha.wrappedPk, SimpleEntity.class);
+	}
+
+	@Benchmark
+	public Object saveAndLoadEntityWithList(BenchmarkState state) {
+
+		WithList entity = state.template.insert(new WithList(new WrappedPk(state.l.incrementAndGet()), "alpha",
+				List.of(new Child("Romulus"), new Child("Remus"))));
+
+		assertThat(entity.wrappedPk).isNotNull() //
+				.extracting(WrappedPk::id).isNotNull();
+
+		return state.template.findById(entity.wrappedPk, WithList.class);
+	}
+
+	@Benchmark
+	public Object saveAndLoadSimpleEntityWithEmbeddedPk(BenchmarkState state) {
+
+		SimpleEntityWithEmbeddedPk entity = state.template
+				.insert(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(state.l.incrementAndGet(), "x"), "alpha"));
+
+		return state.template.findById(entity.embeddedPk, SimpleEntityWithEmbeddedPk.class);
+	}
+
+	@Benchmark
+	public void deleteSingleSimpleEntityWithEmbeddedPk(BenchmarkState state) {
+
+		List<SimpleEntityWithEmbeddedPk> entities = (List<SimpleEntityWithEmbeddedPk>) state.template
+				.insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha")));
+
+		state.template.delete(entities.get(0));
+	}
+
+	@Benchmark
+	public void deleteMultipleSimpleEntityWithEmbeddedPk(BenchmarkState state) {
+
+		List<SimpleEntityWithEmbeddedPk> entities = (List<SimpleEntityWithEmbeddedPk>) state.template
+				.insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta")));
+
+		state.template.deleteAll(List.of(entities.get(1), entities.get(0)));
+	}
+
+	@Benchmark
+	public void updateSingleSimpleEntityWithEmbeddedPk(BenchmarkState state) {
+
+		List<SimpleEntityWithEmbeddedPk> entities = (List<SimpleEntityWithEmbeddedPk>) state.template
+				.insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma")));
+
+		SimpleEntityWithEmbeddedPk updated = new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "ALPHA");
+		state.template.save(updated);
+
+		state.template.deleteAll(SimpleEntityWithEmbeddedPk.class);
+	}
+
+	private record WrappedPk(Long id) {
+	}
+
+	@Table("SIMPLE_ENTITY")
+	private record BaselineEntity( //
+			@Id Long id, //
+			String name //
+	) {
+	}
+
+	private record SimpleEntity( //
+			@Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) WrappedPk wrappedPk, //
+			String name //
+	) {
+	}
+
+	private record Child(String name) {
+	}
+
+	private record WithList( //
+			@Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) WrappedPk wrappedPk, //
+			String name, List<Child> children) {
+	}
+
+	private record EmbeddedPk(Long one, String two) {
+	}
+
+	private record SimpleEntityWithEmbeddedPk( //
+			@Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, //
+			String name //
+	) {
+	}
+
+	private record SingleReference( //
+			@Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, //
+			String name, //
+			Child child) {
+	}
+
+	private record WithListAndCompositeId( //
+			@Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, //
+			String name, //
+			List<Child> child) {
+	}
+
+}
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java
index 2ec070ab76..edb2faee97 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java
@@ -15,8 +15,18 @@
  */
 package org.springframework.data.jdbc.core;
 
-import java.util.*;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
 import java.util.function.BiConsumer;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
 import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException;
@@ -175,8 +185,9 @@ private Identifier getParentKeys(DbAction.WithDependingOn<?> action, JdbcConvert
 
 		Object id = getParentId(action);
 
+		AggregatePath aggregatePath = context.getAggregatePath(action.getPropertyPath());
 		JdbcIdentifierBuilder identifier = JdbcIdentifierBuilder //
-				.forBackReferences(converter, context.getAggregatePath(action.getPropertyPath()), id);
+				.forBackReferences(converter, aggregatePath, getIdMapper(id, aggregatePath, converter));
 
 		for (Map.Entry<PersistentPropertyPath<RelationalPersistentProperty>, Object> qualifier : action.getQualifiers()
 				.entrySet()) {
@@ -186,6 +197,20 @@ private Identifier getParentKeys(DbAction.WithDependingOn<?> action, JdbcConvert
 		return identifier.build();
 	}
 
+	static Function<AggregatePath, Object> getIdMapper(Object idValue, AggregatePath path, JdbcConverter converter) {
+
+		RelationalPersistentProperty idProperty = path.getIdDefiningParentPath().getRequiredIdProperty();
+		RelationalPersistentEntity<?> entity = converter.getMappingContext()
+				.getPersistentEntity(idProperty);
+
+		if (entity == null) {
+			return aggregatePath -> idValue;
+		}
+
+		PersistentPropertyPathAccessor<Object> propertyPathAccessor = entity.getPropertyPathAccessor(idValue);
+		return aggregatePath -> propertyPathAccessor.getProperty(aggregatePath.getSubPathBasedOn(idProperty.getActualType()).getRequiredPersistentPropertyPath());
+	}
+
 	private Object getParentId(DbAction.WithDependingOn<?> action) {
 
 		DbAction.WithEntity<?> idOwningAction = getIdOwningAction(action,
@@ -267,12 +292,10 @@ <T> List<T> populateIdsIfNecessary() {
 
 				if (newEntity != action.getEntity()) {
 
-					cascadingValues.stage(insert.getDependingOn(), insert.getPropertyPath(),
-							qualifierValue, newEntity);
+					cascadingValues.stage(insert.getDependingOn(), insert.getPropertyPath(), qualifierValue, newEntity);
 				} else if (insert.getPropertyPath().getLeafProperty().isCollectionLike()) {
 
-					cascadingValues.gather(insert.getDependingOn(), insert.getPropertyPath(),
-							qualifierValue, newEntity);
+					cascadingValues.gather(insert.getDependingOn(), insert.getPropertyPath(), qualifierValue, newEntity);
 				}
 			}
 		}
@@ -359,8 +382,8 @@ private <T> void updateWithVersion(DbAction.UpdateRoot<T> update) {
 	 */
 	private static class StagedValues {
 
-		static final List<MultiValueAggregator<?>> aggregators = Arrays.asList(SetAggregator.INSTANCE, MapAggregator.INSTANCE,
-				ListAggregator.INSTANCE, SingleElementAggregator.INSTANCE);
+		static final List<MultiValueAggregator<?>> aggregators = Arrays.asList(SetAggregator.INSTANCE,
+				MapAggregator.INSTANCE, ListAggregator.INSTANCE, SingleElementAggregator.INSTANCE);
 
 		Map<DbAction, Map<PersistentPropertyPath, StagedValue>> values = new HashMap<>();
 
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BindParameterNameSanitizer.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BindParameterNameSanitizer.java
index 64213bd939..09dbcefe4a 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BindParameterNameSanitizer.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BindParameterNameSanitizer.java
@@ -29,6 +29,14 @@ abstract class BindParameterNameSanitizer {
 	private static final Pattern parameterPattern = Pattern.compile("\\W");
 
 	static String sanitize(String rawName) {
-		return parameterPattern.matcher(rawName).replaceAll("");
+
+		for (int i = 0; i < rawName.length(); i++) {
+			char c = rawName.charAt(i);
+			if ((c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '_') {
+				return parameterPattern.matcher(rawName).replaceAll("");
+			}
+		}
+
+		return rawName;
 	}
 }
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java
index ff0b67f44e..f0febcad79 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java
@@ -121,6 +121,7 @@ public <T> Object insert(T instance, Class<T> domainType, Identifier identifier,
 	public <T> Object[] insert(List<InsertSubject<T>> insertSubjects, Class<T> domainType, IdValueSource idValueSource) {
 
 		Assert.notEmpty(insertSubjects, "Batch insert must contain at least one InsertSubject");
+
 		SqlIdentifierParameterSource[] sqlParameterSources = insertSubjects.stream()
 				.map(insertSubject -> sqlParametersFactory.forInsert( //
 						insertSubject.getInstance(), //
@@ -171,7 +172,7 @@ public <S> boolean updateWithVersion(S instance, Class<S> domainType, Number pre
 	public void delete(Object id, Class<?> domainType) {
 
 		String deleteByIdSql = sql(domainType).getDeleteById();
-		SqlParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType, ID_SQL_PARAMETER);
+		SqlParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType);
 
 		operations.update(deleteByIdSql, parameter);
 	}
@@ -192,7 +193,7 @@ public <T> void deleteWithVersion(Object id, Class<T> domainType, Number previou
 
 		RelationalPersistentEntity<T> persistentEntity = getRequiredPersistentEntity(domainType);
 
-		SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forQueryById(id, domainType, ID_SQL_PARAMETER);
+		SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forQueryById(id, domainType);
 		parameterSource.addValue(VERSION_SQL_PARAMETER, previousVersion);
 		int affectedRows = operations.update(sql(domainType).getDeleteByIdAndVersion(), parameterSource);
 
@@ -212,8 +213,7 @@ public void delete(Object rootId, PersistentPropertyPath<RelationalPersistentPro
 
 		String delete = sql(rootEntity.getType()).createDeleteByPath(propertyPath);
 
-		SqlIdentifierParameterSource parameters = sqlParametersFactory.forQueryById(rootId, rootEntity.getType(),
-				ROOT_ID_PARAMETER);
+		SqlIdentifierParameterSource parameters = sqlParametersFactory.forQueryById(rootId, rootEntity.getType());
 		operations.update(delete, parameters);
 	}
 
@@ -247,7 +247,7 @@ public void deleteAll(PersistentPropertyPath<RelationalPersistentProperty> prope
 	public <T> void acquireLockById(Object id, LockMode lockMode, Class<T> domainType) {
 
 		String acquireLockByIdSql = sql(domainType).getAcquireLockById(lockMode);
-		SqlIdentifierParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType, ID_SQL_PARAMETER);
+		SqlIdentifierParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType);
 
 		operations.query(acquireLockByIdSql, parameter, ResultSet::next);
 	}
@@ -273,7 +273,7 @@ public long count(Class<?> domainType) {
 	public <T> T findById(Object id, Class<T> domainType) {
 
 		String findOneSql = sql(domainType).getFindOne();
-		SqlIdentifierParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType, ID_SQL_PARAMETER);
+		SqlIdentifierParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType);
 
 		try {
 			return operations.queryForObject(findOneSql, parameter, getRowMapper(domainType));
@@ -359,7 +359,7 @@ public Object mapRow(ResultSet rs, int rowNum) throws SQLException {
 	public <T> boolean existsById(Object id, Class<T> domainType) {
 
 		String existsSql = sql(domainType).getExists();
-		SqlParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType, ID_SQL_PARAMETER);
+		SqlParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType);
 
 		Boolean result = operations.queryForObject(existsSql, parameter, Boolean.class);
 		Assert.state(result != null, "The result of an exists query must not be null");
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java
index 5f9284a54b..711ba330c8 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java
@@ -99,6 +99,25 @@ public static Identifier from(Map<SqlIdentifier, Object> map) {
 		return new Identifier(Collections.unmodifiableList(values));
 	}
 
+	/**
+	 * Creates a new {@link Identifier} from the current instance and sets the value from {@link Identifier}. Existing key
+	 * definitions for {@code name} are overwritten if they already exist.
+	 *
+	 * @param identifier the identifier to append.
+	 * @return the {@link Identifier} containing all existing keys and the key part for {@code name}, {@code value}, and a
+	 *         {@link Class target type}.
+	 * @since 4.0
+	 */
+	public Identifier withPart(Identifier identifier) {
+
+		Identifier result = this;
+		for (SingleIdentifierValue part : identifier.getParts()) {
+			result = result.withPart(part.getName(), part.getValue(), part.getTargetType());
+		}
+
+		return result;
+	}
+
 	/**
 	 * Creates a new {@link Identifier} from the current instance and sets the value for {@code key}. Existing key
 	 * definitions for {@code name} are overwritten if they already exist.
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcBackReferencePropertyValueProvider.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcBackReferencePropertyValueProvider.java
deleted file mode 100644
index 34f9e88de5..0000000000
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcBackReferencePropertyValueProvider.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright 2020-2025 the original author or authors.
- *
- * Licensed 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
- *
- *      https://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.springframework.data.jdbc.core.convert;
-
-import org.springframework.data.mapping.model.PropertyValueProvider;
-import org.springframework.data.relational.core.mapping.AggregatePath;
-import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
-
-/**
- * {@link PropertyValueProvider} obtaining values from a {@link ResultSetAccessor}. For a given id property it provides
- * the value in the resultset under which other entities refer back to it.
- *
- * @author Jens Schauder
- * @author Kurt Niemi
- * @author Mikhail Polivakha
- * @since 2.0
- */
-class JdbcBackReferencePropertyValueProvider implements PropertyValueProvider<RelationalPersistentProperty> {
-
-	private final AggregatePath basePath;
-	private final ResultSetAccessor resultSet;
-
-	/**
-	 * @param basePath path from the aggregate root relative to which all properties get resolved.
-	 * @param resultSet the {@link ResultSetAccessor} from which to obtain the actual values.
-	 */
-	JdbcBackReferencePropertyValueProvider(AggregatePath basePath, ResultSetAccessor resultSet) {
-
-		this.resultSet = resultSet;
-		this.basePath = basePath;
-	}
-
-	@Override
-	public <T> T getPropertyValue(RelationalPersistentProperty property) {
-		return (T) resultSet.getObject(basePath.append(property).getTableInfo().reverseColumnInfo().alias().getReference());
-	}
-
-	public JdbcBackReferencePropertyValueProvider extendBy(RelationalPersistentProperty property) {
-		return new JdbcBackReferencePropertyValueProvider(basePath.append(property), resultSet);
-	}
-}
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcColumnTypes.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcColumnTypes.java
index fa74b3b94f..0c678ef975 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcColumnTypes.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcColumnTypes.java
@@ -24,21 +24,28 @@
 import java.util.Map;
 
 import org.springframework.util.ClassUtils;
+import org.springframework.util.ConcurrentLruCache;
 
 /**
  * Utility that determines the necessary type conversions between Java types used in the domain model and types
  * compatible with JDBC drivers.
  *
  * @author Jens Schauder
+ * @author Mark Paluch
  * @since 2.0
  */
 public enum JdbcColumnTypes {
 
 	INSTANCE {
 
+		private final ConcurrentLruCache<Class<?>, Class<?>> cache = new ConcurrentLruCache<>(64, this::doResolve);
+
 		@SuppressWarnings({ "unchecked", "rawtypes" })
 		public Class<?> resolvePrimitiveType(Class<?> type) {
+			return cache.get(type);
+		}
 
+		private Class<?> doResolve(Class<?> type) {
 			return javaToDbType.entrySet().stream() //
 					.filter(e -> e.getKey().isAssignableFrom(type)) //
 					.map(e -> (Class<?>) e.getValue()) //
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java
index 22944aaad2..24213662ff 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java
@@ -15,7 +15,10 @@
  */
 package org.springframework.data.jdbc.core.convert;
 
+import java.util.function.Function;
+
 import org.springframework.data.relational.core.mapping.AggregatePath;
+import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
 import org.springframework.util.Assert;
 
 /**
@@ -39,15 +42,42 @@ public static JdbcIdentifierBuilder empty() {
 	/**
 	 * Creates ParentKeys with backreference for the given path and value of the parents id.
 	 */
-	public static JdbcIdentifierBuilder forBackReferences(JdbcConverter converter, AggregatePath path, Object value) {
+	public static JdbcIdentifierBuilder forBackReferences(JdbcConverter converter, AggregatePath path,
+			Function<AggregatePath, Object> valueProvider) {
+
+		return new JdbcIdentifierBuilder(forBackReference(converter, path, Identifier.empty(), valueProvider));
+	}
+
+	/**
+	 * @param converter used for determining the column types to be used for different properties. Must not be
+	 *          {@literal null}.
+	 * @param path the path for which needs to back reference an id. Must not be {@literal null}.
+	 * @param defaultIdentifier Identifier to be used as a default when no backreference can be constructed. Must not be
+	 *          {@literal null}.
+	 * @param valueProvider provides values for the {@link Identifier} based on an {@link AggregatePath}. Must not be
+	 *          {@literal null}.
+	 * @return Guaranteed not to be {@literal null}.
+	 */
+	public static Identifier forBackReference(JdbcConverter converter, AggregatePath path, Identifier defaultIdentifier,
+			Function<AggregatePath, Object> valueProvider) {
+
+		Identifier identifierToUse = defaultIdentifier;
+
+		AggregatePath idDefiningParentPath = path.getIdDefiningParentPath();
+
+		// note that the idDefiningParentPath might not itself have an id property, but have a combination of back
+		// references and possibly keys, that form an id
+		if (idDefiningParentPath.hasIdProperty()) {
+
+			AggregatePath.ColumnInfos infos = path.getTableInfo().backReferenceColumnInfos();
+			identifierToUse = infos.reduce(Identifier.empty(), (ap, ci) -> {
 
-		Identifier identifier = Identifier.of( //
-				path.getTableInfo().reverseColumnInfo().name(), //
-				value, //
-				converter.getColumnType(path.getIdDefiningParentPath().getRequiredIdProperty()) //
-		);
+				RelationalPersistentProperty property = ap.getRequiredLeafProperty();
+				return Identifier.of(ci.name(), valueProvider.apply(ap), converter.getColumnType(property));
+			}, Identifier::withPart);
+		}
 
-		return new JdbcIdentifierBuilder(identifier);
+		return identifierToUse;
 	}
 
 	/**
@@ -62,8 +92,8 @@ public JdbcIdentifierBuilder withQualifier(AggregatePath path, Object value) {
 		Assert.notNull(path, "Path must not be null");
 		Assert.notNull(value, "Value must not be null");
 
-		identifier = identifier.withPart(path.getTableInfo().qualifierColumnInfo().name(), value,
-				path.getTableInfo().qualifierColumnType());
+		AggregatePath.TableInfo tableInfo = path.getTableInfo();
+		identifier = identifier.withPart(tableInfo.qualifierColumnInfo().name(), value, tableInfo.qualifierColumnType());
 
 		return this;
 	}
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java
index 7460931dab..2c3feffdb6 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java
@@ -80,7 +80,7 @@ public class MappingJdbcConverter extends MappingRelationalConverter implements
 	 * {@link #MappingJdbcConverter(RelationalMappingContext, RelationResolver, CustomConversions, JdbcTypeFactory)}
 	 * (MappingContext, RelationResolver, JdbcTypeFactory)} to convert arrays and large objects into JDBC-specific types.
 	 *
-	 * @param context          must not be {@literal null}.
+	 * @param context must not be {@literal null}.
 	 * @param relationResolver used to fetch additional relations from the database. Must not be {@literal null}.
 	 */
 	public MappingJdbcConverter(RelationalMappingContext context, RelationResolver relationResolver) {
@@ -98,12 +98,12 @@ public MappingJdbcConverter(RelationalMappingContext context, RelationResolver r
 	/**
 	 * Creates a new {@link MappingJdbcConverter} given {@link MappingContext}.
 	 *
-	 * @param context          must not be {@literal null}.
+	 * @param context must not be {@literal null}.
 	 * @param relationResolver used to fetch additional relations from the database. Must not be {@literal null}.
-	 * @param typeFactory      must not be {@literal null}
+	 * @param typeFactory must not be {@literal null}
 	 */
 	public MappingJdbcConverter(RelationalMappingContext context, RelationResolver relationResolver,
-								CustomConversions conversions, JdbcTypeFactory typeFactory) {
+			CustomConversions conversions, JdbcTypeFactory typeFactory) {
 
 		super(context, conversions);
 
@@ -220,7 +220,7 @@ private boolean canWriteAsJdbcValue(@Nullable Object value) {
 			return true;
 		}
 
-		if (value instanceof AggregateReference aggregateReference) {
+		if (value instanceof AggregateReference<?, ?> aggregateReference) {
 			return canWriteAsJdbcValue(aggregateReference.getId());
 		}
 
@@ -285,7 +285,7 @@ public <R> R readAndResolve(TypeInformation<R> type, RowDocument source, Identif
 
 	@Override
 	protected RelationalPropertyValueProvider newValueProvider(RowDocumentAccessor documentAccessor,
-															   ValueExpressionEvaluator evaluator, ConversionContext context) {
+			ValueExpressionEvaluator evaluator, ConversionContext context) {
 
 		if (context instanceof ResolvingConversionContext rcc) {
 
@@ -314,7 +314,7 @@ class ResolvingRelationalPropertyValueProvider implements RelationalPropertyValu
 		private final Identifier identifier;
 
 		private ResolvingRelationalPropertyValueProvider(AggregatePathValueProvider delegate, RowDocumentAccessor accessor,
-														 ResolvingConversionContext context, Identifier identifier) {
+				ResolvingConversionContext context, Identifier identifier) {
 
 			AggregatePath path = context.aggregatePath();
 
@@ -323,7 +323,7 @@ private ResolvingRelationalPropertyValueProvider(AggregatePathValueProvider dele
 			this.context = context;
 			this.identifier = path.isEntity()
 					? potentiallyAppendIdentifier(identifier, path.getRequiredLeafEntity(),
-					property -> delegate.getValue(path.append(property)))
+							property -> delegate.getValue(path.append(property)))
 					: identifier;
 		}
 
@@ -331,7 +331,7 @@ private ResolvingRelationalPropertyValueProvider(AggregatePathValueProvider dele
 		 * Conditionally append the identifier if the entity has an identifier property.
 		 */
 		static Identifier potentiallyAppendIdentifier(Identifier base, RelationalPersistentEntity<?> entity,
-													  Function<RelationalPersistentProperty, Object> getter) {
+				Function<RelationalPersistentProperty, Object> getter) {
 
 			if (entity.hasIdProperty()) {
 
@@ -361,24 +361,10 @@ public <T> T getPropertyValue(RelationalPersistentProperty property) {
 
 				if (property.isCollectionLike() || property.isMap()) {
 
-					Identifier identifierToUse = this.identifier;
-					AggregatePath idDefiningParentPath = aggregatePath.getIdDefiningParentPath();
+					Identifier identifier = JdbcIdentifierBuilder.forBackReference(MappingJdbcConverter.this, aggregatePath,
+							this.identifier, getWrappedValueProvider(delegate::getValue, aggregatePath));
 
-					// note that the idDefiningParentPath might not itself have an id property, but have a combination of back
-					// references and possibly keys, that form an id
-					if (idDefiningParentPath.hasIdProperty()) {
-
-						RelationalPersistentProperty identifier = idDefiningParentPath.getRequiredIdProperty();
-						AggregatePath idPath = idDefiningParentPath.append(identifier);
-						Object value = delegate.getValue(idPath);
-
-						Assert.state(value != null, "Identifier value must not be null at this point");
-
-						identifierToUse = Identifier.of(aggregatePath.getTableInfo().reverseColumnInfo().name(), value,
-								identifier.getActualType());
-					}
-
-					Iterable<Object> allByPath = relationResolver.findAllByPath(identifierToUse,
+					Iterable<Object> allByPath = relationResolver.findAllByPath(identifier,
 							aggregatePath.getRequiredPersistentPropertyPath());
 
 					if (property.isCollectionLike()) {
@@ -423,7 +409,7 @@ public boolean hasValue(RelationalPersistentProperty property) {
 					return delegate.hasValue(toUse);
 				}
 
-				return delegate.hasValue(aggregatePath.getTableInfo().reverseColumnInfo().alias());
+				return delegate.hasValue(aggregatePath.getTableInfo().backReferenceColumnInfos().any().alias());
 			}
 
 			return delegate.hasValue(aggregatePath);
@@ -449,7 +435,7 @@ public boolean hasNonEmptyValue(RelationalPersistentProperty property) {
 					return delegate.hasValue(toUse);
 				}
 
-				return delegate.hasValue(aggregatePath.getTableInfo().reverseColumnInfo().alias());
+				return delegate.hasValue(aggregatePath.getTableInfo().backReferenceColumnInfos().any().alias());
 			}
 
 			return delegate.hasNonEmptyValue(aggregatePath);
@@ -460,7 +446,35 @@ public RelationalPropertyValueProvider withContext(ConversionContext context) {
 
 			return context == this.context ? this
 					: new ResolvingRelationalPropertyValueProvider(delegate.withContext(context), accessor,
-					(ResolvingConversionContext) context, identifier);
+							(ResolvingConversionContext) context, identifier);
+		}
+	}
+
+	private static Function<AggregatePath, Object> getWrappedValueProvider(Function<AggregatePath, Object> valueProvider,
+			AggregatePath aggregatePath) {
+
+		AggregatePath idDefiningParentPath = aggregatePath.getIdDefiningParentPath();
+
+		if (!idDefiningParentPath.hasIdProperty()) {
+			return ap -> {
+				throw new IllegalStateException(
+						"AggregatePath %s does not define an identifier property".formatted(idDefiningParentPath));
+			};
+		}
+
+		RelationalPersistentProperty idProperty = idDefiningParentPath.getRequiredIdProperty();
+		AggregatePath idPath = idProperty.isEntity() ? idDefiningParentPath.append(idProperty) : idDefiningParentPath;
+
+		return ap -> valueProvider.apply(smartAppend(idPath, ap));
+	}
+
+	private static AggregatePath smartAppend(AggregatePath base, AggregatePath suffix) {
+
+		RelationalPersistentEntity<?> owner = suffix.getRequiredBaseProperty().getOwner();
+		if (owner.equals(base.getRequiredLeafEntity())) {
+			return base.append(suffix);
+		} else {
+			return smartAppend(base, suffix.getTail());
 		}
 	}
 
@@ -472,7 +486,7 @@ public RelationalPropertyValueProvider withContext(ConversionContext context) {
 	 * @param identifier
 	 */
 	private record ResolvingConversionContext(ConversionContext delegate, AggregatePath aggregatePath,
-											  Identifier identifier) implements ConversionContext {
+			Identifier identifier) implements ConversionContext {
 
 		@Override
 		public <S> S convert(Object source, TypeInformation<? extends S> typeHint) {
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java
index 2d27a453ac..5509157847 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java
@@ -26,6 +26,7 @@
 import org.springframework.data.relational.core.mapping.RelationalMappingContext;
 import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
 import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
+import org.springframework.data.relational.core.mapping.RelationalPredicates;
 import org.springframework.data.relational.core.sql.SqlIdentifier;
 import org.springframework.data.relational.domain.RowDocument;
 import org.springframework.lang.Nullable;
@@ -235,7 +236,7 @@ private void readEntity(RS row, RowDocument document, AggregatePath basePath,
 
 				AggregatePath path = basePath.append(property);
 
-				if (property.isEntity() && !property.isEmbedded() && (property.isCollectionLike() || property.isQualified())) {
+				if (RelationalPredicates.isRelation(property) && (property.isCollectionLike() || property.isQualified())) {
 
 					readerState.put(property, new ContainerSink<>(aggregateContext, property, path));
 					continue;
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java
index 7663e6cd4f..2076c9b167 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java
@@ -40,10 +40,6 @@ class SqlContext {
 		this.table = Table.create(entity.getQualifiedTableName());
 	}
 
-	Column getIdColumn() {
-		return table.column(entity.getIdColumn());
-	}
-
 	Column getVersionColumn() {
 		return table.column(entity.getRequiredVersionProperty().getColumnName());
 	}
@@ -60,11 +56,21 @@ Table getTable(AggregatePath path) {
 	}
 
 	Column getColumn(AggregatePath path) {
-		AggregatePath.ColumnInfo columnInfo = path.getColumnInfo();
-		return getTable(path).column(columnInfo.name()).as(columnInfo.alias());
+		return getAliasedColumn(path, path.getColumnInfo());
 	}
 
-	Column getReverseColumn(AggregatePath path) {
-		return getTable(path).column(path.getTableInfo().reverseColumnInfo().name()).as(path.getTableInfo().reverseColumnInfo().alias());
+	/**
+	 * A token reverse column, used in selects to identify, if an entity is present or {@literal null}.
+	 *
+	 * @param path must not be null.
+	 * @return a {@literal Column} that is part of the effective primary key for the given path.
+	 * @since 4.0
+	 */
+	Column getAnyReverseColumn(AggregatePath path) {
+		return getAliasedColumn(path, path.getTableInfo().backReferenceColumnInfos().any());
+	}
+
+	private Column getAliasedColumn(AggregatePath path, AggregatePath.ColumnInfo columnInfo) {
+		return getTable(path).column(columnInfo.name()).as(columnInfo.alias());
 	}
 }
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java
index 783f12f157..82f2db5158 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java
@@ -15,8 +15,19 @@
  */
 package org.springframework.data.jdbc.core.convert;
 
-import java.util.*;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.function.BiFunction;
 import java.util.function.Function;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
 import org.springframework.data.domain.Pageable;
@@ -34,7 +45,6 @@
 import org.springframework.data.relational.core.query.CriteriaDefinition;
 import org.springframework.data.relational.core.query.Query;
 import org.springframework.data.relational.core.sql.*;
-import org.springframework.data.relational.core.sql.render.RenderContext;
 import org.springframework.data.relational.core.sql.render.SqlRenderer;
 import org.springframework.data.util.Lazy;
 import org.springframework.data.util.Predicates;
@@ -61,12 +71,10 @@
  * @author Viktor Ardelean
  * @author Kurt Niemi
  */
-class SqlGenerator {
+public class SqlGenerator {
 
 	static final SqlIdentifier VERSION_SQL_PARAMETER = SqlIdentifier.unquoted("___oldOptimisticLockingVersion");
-	static final SqlIdentifier ID_SQL_PARAMETER = SqlIdentifier.unquoted("id");
 	static final SqlIdentifier IDS_SQL_PARAMETER = SqlIdentifier.unquoted("ids");
-	static final SqlIdentifier ROOT_ID_PARAMETER = SqlIdentifier.unquoted("rootId");
 
 	/**
 	 * Length of an aggregate path that is one longer then the root path.
@@ -75,7 +83,6 @@ class SqlGenerator {
 
 	private final RelationalPersistentEntity<?> entity;
 	private final RelationalMappingContext mappingContext;
-	private final RenderContext renderContext;
 
 	private final SqlContext sqlContext;
 	private final SqlRenderer sqlRenderer;
@@ -112,13 +119,25 @@ class SqlGenerator {
 		this.mappingContext = mappingContext;
 		this.entity = entity;
 		this.sqlContext = new SqlContext(entity);
-		this.renderContext = new RenderContextFactory(dialect).createRenderContext();
-		this.sqlRenderer = SqlRenderer.create(renderContext);
+		this.sqlRenderer = SqlRenderer.create(new RenderContextFactory(dialect).createRenderContext());
 		this.columns = new Columns(entity, mappingContext, converter);
 		this.queryMapper = new QueryMapper(converter);
 		this.dialect = dialect;
 	}
 
+	/**
+	 * Create a basic select structure with all the necessary joins
+	 *
+	 * @param table the table to base the select on
+	 * @param pathFilter a filter for excluding paths from the select. All paths for which the filter returns
+	 *          {@literal true} will be skipped when determining columns to select.
+	 * @return a select structure suitable for constructing more specialized selects by adding conditions.
+	 * @since 4.0
+	 */
+	public SelectBuilder.SelectWhere createSelectBuilder(Table table, Predicate<AggregatePath> pathFilter) {
+		return createSelectBuilder(table, pathFilter, Collections.emptyList(), Query.empty());
+	}
+
 	/**
 	 * When deleting entities there is a fundamental difference between deleting
 	 * <ol>
@@ -158,44 +177,49 @@ private static boolean isDeeplyNested(AggregatePath path) {
 	 * given {@literal path} to those that reference the root entities specified by the {@literal rootCondition}.
 	 *
 	 * @param path specifies the table and id to select
-	 * @param rootCondition the condition on the root of the path determining what to select
-	 * @param filterColumn the column to apply the IN-condition to.
+	 * @param conditionFunction a function for construction a where clause
+	 * @param columns map making all columns available as a map from {@link AggregatePath}
 	 * @return the IN condition
 	 */
-	private Condition getSubselectCondition(AggregatePath path, Function<Column, Condition> rootCondition,
-			Column filterColumn) {
+	private Condition getSubselectCondition(AggregatePath path,
+			Function<Map<AggregatePath, Column>, Condition> conditionFunction, Map<AggregatePath, Column> columns) {
 
 		AggregatePath parentPath = path.getParentPath();
 
 		if (!parentPath.hasIdProperty()) {
 			if (isDeeplyNested(parentPath)) {
-				return getSubselectCondition(parentPath, rootCondition, filterColumn);
+				return getSubselectCondition(parentPath, conditionFunction, columns);
 			}
-			return rootCondition.apply(filterColumn);
+			return conditionFunction.apply(columns);
 		}
 
-		Table subSelectTable = Table.create(parentPath.getTableInfo().qualifiedTableName());
-		Column idColumn = subSelectTable.column(parentPath.getTableInfo().idColumnName());
-		Column selectFilterColumn = subSelectTable.column(parentPath.getTableInfo().effectiveIdColumnName());
+		AggregatePath.TableInfo parentPathTableInfo = parentPath.getTableInfo();
+		Table subSelectTable = Table.create(parentPathTableInfo.qualifiedTableName());
+
+		Map<AggregatePath, Column> selectFilterColumns = parentPathTableInfo.effectiveIdColumnInfos().toMap(subSelectTable);
 
 		Condition innerCondition;
 
 		if (isFirstNonRoot(parentPath)) { // if the parent is the root of the path
-
 			// apply the rootCondition
-			innerCondition = rootCondition.apply(selectFilterColumn);
+			innerCondition = conditionFunction.apply(selectFilterColumns);
 		} else {
-
 			// otherwise, we need another layer of subselect
-			innerCondition = getSubselectCondition(parentPath, rootCondition, selectFilterColumn);
+			innerCondition = getSubselectCondition(parentPath, conditionFunction, selectFilterColumns);
 		}
 
+		List<Column> idColumns = parentPathTableInfo.idColumnInfos().toColumnList(subSelectTable);
+
 		Select select = Select.builder() //
-				.select(idColumn) //
+				.select(idColumns) //
 				.from(subSelectTable) //
 				.where(innerCondition).build();
 
-		return filterColumn.in(select);
+		return Conditions.in(toExpression(columns), select);
+	}
+
+	private Expression toExpression(Map<AggregatePath, Column> columnsMap) {
+		return Expressions.of(new ArrayList<>(columnsMap.values()));
 	}
 
 	private BindMarker getBindMarker(SqlIdentifier columnName) {
@@ -441,7 +465,7 @@ String createDeleteAllSql(@Nullable PersistentPropertyPath<RelationalPersistentP
 			return render(deleteAll.build());
 		}
 
-		return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), Column::isNotNull);
+		return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), this::isNotNullCondition);
 	}
 
 	/**
@@ -452,8 +476,9 @@ String createDeleteAllSql(@Nullable PersistentPropertyPath<RelationalPersistentP
 	 * @return the statement as a {@link String}. Guaranteed to be not {@literal null}.
 	 */
 	String createDeleteByPath(PersistentPropertyPath<RelationalPersistentProperty> path) {
-		return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path),
-				filterColumn -> filterColumn.isEqualTo(getBindMarker(ROOT_ID_PARAMETER)));
+		// TODO: When deleting by path, why do we expect the where-value to be id and not named after the path?
+		// See SqlGeneratorEmbeddedUnitTests.deleteByPath
+		return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), this::equalityCondition);
 	}
 
 	/**
@@ -464,17 +489,82 @@ String createDeleteByPath(PersistentPropertyPath<RelationalPersistentProperty> p
 	 * @return the statement as a {@link String}. Guaranteed to be not {@literal null}.
 	 */
 	String createDeleteInByPath(PersistentPropertyPath<RelationalPersistentProperty> path) {
+		return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), this::inCondition);
+	}
+
+	/**
+	 * Constructs a where condition. The where condition will be of the form {@literal <columns> IN :bind-marker}
+	 */
+	private Condition inCondition(Map<AggregatePath, Column> columnMap) {
+
+		Collection<Column> columns = columnMap.values();
+
+		return Conditions.in(columns.size() == 1 ? columns.iterator().next() : TupleExpression.create(columns),
+				getBindMarker(IDS_SQL_PARAMETER));
+	}
+
+	/**
+	 * Constructs a where-condition. The where condition will be of the form
+	 * {@literal <column-a> = :bind-marker-a AND <column-b> = :bind-marker-b ...}
+	 */
+	private Condition equalityCondition(Map<AggregatePath, Column> columnMap) {
+
+		Assert.isTrue(!columnMap.isEmpty(), "Column map must not be empty");
+
+		AggregatePath.ColumnInfos idColumnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos();
+
+		return createPredicate(columnMap, (aggregatePath, column) -> {
+			return column.isEqualTo(getBindMarker(idColumnInfos.get(aggregatePath).name()));
+		});
+	}
+
+	/**
+	 * Constructs a function for constructing where a condition. The where condition will be of the form
+	 * {@literal <column-a> IS NOT NULL AND <column-b> IS NOT NULL ... }
+	 */
+	private Condition isNotNullCondition(Map<AggregatePath, Column> columnMap) {
+		return createPredicate(columnMap, (aggregatePath, column) -> column.isNotNull());
+	}
+
+	/**
+	 * Constructs a function for constructing where a condition. The where condition will be of the form
+	 * {@literal <column-a> IS NOT NULL AND <column-b> IS NOT NULL ... }
+	 */
+	private static Condition createPredicate(Map<AggregatePath, Column> columnMap,
+			BiFunction<AggregatePath, Column, Condition> conditionFunction) {
 
-		return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path),
-				filterColumn -> filterColumn.in(getBindMarker(IDS_SQL_PARAMETER)));
+		Condition result = null;
+		for (Map.Entry<AggregatePath, Column> entry : columnMap.entrySet()) {
+
+			Condition singleCondition = conditionFunction.apply(entry.getKey(), entry.getValue());
+			result = result == null ? singleCondition : result.and(singleCondition);
+		}
+		Assert.state(result != null, "We need at least one condition");
+		return result;
 	}
 
 	private String createFindOneSql() {
+		return render(selectBuilder().where(equalityIdWhereCondition()).build());
+	}
 
-		Select select = selectBuilder().where(getIdColumn().isEqualTo(getBindMarker(ID_SQL_PARAMETER))) //
-				.build();
+	private Condition equalityIdWhereCondition() {
+		return equalityIdWhereCondition(getIdColumns());
+	}
 
-		return render(select);
+	private Condition equalityIdWhereCondition(Iterable<Column> columns) {
+
+		Assert.isTrue(columns.iterator().hasNext(), "Identifier columns must not be empty");
+
+		Condition aggregate = null;
+		for (Column column : columns) {
+
+			Comparison condition = column.isEqualTo(getBindMarker(column.getName()));
+			aggregate = aggregate == null ? condition : aggregate.and(condition);
+		}
+
+		Assert.state(aggregate != null, "We need at least one id column");
+
+		return aggregate;
 	}
 
 	private String createAcquireLockById(LockMode lockMode) {
@@ -482,9 +572,9 @@ private String createAcquireLockById(LockMode lockMode) {
 		Table table = this.getTable();
 
 		Select select = StatementBuilder //
-				.select(getIdColumn()) //
+				.select(getSingleNonNullColumn()) //
 				.from(table) //
-				.where(getIdColumn().isEqualTo(getBindMarker(ID_SQL_PARAMETER))) //
+				.where(equalityIdWhereCondition()) //
 				.lock(lockMode) //
 				.build();
 
@@ -496,7 +586,7 @@ private String createAcquireLockAll(LockMode lockMode) {
 		Table table = this.getTable();
 
 		Select select = StatementBuilder //
-				.select(getIdColumn()) //
+				.select(getSingleNonNullColumn()) //
 				.from(table) //
 				.lock(lockMode) //
 				.build();
@@ -521,21 +611,24 @@ private SelectBuilder.SelectWhere selectBuilder(Collection<SqlIdentifier> keyCol
 	}
 
 	private SelectBuilder.SelectWhere selectBuilder(Collection<SqlIdentifier> keyColumns, Query query) {
+		return createSelectBuilder(getTable(), ap -> false, keyColumns, query);
+	}
 
-		Table table = getTable();
+	private SelectBuilder.SelectWhere createSelectBuilder(Table table, Predicate<AggregatePath> pathFilter,
+			Collection<SqlIdentifier> keyColumns, Query query) {
 
-		Projection projection = getProjection(keyColumns, query, table);
-		SelectBuilder.SelectAndFrom selectBuilder = StatementBuilder.select(projection.columns());
-		SelectBuilder.SelectJoin baseSelect = selectBuilder.from(table);
+		Projection projection = getProjection(pathFilter, keyColumns, query, table);
+		SelectBuilder.SelectJoin baseSelect = StatementBuilder.select(projection.columns()).from(table);
 
-		for (Join join : projection.joins()) {
-			baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(join.joinColumn).equals(join.parentId);
-		}
+		return (SelectBuilder.SelectWhere) addJoins(baseSelect, projection.joins());
+	}
 
-		return (SelectBuilder.SelectWhere) baseSelect;
+	private static SelectBuilder.SelectJoin addJoins(SelectBuilder.SelectJoin baseSelect, Joins joins) {
+		return joins.reduce(baseSelect, (join, select) -> select.leftOuterJoin(join.joinTable).on(join.condition));
 	}
 
-	private Projection getProjection(Collection<SqlIdentifier> keyColumns, Query query, Table table) {
+	private Projection getProjection(Predicate<AggregatePath> pathFilter, Collection<SqlIdentifier> keyColumns,
+			Query query, Table table) {
 
 		Set<Expression> columns = new LinkedHashSet<>();
 		Set<Join> joins = new LinkedHashSet<>();
@@ -546,7 +639,7 @@ private Projection getProjection(Collection<SqlIdentifier> keyColumns, Query que
 				AggregatePath aggregatePath = mappingContext.getAggregatePath(
 						mappingContext.getPersistentPropertyPath(columnName.getReference(), entity.getTypeInformation()));
 
-				includeColumnAndJoin(aggregatePath, joins, columns);
+				includeColumnAndJoin(aggregatePath, pathFilter, joins, columns);
 			} catch (InvalidPersistentPropertyPath e) {
 				columns.add(Column.create(columnName, table));
 			}
@@ -559,7 +652,11 @@ private Projection getProjection(Collection<SqlIdentifier> keyColumns, Query que
 
 				AggregatePath aggregatePath = mappingContext.getAggregatePath(path);
 
-				includeColumnAndJoin(aggregatePath, joins, columns);
+				if (pathFilter.test(aggregatePath)) {
+					continue;
+				}
+
+				includeColumnAndJoin(aggregatePath, pathFilter, joins, columns);
 			}
 		}
 
@@ -567,11 +664,29 @@ private Projection getProjection(Collection<SqlIdentifier> keyColumns, Query que
 			columns.add(table.column(keyColumn).as(keyColumn));
 		}
 
-		return new Projection(columns, joins);
+		return new Projection(columns, Joins.of(joins));
 	}
 
-	private void includeColumnAndJoin(AggregatePath aggregatePath, Collection<Join> joins,
-			Collection<Expression> columns) {
+	private void includeColumnAndJoin(AggregatePath aggregatePath, Predicate<AggregatePath> pathFilter,
+			Collection<Join> joins, Collection<Expression> columns) {
+
+		if (aggregatePath.isEmbedded()) {
+
+			RelationalPersistentEntity<?> entity = aggregatePath.getRequiredLeafEntity();
+
+			for (RelationalPersistentProperty property : entity) {
+
+				AggregatePath nested = aggregatePath.append(property);
+
+				if (pathFilter.test(nested)) {
+					continue;
+				}
+
+				includeColumnAndJoin(nested, pathFilter, joins, columns);
+			}
+
+			return;
+		}
 
 		joins.addAll(getJoins(aggregatePath));
 
@@ -587,7 +702,24 @@ private void includeColumnAndJoin(AggregatePath aggregatePath, Collection<Join>
 	 * @param columns
 	 * @param joins
 	 */
-	record Projection(Set<Expression> columns, Set<Join> joins) {
+	record Projection(Collection<Expression> columns, Joins joins) {
+
+	}
+
+	record Joins(Collection<Join> joins) {
+
+		public static Joins of(Collection<Join> joins) {
+			return new Joins(joins);
+		}
+
+		public <T> T reduce(T identity, BiFunction<Join, T, T> accumulator) {
+
+			T result = identity;
+			for (Join join : joins) {
+				result = accumulator.apply(join, result);
+			}
+			return result;
+		}
 	}
 
 	private SelectBuilder.SelectOrdered selectBuilder(Collection<SqlIdentifier> keyColumns, Sort sort,
@@ -644,7 +776,7 @@ Column getColumn(AggregatePath path) {
 				return null;
 			}
 
-			return sqlContext.getReverseColumn(path);
+			return sqlContext.getAnyReverseColumn(path);
 		}
 
 		return sqlContext.getColumn(path);
@@ -673,32 +805,44 @@ Join getJoin(AggregatePath path) {
 		}
 
 		Table currentTable = sqlContext.getTable(path);
+		AggregatePath.ColumnInfos backRefColumnInfos = path.getTableInfo().backReferenceColumnInfos();
 
 		AggregatePath idDefiningParentPath = path.getIdDefiningParentPath();
 		Table parentTable = sqlContext.getTable(idDefiningParentPath);
+		AggregatePath.ColumnInfos idColumnInfos = idDefiningParentPath.getTableInfo().idColumnInfos();
 
-		return new Join( //
-				currentTable, //
-				currentTable.column(path.getTableInfo().reverseColumnInfo().name()), //
-				parentTable.column(idDefiningParentPath.getTableInfo().idColumnName()) //
-		);
+		Condition joinCondition = backRefColumnInfos.reduce(Conditions.unrestricted(), (aggregatePath, columnInfo) -> {
+
+			return currentTable.column(columnInfo.name())
+					.isEqualTo(parentTable.column(idColumnInfos.get(aggregatePath).name()));
+		}, Condition::and);
+
+		return new Join(currentTable, joinCondition);
 	}
 
 	private String createFindAllInListSql() {
 
-		Select select = selectBuilder().where(getIdColumn().in(getBindMarker(IDS_SQL_PARAMETER))).build();
+		In condition = idInWhereClause();
+		Select select = selectBuilder().where(condition).build();
 
 		return render(select);
 	}
 
-	private String createExistsSql() {
+	private In idInWhereClause() {
+
+		List<Column> idColumns = getIdColumns();
+		Expression expression = idColumns.size() == 1 ? idColumns.get(0) : TupleExpression.create(idColumns);
 
+		return Conditions.in(expression, getBindMarker(IDS_SQL_PARAMETER));
+	}
+
+	private String createExistsSql() {
 		Table table = getTable();
 
 		Select select = StatementBuilder //
-				.select(Functions.count(getIdColumn())) //
+				.select(Functions.count(getSingleNonNullColumn())) //
 				.from(table) //
-				.where(getIdColumn().isEqualTo(getBindMarker(ID_SQL_PARAMETER))) //
+				.where(equalityIdWhereCondition()) //
 				.build();
 
 		return render(select);
@@ -769,7 +913,7 @@ private UpdateBuilder.UpdateWhereAndOr createBaseUpdate() {
 		return Update.builder() //
 				.table(table) //
 				.set(assignments) //
-				.where(getIdColumn().isEqualTo(getBindMarker(entity.getIdColumn())));
+				.where(equalityIdWhereCondition());
 	}
 
 	private String createDeleteByIdSql() {
@@ -792,16 +936,17 @@ private String createDeleteByIdAndVersionSql() {
 	private DeleteBuilder.DeleteWhereAndOr createBaseDeleteById(Table table) {
 
 		return Delete.builder().from(table) //
-				.where(getIdColumn().isEqualTo(getBindMarker(ID_SQL_PARAMETER)));
+				.where(equalityIdWhereCondition());
 	}
 
 	private DeleteBuilder.DeleteWhereAndOr createBaseDeleteByIdIn(Table table) {
 
 		return Delete.builder().from(table) //
-				.where(getIdColumn().in(getBindMarker(IDS_SQL_PARAMETER)));
+				.where(idInWhereClause());
 	}
 
-	private String createDeleteByPathAndCriteria(AggregatePath path, Function<Column, Condition> rootCondition) {
+	private String createDeleteByPathAndCriteria(AggregatePath path,
+			Function<Map<AggregatePath, Column>, Condition> multiIdCondition) {
 
 		Table table = Table.create(path.getTableInfo().qualifiedTableName());
 
@@ -809,16 +954,17 @@ private String createDeleteByPathAndCriteria(AggregatePath path, Function<Column
 				.from(table);
 		Delete delete;
 
-		Column filterColumn = table.column(path.getTableInfo().reverseColumnInfo().name());
+		AggregatePath.ColumnInfos columnInfos = path.getTableInfo().backReferenceColumnInfos();
+		Map<AggregatePath, Column> columns = columnInfos.toMap(table);
 
 		if (isFirstNonRoot(path)) {
 
 			delete = builder //
-					.where(rootCondition.apply(filterColumn)) //
+					.where(multiIdCondition.apply(columns)) //
 					.build();
 		} else {
 
-			Condition condition = getSubselectCondition(path, rootCondition, filterColumn);
+			Condition condition = getSubselectCondition(path, multiIdCondition, columns);
 			delete = builder.where(condition).build();
 		}
 
@@ -831,7 +977,7 @@ private String createDeleteByListSql() {
 
 		Delete delete = Delete.builder() //
 				.from(table) //
-				.where(getIdColumn().in(getBindMarker(IDS_SQL_PARAMETER))) //
+				.where(idInWhereClause()) //
 				.build();
 
 		return render(delete);
@@ -857,8 +1003,23 @@ private Table getTable() {
 		return sqlContext.getTable();
 	}
 
-	private Column getIdColumn() {
-		return sqlContext.getIdColumn();
+	/**
+	 * @return a single column of the primary key to be used in places where one need something not null to be selected.
+	 */
+	private Column getSingleNonNullColumn() {
+		return doGetColumn(AggregatePath.ColumnInfos::any);
+	}
+
+	private List<Column> getIdColumns() {
+		return doGetColumn(AggregatePath.ColumnInfos::toColumnList);
+	}
+
+	private <T> T doGetColumn(
+			BiFunction<AggregatePath.ColumnInfos, BiFunction<AggregatePath, AggregatePath.ColumnInfo, Column>, T> columnListFunction) {
+
+		AggregatePath.ColumnInfos columnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos();
+
+		return columnListFunction.apply(columnInfos, (aggregatePath, columnInfo) -> sqlContext.getColumn(aggregatePath));
 	}
 
 	private Column getVersionColumn() {
@@ -1015,7 +1176,8 @@ private SelectBuilder.SelectJoin getExistsSelect() {
 				.select(dialect.getExistsFunction()) //
 				.from(table);
 
-		// add possible joins
+		// collect joins
+		List<Join> joins = new ArrayList<>();
 		for (PersistentPropertyPath<RelationalPersistentProperty> path : mappingContext
 				.findPersistentPropertyPaths(entity.getType(), p -> true)) {
 
@@ -1024,10 +1186,11 @@ private SelectBuilder.SelectJoin getExistsSelect() {
 			// add a join if necessary
 			Join join = getJoin(aggregatePath);
 			if (join != null) {
-				baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(join.joinColumn).equals(join.parentId);
+				joins.add(join);
 			}
 		}
-		return baseSelect;
+
+		return addJoins(baseSelect, Joins.of(joins));
 	}
 
 	/**
@@ -1049,6 +1212,7 @@ private SelectBuilder.SelectJoin getSelectCountWithExpression(Expression... coun
 				.select(Functions.count(countExpressions)) //
 				.from(table);
 
+		List<Join> joins = new ArrayList<>();
 		// add possible joins
 		for (PersistentPropertyPath<RelationalPersistentProperty> path : mappingContext
 				.findPersistentPropertyPaths(entity.getType(), p -> true)) {
@@ -1058,10 +1222,10 @@ private SelectBuilder.SelectJoin getSelectCountWithExpression(Expression... coun
 			// add a join if necessary
 			Join join = getJoin(extPath);
 			if (join != null) {
-				baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(join.joinColumn).equals(join.parentId);
+				joins.add(join);
 			}
 		}
-		return baseSelect;
+		return addJoins(baseSelect, Joins.of(joins));
 	}
 
 	private SelectBuilder.SelectOrdered applyQueryOnSelect(Query query, MapSqlParameterSource parameterSource,
@@ -1102,62 +1266,7 @@ SelectBuilder.SelectOrdered applyCriteria(@Nullable CriteriaDefinition criteria,
 	/**
 	 * Value object representing a {@code JOIN} association.
 	 */
-	static final class Join {
-
-		private final Table joinTable;
-		private final Column joinColumn;
-		private final Column parentId;
-
-		Join(Table joinTable, Column joinColumn, Column parentId) {
-
-			Assert.notNull(joinTable, "JoinTable must not be null");
-			Assert.notNull(joinColumn, "JoinColumn must not be null");
-			Assert.notNull(parentId, "ParentId must not be null");
-
-			this.joinTable = joinTable;
-			this.joinColumn = joinColumn;
-			this.parentId = parentId;
-		}
-
-		Table getJoinTable() {
-			return this.joinTable;
-		}
-
-		Column getJoinColumn() {
-			return this.joinColumn;
-		}
-
-		Column getParentId() {
-			return this.parentId;
-		}
-
-		@Override
-		public boolean equals(@Nullable Object o) {
-
-			if (this == o) {
-				return true;
-			}
-			if (o == null || getClass() != o.getClass()) {
-				return false;
-			}
-			Join join = (Join) o;
-			return joinTable.equals(join.joinTable) && joinColumn.equals(join.joinColumn) && parentId.equals(join.parentId);
-		}
-
-		@Override
-		public int hashCode() {
-			return Objects.hash(joinTable, joinColumn, parentId);
-		}
-
-		@Override
-		public String toString() {
-
-			return "Join{" + //
-					"joinTable=" + joinTable + //
-					", joinColumn=" + joinColumn + //
-					", parentId=" + parentId + //
-					'}';
-		}
+	record Join(Table joinTable, Condition condition) {
 	}
 
 	/**
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGeneratorSource.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGeneratorSource.java
index 0a217dce63..5f5d9de361 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGeneratorSource.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGeneratorSource.java
@@ -56,7 +56,7 @@ public Dialect getDialect() {
 		return dialect;
 	}
 
-	SqlGenerator getSqlGenerator(Class<?> domainType) {
+	public SqlGenerator getSqlGenerator(Class<?> domainType) {
 
 		return CACHE.computeIfAbsent(domainType,
 				t -> new SqlGenerator(context, converter, context.getRequiredPersistentEntity(t), dialect));
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java
index 78ff82deb2..209aa7108d 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java
@@ -35,8 +35,8 @@
  */
 class SqlIdentifierParameterSource extends AbstractSqlParameterSource {
 
-	private final Set<SqlIdentifier> identifiers = new HashSet<>();
-	private final Map<String, Object> namesToValues = new HashMap<>();
+	private final Set<SqlIdentifier> identifiers = new HashSet<>(16, 1f);
+	private final Map<String, Object> namesToValues = new HashMap<>(16, 1f);
 
 	@Override
 	public boolean hasValue(String paramName) {
@@ -73,7 +73,7 @@ void addAll(SqlIdentifierParameterSource others) {
 
 		for (SqlIdentifier identifier : others.getIdentifiers()) {
 
-			String name = BindParameterNameSanitizer.sanitize( identifier.getReference());
+			String name = BindParameterNameSanitizer.sanitize(identifier.getReference());
 			addValue(identifier, others.getValue(name), others.getSqlType(name));
 		}
 	}
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java
index 8bf9bb869f..14a4743352 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java
@@ -17,22 +17,25 @@
 
 import java.sql.SQLType;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.function.BiFunction;
 import java.util.function.Predicate;
 
 import org.springframework.data.jdbc.core.mapping.JdbcValue;
 import org.springframework.data.jdbc.support.JdbcUtil;
 import org.springframework.data.mapping.PersistentProperty;
 import org.springframework.data.mapping.PersistentPropertyAccessor;
+import org.springframework.data.mapping.PersistentPropertyPathAccessor;
 import org.springframework.data.relational.core.conversion.IdValueSource;
+import org.springframework.data.relational.core.mapping.AggregatePath;
 import org.springframework.data.relational.core.mapping.RelationalMappingContext;
 import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
 import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
+import org.springframework.data.relational.core.mapping.RelationalPredicates;
 import org.springframework.data.relational.core.sql.SqlIdentifier;
-import org.springframework.jdbc.support.JdbcUtils;
 import org.springframework.lang.Nullable;
-import org.springframework.util.Assert;
 
 /**
  * Creates the {@link SqlIdentifierParameterSource} for various SQL operations, dialect identifier processing rules and
@@ -41,9 +44,11 @@
  * @author Jens Schauder
  * @author Chirag Tailor
  * @author Mikhail Polivakha
+ * @author Mark Paluch
  * @since 2.4
  */
 public class SqlParametersFactory {
+
 	private final RelationalMappingContext context;
 	private final JdbcConverter converter;
 
@@ -78,9 +83,17 @@ <T> SqlIdentifierParameterSource forInsert(T instance, Class<T> domainType, Iden
 
 		if (IdValueSource.PROVIDED.equals(idValueSource)) {
 
-			RelationalPersistentProperty idProperty = persistentEntity.getRequiredIdProperty();
-			Object idValue = persistentEntity.getIdentifierAccessor(instance).getRequiredIdentifier();
-			addConvertedPropertyValue(parameterSource, idProperty, idValue, idProperty.getColumnName());
+			PersistentPropertyPathAccessor<T> propertyPathAccessor = persistentEntity.getPropertyPathAccessor(instance);
+
+			AggregatePath.ColumnInfos columnInfos = context.getAggregatePath(persistentEntity).getTableInfo().idColumnInfos();
+
+			//  fullPath: because we use the result with a PropertyPathAccessor
+			columnInfos.forEach((ap, __) -> {
+				Object idValue = propertyPathAccessor.getProperty(ap.getRequiredPersistentPropertyPath());
+				RelationalPersistentProperty idProperty = ap.getRequiredLeafProperty();
+				addConvertedPropertyValue(parameterSource, idProperty, idValue, idProperty.getColumnName());
+			});
+
 		}
 		return parameterSource;
 	}
@@ -104,21 +117,25 @@ <T> SqlIdentifierParameterSource forUpdate(T instance, Class<T> domainType) {
 	 *
 	 * @param id the entity id. Must not be {@code null}.
 	 * @param domainType the type of the instance. Must not be {@code null}.
-	 * @param name the name to be used for the id parameter.
 	 * @return the {@link SqlIdentifierParameterSource} for the query. Guaranteed to not be {@code null}.
 	 * @since 2.4
 	 */
-	<T> SqlIdentifierParameterSource forQueryById(Object id, Class<T> domainType, SqlIdentifier name) {
+	<T> SqlIdentifierParameterSource forQueryById(Object id, Class<T> domainType) {
 
-		SqlIdentifierParameterSource parameterSource = new SqlIdentifierParameterSource();
+		return doWithIdentifiers(domainType, (columns, idProperty, complexId) -> {
 
-		addConvertedPropertyValue( //
-				parameterSource, //
-				getRequiredPersistentEntity(domainType).getRequiredIdProperty(), //
-				id, //
-				name //
-		);
-		return parameterSource;
+			SqlIdentifierParameterSource parameterSource = new SqlIdentifierParameterSource();
+			BiFunction<Object, AggregatePath, Object> valueExtractor = getIdMapper(complexId);
+
+			columns.forEach((ap, ci) -> addConvertedPropertyValue( //
+					parameterSource, //
+					ap.getRequiredLeafProperty(), //
+					valueExtractor.apply(id, ap), //
+					ci.name() //
+			));
+
+			return parameterSource;
+		});
 	}
 
 	/**
@@ -131,12 +148,44 @@ <T> SqlIdentifierParameterSource forQueryById(Object id, Class<T> domainType, Sq
 	 */
 	<T> SqlIdentifierParameterSource forQueryByIds(Iterable<?> ids, Class<T> domainType) {
 
-		SqlIdentifierParameterSource parameterSource = new SqlIdentifierParameterSource();
+		return doWithIdentifiers(domainType, (columns, idProperty, complexId) -> {
 
-		addConvertedPropertyValuesAsList(parameterSource, getRequiredPersistentEntity(domainType).getRequiredIdProperty(),
-				ids);
+			SqlIdentifierParameterSource parameterSource = new SqlIdentifierParameterSource();
 
-		return parameterSource;
+			BiFunction<Object, AggregatePath, Object> valueExtractor = getIdMapper(complexId);
+
+			List<Object[]> parameterValues = new ArrayList<>(ids instanceof Collection<?> c ? c.size() : 16);
+			for (Object id : ids) {
+
+				Object[] tupleList = new Object[columns.size()];
+
+				int i = 0;
+				for (AggregatePath path : columns.paths()) {
+					tupleList[i++] = valueExtractor.apply(id, path);
+				}
+
+				parameterValues.add(tupleList);
+			}
+
+			parameterSource.addValue(SqlGenerator.IDS_SQL_PARAMETER, parameterValues);
+			return parameterSource;
+		});
+	}
+
+	private <T> T doWithIdentifiers(Class<?> domainType, IdentifierCallback<T> callback) {
+
+		RelationalPersistentEntity<?> entity = context.getRequiredPersistentEntity(domainType);
+		RelationalPersistentProperty idProperty = entity.getRequiredIdProperty();
+		RelationalPersistentEntity<?> complexId = context.getPersistentEntity(idProperty);
+		AggregatePath.ColumnInfos columns = context.getAggregatePath(entity).getTableInfo().idColumnInfos();
+
+		return callback.doWithIdentifiers(columns, idProperty, complexId);
+	}
+
+	interface IdentifierCallback<T> {
+
+		T doWithIdentifiers(AggregatePath.ColumnInfos columns, RelationalPersistentProperty idProperty,
+				RelationalPersistentEntity<?> complexId);
 	}
 
 	/**
@@ -156,19 +205,17 @@ SqlIdentifierParameterSource forQueryByIdentifier(Identifier identifier) {
 		return parameterSource;
 	}
 
-	/**
-	 * Utility to create {@link Predicate}s.
-	 */
-	static class Predicates {
-
-		/**
-		 * Include all {@link Predicate} returning {@literal false} to never skip a property.
-		 *
-		 * @return the include all {@link Predicate}.
-		 */
-		static Predicate<RelationalPersistentProperty> includeAll() {
-			return it -> false;
+	private BiFunction<Object, AggregatePath, Object> getIdMapper(@Nullable RelationalPersistentEntity<?> complexId) {
+
+		if (complexId == null) {
+			return (id, aggregatePath) -> id;
 		}
+
+		return (id, aggregatePath) -> {
+
+			PersistentPropertyAccessor<Object> accessor = complexId.getPropertyAccessor(id);
+			return accessor.getProperty(aggregatePath.getRequiredLeafProperty());
+		};
 	}
 
 	private void addConvertedPropertyValue(SqlIdentifierParameterSource parameterSource,
@@ -199,28 +246,6 @@ private void addConvertedValue(SqlIdentifierParameterSource parameterSource, @Nu
 				jdbcValue.getJdbcType().getVendorTypeNumber());
 	}
 
-	private void addConvertedPropertyValuesAsList(SqlIdentifierParameterSource parameterSource,
-			RelationalPersistentProperty property, Iterable<?> values) {
-
-		List<Object> convertedIds = new ArrayList<>();
-		JdbcValue jdbcValue = null;
-		for (Object id : values) {
-
-			Class<?> columnType = converter.getColumnType(property);
-			SQLType sqlType = converter.getTargetSqlType(property);
-
-			jdbcValue = converter.writeJdbcValue(id, columnType, sqlType);
-			convertedIds.add(jdbcValue.getValue());
-		}
-
-		Assert.state(jdbcValue != null, "JdbcValue must be not null at this point; Please report this as a bug");
-
-		SQLType jdbcType = jdbcValue.getJdbcType();
-		int typeNumber = jdbcType == null ? JdbcUtils.TYPE_UNKNOWN : jdbcType.getVendorTypeNumber();
-
-		parameterSource.addValue(SqlGenerator.IDS_SQL_PARAMETER, convertedIds, typeNumber);
-	}
-
 	@SuppressWarnings("unchecked")
 	private <S> RelationalPersistentEntity<S> getRequiredPersistentEntity(Class<S> domainType) {
 		return (RelationalPersistentEntity<S>) context.getRequiredPersistentEntity(domainType);
@@ -235,12 +260,14 @@ private <S, T> SqlIdentifierParameterSource getParameterSource(@Nullable S insta
 		PersistentPropertyAccessor<S> propertyAccessor = instance != null ? persistentEntity.getPropertyAccessor(instance)
 				: NoValuePropertyAccessor.instance();
 
+
 		persistentEntity.doWithAll(property -> {
 
 			if (skipProperty.test(property) || !property.isWritable()) {
 				return;
 			}
-			if (property.isEntity() && !property.isEmbedded()) {
+
+			if (RelationalPredicates.isRelation(property)) {
 				return;
 			}
 
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/schema/Tables.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/schema/Tables.java
index c8d28cc309..e77cdd6884 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/schema/Tables.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/schema/Tables.java
@@ -33,6 +33,7 @@
 import org.springframework.data.relational.core.mapping.RelationalMappingContext;
 import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
 import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
+import org.springframework.data.relational.core.mapping.RelationalPredicates;
 import org.springframework.data.relational.core.sql.SqlIdentifier;
 import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
@@ -68,7 +69,7 @@ public static Tables from(Stream<? extends RelationalPersistentEntity<?>> persis
 
 					for (RelationalPersistentProperty property : entity) {
 
-						if (property.isEntity() && !property.isEmbedded()) {
+						if (RelationalPredicates.isRelation(property)) {
 							foreignKeyMetadataList.add(createForeignKeyMetadata(entity, property, context, sqlTypeMapping));
 							continue;
 						}
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java
index a7d187b441..d3968b18a0 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java
@@ -17,12 +17,11 @@
 
 import java.util.ArrayList;
 import java.util.List;
-import java.util.stream.Stream;
+import java.util.function.Consumer;
 
 import org.springframework.data.domain.Sort;
 import org.springframework.data.jdbc.core.convert.JdbcConverter;
 import org.springframework.data.jdbc.core.convert.QueryMapper;
-import org.springframework.data.mapping.Parameter;
 import org.springframework.data.mapping.PersistentPropertyPath;
 import org.springframework.data.relational.core.dialect.Dialect;
 import org.springframework.data.relational.core.dialect.RenderContextFactory;
@@ -31,10 +30,13 @@
 import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
 import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
 import org.springframework.data.relational.core.query.Criteria;
+import org.springframework.data.relational.core.sql.Column;
 import org.springframework.data.relational.core.sql.Condition;
 import org.springframework.data.relational.core.sql.Conditions;
 import org.springframework.data.relational.core.sql.Delete;
 import org.springframework.data.relational.core.sql.DeleteBuilder.DeleteWhere;
+import org.springframework.data.relational.core.sql.Expression;
+import org.springframework.data.relational.core.sql.Expressions;
 import org.springframework.data.relational.core.sql.Select;
 import org.springframework.data.relational.core.sql.SelectBuilder.SelectWhere;
 import org.springframework.data.relational.core.sql.StatementBuilder;
@@ -44,13 +46,14 @@
 import org.springframework.data.relational.repository.query.RelationalParameterAccessor;
 import org.springframework.data.relational.repository.query.RelationalQueryCreator;
 import org.springframework.data.repository.query.parser.PartTree;
+import org.springframework.data.util.Predicates;
 import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
 import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 /**
- * Implementation of {@link RelationalQueryCreator} that creates {@link List} of deletion {@link ParametrizedQuery}
- * from a {@link PartTree}.
+ * Implementation of {@link RelationalQueryCreator} that creates {@link List} of deletion {@link ParametrizedQuery} from
+ * a {@link PartTree}.
  *
  * @author Yunyoung LEE
  * @author Nikita Konev
@@ -96,18 +99,18 @@ protected List<ParametrizedQuery> complete(@Nullable Criteria criteria, Sort sor
 		Table table = Table.create(entityMetadata.getTableName());
 		MapSqlParameterSource parameterSource = new MapSqlParameterSource();
 
-		SqlContext sqlContext = new SqlContext(entity);
-
 		Condition condition = criteria == null ? null
 				: queryMapper.getMappedObject(parameterSource, criteria, table, entity);
 
+		List<Column> idColumns = context.getAggregatePath(entity).getTableInfo().idColumnInfos().toColumnList(table);
+
 		// create select criteria query for subselect
-		SelectWhere selectBuilder = StatementBuilder.select(sqlContext.getIdColumn()).from(table);
+		SelectWhere selectBuilder = StatementBuilder.select(idColumns).from(table);
 		Select select = condition == null ? selectBuilder.build() : selectBuilder.where(condition).build();
 
 		// create delete relation queries
 		List<Delete> deleteChain = new ArrayList<>();
-		deleteRelations(deleteChain, entity, select);
+		deleteRelations(entity, select, deleteChain::add);
 
 		// crate delete query
 		DeleteWhere deleteBuilder = StatementBuilder.delete(table);
@@ -125,34 +128,39 @@ protected List<ParametrizedQuery> complete(@Nullable Criteria criteria, Sort sor
 		return queries;
 	}
 
-	private void deleteRelations(List<Delete> deleteChain, RelationalPersistentEntity<?> entity, Select parentSelect) {
+	private void deleteRelations(RelationalPersistentEntity<?> entity, Select parentSelect,
+			Consumer<Delete> deleteConsumer) {
 
 		for (PersistentPropertyPath<RelationalPersistentProperty> path : context
-				.findPersistentPropertyPaths(entity.getType(), p -> true)) {
+				.findPersistentPropertyPaths(entity.getType(), Predicates.isTrue())) {
 
 			AggregatePath aggregatePath = context.getAggregatePath(path);
 
-			// prevent duplication on recursive call
-			if (path.getLength() > 1 && !aggregatePath.getParentPath().isEmbedded()) {
+			if (aggregatePath.isEmbedded() || !aggregatePath.isEntity()) {
 				continue;
 			}
 
-			if (aggregatePath.isEntity() && !aggregatePath.isEmbedded()) {
+			SqlContext sqlContext = new SqlContext();
 
-				SqlContext sqlContext = new SqlContext(aggregatePath.getLeafEntity());
+			// MariaDB prior to 11.6 does not support aliases for delete statements
+			Table table = sqlContext.getUnaliasedTable(aggregatePath);
 
-				Condition inCondition = Conditions
-						.in(sqlContext.getTable().column(aggregatePath.getTableInfo().reverseColumnInfo().name()), parentSelect);
+			List<Column> reverseColumns = aggregatePath.getTableInfo().backReferenceColumnInfos().toColumnList(table);
+			Expression expression = Expressions.of(reverseColumns);
 
-				Select select = StatementBuilder.select( //
-						sqlContext.getTable().column(aggregatePath.getIdDefiningParentPath().getTableInfo().idColumnName()) //
-				).from(sqlContext.getTable()) //
-						.where(inCondition) //
-						.build();
-				deleteRelations(deleteChain, aggregatePath.getLeafEntity(), select);
+			Condition inCondition = Conditions.in(expression, parentSelect);
 
-				deleteChain.add(StatementBuilder.delete(sqlContext.getTable()).where(inCondition).build());
-			}
+			List<Column> parentIdColumns = aggregatePath.getIdDefiningParentPath().getTableInfo().idColumnInfos()
+					.toColumnList(table);
+
+			Select select = StatementBuilder.select( //
+					parentIdColumns //
+			).from(table) //
+					.where(inCondition) //
+					.build();
+			deleteRelations(aggregatePath.getLeafEntity(), select, deleteConsumer);
+
+			deleteConsumer.accept(StatementBuilder.delete(table).where(inCondition).build());
 		}
 	}
 }
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java
index cc28ff2f18..fa7202a4a8 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java
@@ -15,15 +15,14 @@
  */
 package org.springframework.data.jdbc.repository.query;
 
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
 import java.util.Optional;
+import java.util.function.Predicate;
 
 import org.springframework.data.domain.Pageable;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.jdbc.core.convert.JdbcConverter;
 import org.springframework.data.jdbc.core.convert.QueryMapper;
+import org.springframework.data.jdbc.core.convert.SqlGeneratorSource;
 import org.springframework.data.mapping.PersistentPropertyPath;
 import org.springframework.data.relational.core.dialect.Dialect;
 import org.springframework.data.relational.core.dialect.RenderContextFactory;
@@ -33,12 +32,10 @@
 import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
 import org.springframework.data.relational.core.query.Criteria;
 import org.springframework.data.relational.core.sql.Column;
-import org.springframework.data.relational.core.sql.Expression;
 import org.springframework.data.relational.core.sql.Expressions;
 import org.springframework.data.relational.core.sql.Functions;
 import org.springframework.data.relational.core.sql.Select;
 import org.springframework.data.relational.core.sql.SelectBuilder;
-import org.springframework.data.relational.core.sql.StatementBuilder;
 import org.springframework.data.relational.core.sql.Table;
 import org.springframework.data.relational.core.sql.render.SqlRenderer;
 import org.springframework.data.relational.repository.Lock;
@@ -73,6 +70,7 @@ class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> {
 	private final boolean isSliceQuery;
 	private final ReturnedType returnedType;
 	private final Optional<Lock> lockMode;
+	private final SqlGeneratorSource sqlGeneratorSource;
 
 	/**
 	 * Creates new instance of this class with the given {@link PartTree}, {@link JdbcConverter}, {@link Dialect},
@@ -86,16 +84,45 @@ class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> {
 	 * @param accessor parameter metadata provider, must not be {@literal null}.
 	 * @param isSliceQuery flag denoting if the query returns a {@link org.springframework.data.domain.Slice}.
 	 * @param returnedType the {@link ReturnedType} to be returned by the query. Must not be {@literal null}.
+	 * @deprecated use
+	 *             {@link JdbcQueryCreator#JdbcQueryCreator(RelationalMappingContext, PartTree, JdbcConverter, Dialect, RelationalEntityMetadata, RelationalParameterAccessor, boolean, ReturnedType, Optional, SqlGeneratorSource)}
+	 *             instead.
 	 */
+	@Deprecated(since = "4.0", forRemoval = true)
 	JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect,
 			RelationalEntityMetadata<?> entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery,
 			ReturnedType returnedType, Optional<Lock> lockMode) {
+		this(context, tree, converter, dialect, entityMetadata, accessor, isSliceQuery, returnedType, lockMode,
+				new SqlGeneratorSource(context, converter, dialect));
+	}
+
+	/**
+	 * Creates new instance of this class with the given {@link PartTree}, {@link JdbcConverter}, {@link Dialect},
+	 * {@link RelationalEntityMetadata} and {@link RelationalParameterAccessor}.
+	 *
+	 * @param context the mapping context. Must not be {@literal null}.
+	 * @param tree part tree, must not be {@literal null}.
+	 * @param converter must not be {@literal null}.
+	 * @param dialect must not be {@literal null}.
+	 * @param entityMetadata relational entity metadata, must not be {@literal null}.
+	 * @param accessor parameter metadata provider, must not be {@literal null}.
+	 * @param isSliceQuery flag denoting if the query returns a {@link org.springframework.data.domain.Slice}.
+	 * @param returnedType the {@link ReturnedType} to be returned by the query. Must not be {@literal null}.
+	 * @param lockMode lock mode to be used for the query.
+	 * @param sqlGeneratorSource the source providing SqlGenerator instances for generating SQL. Must not be
+	 *          {@literal null}
+	 * @since 4.0
+	 */
+	JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect,
+			RelationalEntityMetadata<?> entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery,
+			ReturnedType returnedType, Optional<Lock> lockMode, SqlGeneratorSource sqlGeneratorSource) {
 		super(tree, accessor);
 
 		Assert.notNull(converter, "JdbcConverter must not be null");
 		Assert.notNull(dialect, "Dialect must not be null");
 		Assert.notNull(entityMetadata, "Relational entity metadata must not be null");
 		Assert.notNull(returnedType, "ReturnedType must not be null");
+		Assert.notNull(sqlGeneratorSource, "SqlGeneratorSource must not be null");
 
 		this.context = context;
 		this.tree = tree;
@@ -107,6 +134,7 @@ class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> {
 		this.isSliceQuery = isSliceQuery;
 		this.returnedType = returnedType;
 		this.lockMode = lockMode;
+		this.sqlGeneratorSource = sqlGeneratorSource;
 	}
 
 	/**
@@ -222,7 +250,8 @@ SelectBuilder.SelectLimitOffset createSelectClause(RelationalPersistentEntity<?>
 		SelectBuilder.SelectJoin builder;
 		if (tree.isExistsProjection()) {
 
-			Column idColumn = table.column(entity.getIdColumn());
+			AggregatePath.ColumnInfo anyIdColumnInfo = context.getAggregatePath(entity).getTableInfo().idColumnInfos().any();
+			Column idColumn = table.column(anyIdColumnInfo.name());
 			builder = Select.builder().select(idColumn).from(table);
 		} else if (tree.isCountProjection()) {
 			builder = Select.builder().select(Functions.count(Expressions.asterisk())).from(table);
@@ -235,139 +264,13 @@ SelectBuilder.SelectLimitOffset createSelectClause(RelationalPersistentEntity<?>
 
 	private SelectBuilder.SelectJoin selectBuilder(Table table) {
 
-		List<Expression> columnExpressions = new ArrayList<>();
 		RelationalPersistentEntity<?> entity = entityMetadata.getTableEntity();
-		SqlContext sqlContext = new SqlContext(entity);
-
-		List<Join> joinTables = new ArrayList<>();
-		for (PersistentPropertyPath<RelationalPersistentProperty> path : context
-				.findPersistentPropertyPaths(entity.getType(), p -> true)) {
-
-			AggregatePath aggregatePath = context.getAggregatePath(path);
-
-			if (returnedType.needsCustomConstruction()) {
-				if (!returnedType.getInputProperties().contains(aggregatePath.getRequiredBaseProperty().getName())) {
-					continue;
-				}
-			}
-
-			// add a join if necessary
-			Join join = getJoin(sqlContext, aggregatePath);
-			if (join != null) {
-				joinTables.add(join);
-			}
-
-			Column column = getColumn(sqlContext, aggregatePath);
-			if (column != null) {
-				columnExpressions.add(column);
-			}
-		}
-
-		SelectBuilder.SelectAndFrom selectBuilder = StatementBuilder.select(columnExpressions);
-		SelectBuilder.SelectJoin baseSelect = selectBuilder.from(table);
-
-		for (Join join : joinTables) {
-			baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(join.joinColumn).equals(join.parentId);
-		}
-
-		return baseSelect;
-	}
-
-	/**
-	 * Create a {@link Column} for {@link AggregatePath}.
-	 *
-	 * @param sqlContext
-	 * @param path the path to the column in question.
-	 * @return the statement as a {@link String}. Guaranteed to be not {@literal null}.
-	 */
-	@Nullable
-	private Column getColumn(SqlContext sqlContext, AggregatePath path) {
-
-		// an embedded itself doesn't give an column, its members will though.
-		// if there is a collection or map on the path it won't get selected at all, but it will get loaded with a separate
-		// select
-		// only the parent path is considered in order to handle arrays that get stored as BINARY properly
-		if (path.isEmbedded() || path.getParentPath().isMultiValued()) {
-			return null;
-		}
-
-		if (path.isEntity()) {
-
-			// Simple entities without id include there backreference as an synthetic id in order to distinguish null entities
-			// from entities with only null values.
-
-			if (path.isQualified() //
-					|| path.isCollectionLike() //
-					|| path.hasIdProperty() //
-			) {
-				return null;
-			}
-
-			return sqlContext.getReverseColumn(path);
-		}
-
-		return sqlContext.getColumn(path);
-	}
-
-	@Nullable
-	Join getJoin(SqlContext sqlContext, AggregatePath path) {
-
-		if (!path.isEntity() || path.isEmbedded() || path.isMultiValued()) {
-			return null;
-		}
-
-		Table currentTable = sqlContext.getTable(path);
 
-		AggregatePath idDefiningParentPath = path.getIdDefiningParentPath();
-		Table parentTable = sqlContext.getTable(idDefiningParentPath);
+		Predicate<AggregatePath> filter = ap -> returnedType.needsCustomConstruction()
+				&& !returnedType.getInputProperties().contains(ap.getRequiredBaseProperty().getName());
 
-		return new Join( //
-				currentTable, //
-				currentTable.column(path.getTableInfo().reverseColumnInfo().name()), //
-				parentTable.column(idDefiningParentPath.getTableInfo().idColumnName()) //
-		);
+		return (SelectBuilder.SelectJoin) sqlGeneratorSource.getSqlGenerator(entity.getType()).createSelectBuilder(table,
+				filter);
 	}
 
-	/**
-	 * Value object representing a {@code JOIN} association.
-	 */
-	static private final class Join {
-
-		private final Table joinTable;
-		private final Column joinColumn;
-		private final Column parentId;
-
-		Join(Table joinTable, Column joinColumn, Column parentId) {
-
-			Assert.notNull(joinTable, "JoinTable must not be null");
-			Assert.notNull(joinColumn, "JoinColumn must not be null");
-			Assert.notNull(parentId, "ParentId must not be null");
-
-			this.joinTable = joinTable;
-			this.joinColumn = joinColumn;
-			this.parentId = parentId;
-		}
-
-		@Override
-		public boolean equals(@Nullable Object o) {
-
-			if (this == o)
-				return true;
-			if (o == null || getClass() != o.getClass())
-				return false;
-			Join join = (Join) o;
-			return joinTable.equals(join.joinTable) && joinColumn.equals(join.joinColumn) && parentId.equals(join.parentId);
-		}
-
-		@Override
-		public int hashCode() {
-			return Objects.hash(joinTable, joinColumn, parentId);
-		}
-
-		@Override
-		public String toString() {
-
-			return "Join{" + "joinTable=" + joinTable + ", joinColumn=" + joinColumn + ", parentId=" + parentId + '}';
-		}
-	}
 }
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java
index 4d34666631..0b83d2d575 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java
@@ -16,13 +16,12 @@
 package org.springframework.data.jdbc.repository.query;
 
 import org.springframework.data.relational.core.mapping.AggregatePath;
-import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
 import org.springframework.data.relational.core.sql.Column;
 import org.springframework.data.relational.core.sql.SqlIdentifier;
 import org.springframework.data.relational.core.sql.Table;
 
 /**
- * Utility to get from path to SQL DSL elements. This is a temporary class and duplicates
+ * Utility to get from path to SQL DSL elements. This is a temporary class and duplicates parts of
  * {@link org.springframework.data.jdbc.core.convert.SqlContext}.
  *
  * @author Jens Schauder
@@ -32,42 +31,29 @@
  */
 class SqlContext {
 
-	private final RelationalPersistentEntity<?> entity;
-	private final Table table;
-
-	SqlContext(RelationalPersistentEntity<?> entity) {
-
-		this.entity = entity;
-		this.table = Table.create(entity.getQualifiedTableName());
-	}
+	Table getTable(AggregatePath path) {
 
-	Column getIdColumn() {
-		return table.column(entity.getIdColumn());
+		Table table = getUnaliasedTable(path);
+		AggregatePath.TableInfo tableInfo = path.getTableInfo();
+		SqlIdentifier tableAlias = tableInfo.tableAlias();
+		return tableAlias == null ? table : table.as(tableAlias);
 	}
 
-	Column getVersionColumn() {
-		return table.column(entity.getRequiredVersionProperty().getColumnName());
-	}
+	Column getColumn(AggregatePath path) {
 
-	Table getTable() {
-		return table;
+		AggregatePath.ColumnInfo columnInfo = path.getColumnInfo();
+		return getTable(path).column(columnInfo.name()).as(columnInfo.alias());
 	}
 
-	Table getTable(AggregatePath path) {
+	Column getAnyReverseColumn(AggregatePath path) {
 
-		SqlIdentifier tableAlias = path.getTableInfo().tableAlias();
-		Table table = Table.create(path.getTableInfo().qualifiedTableName());
-		return tableAlias == null ? table : table.as(tableAlias);
+		AggregatePath.ColumnInfo anyReverseColumnInfo = path.getTableInfo().backReferenceColumnInfos().any();
+		return getTable(path).column(anyReverseColumnInfo.name()).as(anyReverseColumnInfo.alias());
 	}
 
-	Column getColumn(AggregatePath path) {
-		AggregatePath.ColumnInfo columnInfo = path.getColumnInfo();
-		AggregatePath.ColumnInfo columnInfo1 = path.getColumnInfo();
-		return getTable(path).column(columnInfo1.name()).as(columnInfo.alias());
-	}
+	public Table getUnaliasedTable(AggregatePath path) {
 
-	Column getReverseColumn(AggregatePath path) {
-		return getTable(path).column(path.getTableInfo().reverseColumnInfo().name())
-				.as(path.getTableInfo().reverseColumnInfo().alias());
+		AggregatePath.TableInfo tableInfo = path.getTableInfo();
+		return Table.create(tableInfo.qualifiedTableName());
 	}
 }
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/support/JdbcUtil.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/support/JdbcUtil.java
index 5fec40f1a9..c091191bb5 100644
--- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/support/JdbcUtil.java
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/support/JdbcUtil.java
@@ -27,7 +27,7 @@
 import java.util.Map;
 
 import org.springframework.jdbc.support.JdbcUtils;
-import org.springframework.util.Assert;
+import org.springframework.util.ConcurrentLruCache;
 
 /**
  * Contains methods dealing with the quirks of JDBC, independent of any Entity, Aggregate or Repository abstraction.
@@ -58,7 +58,10 @@ public String toString() {
 			return getName();
 		}
 	};
+
 	private static final Map<Class<?>, SQLType> sqlTypeMappings = new HashMap<>();
+	private static ConcurrentLruCache<Class<?>, SQLType> sqlTypeCache = new ConcurrentLruCache<>(64,
+			JdbcUtil::doGetSqlType);
 
 	static {
 
@@ -97,9 +100,10 @@ private JdbcUtil() {
 	 * @return a matching {@link SQLType} or {@link #TYPE_UNKNOWN}.
 	 */
 	public static SQLType targetSqlTypeFor(Class<?> type) {
+		return sqlTypeCache.get(type);
+	}
 
-		Assert.notNull(type, "Type must not be null");
-
+	private static SQLType doGetSqlType(Class<?> type) {
 		return sqlTypeMappings.keySet().stream() //
 				.filter(k -> k.isAssignableFrom(type)) //
 				.findFirst() //
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java
new file mode 100644
index 0000000000..9b5df44a80
--- /dev/null
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java
@@ -0,0 +1,310 @@
+/*
+ * Copyright 2017-2024 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.jdbc.core;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jdbc.core.convert.DataAccessStrategy;
+import org.springframework.data.jdbc.core.convert.JdbcConverter;
+import org.springframework.data.jdbc.testing.DatabaseType;
+import org.springframework.data.jdbc.testing.EnabledOnDatabase;
+import org.springframework.data.jdbc.testing.IntegrationTest;
+import org.springframework.data.jdbc.testing.TestConfiguration;
+import org.springframework.data.relational.core.mapping.Embedded;
+import org.springframework.data.relational.core.mapping.RelationalMappingContext;
+import org.springframework.data.relational.core.query.Query;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
+
+/**
+ * Integration tests for {@link JdbcAggregateTemplate} and it's handling of entities with embedded entities as keys.
+ *
+ * @author Jens Schauder
+ */
+@IntegrationTest
+@EnabledOnDatabase(DatabaseType.HSQL)
+class CompositeIdAggregateTemplateHsqlIntegrationTests {
+
+	@Autowired JdbcAggregateOperations template;
+	@Autowired private NamedParameterJdbcOperations namedParameterJdbcTemplate;
+
+	@Test // GH-574
+	void saveAndLoadSimpleEntity() {
+
+		SimpleEntity entity = template.insert(new SimpleEntity(new WrappedPk(23L), "alpha"));
+
+		assertThat(entity.wrappedPk).isNotNull() //
+				.extracting(WrappedPk::id).isNotNull();
+
+		SimpleEntity reloaded = template.findById(entity.wrappedPk, SimpleEntity.class);
+
+		assertThat(reloaded).isEqualTo(entity);
+	}
+
+	@Test // GH-574
+	void saveAndLoadEntityWithList() {
+
+		WithList entity = template
+				.insert(new WithList(new WrappedPk(23L), "alpha", List.of(new Child("Romulus"), new Child("Remus"))));
+
+		assertThat(entity.wrappedPk).isNotNull() //
+				.extracting(WrappedPk::id).isNotNull();
+
+		WithList reloaded = template.findById(entity.wrappedPk, WithList.class);
+
+		assertThat(reloaded).isEqualTo(entity);
+	}
+
+	@Test // GH-574
+	void saveAndLoadSimpleEntityWithEmbeddedPk() {
+
+		SimpleEntityWithEmbeddedPk entity = template
+				.insert(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"));
+
+		SimpleEntityWithEmbeddedPk reloaded = template.findById(entity.embeddedPk, SimpleEntityWithEmbeddedPk.class);
+
+		assertThat(reloaded).isEqualTo(entity);
+	}
+
+	@Test // GH-574
+	void saveAndLoadSimpleEntitiesWithEmbeddedPk() {
+
+		List<SimpleEntityWithEmbeddedPk> entities = (List<SimpleEntityWithEmbeddedPk>) template
+				.insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma")));
+
+		List<EmbeddedPk> firstTwoPks = entities.stream().limit(2).map(SimpleEntityWithEmbeddedPk::embeddedPk).toList();
+		Iterable<SimpleEntityWithEmbeddedPk> reloaded = template.findAllById(firstTwoPks, SimpleEntityWithEmbeddedPk.class);
+
+		assertThat(reloaded).containsExactlyInAnyOrder(entities.get(0), entities.get(1));
+	}
+
+	@Test // GH-574
+	void deleteSingleSimpleEntityWithEmbeddedPk() {
+
+		List<SimpleEntityWithEmbeddedPk> entities = (List<SimpleEntityWithEmbeddedPk>) template
+				.insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma")));
+
+		template.delete(entities.get(1));
+
+		Iterable<SimpleEntityWithEmbeddedPk> reloaded = template.findAll(SimpleEntityWithEmbeddedPk.class);
+
+		assertThat(reloaded).containsExactlyInAnyOrder(entities.get(0), entities.get(2));
+	}
+
+	@Test // GH-574
+	void deleteMultipleSimpleEntityWithEmbeddedPk() {
+
+		List<SimpleEntityWithEmbeddedPk> entities = (List<SimpleEntityWithEmbeddedPk>) template
+				.insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma")));
+
+		template.deleteAll(List.of(entities.get(1), entities.get(0)));
+
+		Iterable<SimpleEntityWithEmbeddedPk> reloaded = template.findAll(SimpleEntityWithEmbeddedPk.class);
+
+		assertThat(reloaded).containsExactly(entities.get(2));
+	}
+
+	@Test // GH-574
+	void existsSingleSimpleEntityWithEmbeddedPk() {
+
+		List<SimpleEntityWithEmbeddedPk> entities = (List<SimpleEntityWithEmbeddedPk>) template
+				.insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma")));
+
+		assertThat(template.existsById(entities.get(1).embeddedPk, SimpleEntityWithEmbeddedPk.class)).isTrue();
+		assertThat(template.existsById(new EmbeddedPk(24L, "x"), SimpleEntityWithEmbeddedPk.class)).isFalse();
+
+	}
+
+	@Test // GH-574
+	void updateSingleSimpleEntityWithEmbeddedPk() {
+
+		List<SimpleEntityWithEmbeddedPk> entities = (List<SimpleEntityWithEmbeddedPk>) template
+				.insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"),
+						new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma")));
+
+		SimpleEntityWithEmbeddedPk updated = new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "ALPHA");
+		template.save(updated);
+
+		Iterable<SimpleEntityWithEmbeddedPk> reloaded = template.findAll(SimpleEntityWithEmbeddedPk.class);
+
+		assertThat(reloaded).containsExactlyInAnyOrder(updated, entities.get(1), entities.get(2));
+	}
+
+	@Test // GH-574
+	void saveAndLoadSingleReferenceAggregate() {
+
+		SingleReference entity = template.insert(new SingleReference(new EmbeddedPk(23L, "x"), "alpha", new Child("Alf")));
+
+		SingleReference reloaded = template.findById(entity.embeddedPk, SingleReference.class);
+
+		assertThat(reloaded).isEqualTo(entity);
+	}
+
+	@Test // GH-574
+	void updateSingleReferenceAggregate() {
+
+		EmbeddedPk id = new EmbeddedPk(23L, "x");
+		template.insert(new SingleReference(id, "alpha", new Child("Alf")));
+
+		SingleReference updated = new SingleReference(id, "beta", new Child("Barny"));
+		template.save(updated);
+
+		List<SingleReference> all = template.findAll(SingleReference.class);
+
+		assertThat(all).containsExactly(updated);
+	}
+
+	@Test // GH-574
+	void saveAndLoadWithListAndCompositeId() {
+
+		WithListAndCompositeId entity = template.insert( //
+				new WithListAndCompositeId( //
+						new EmbeddedPk(23L, "x"), "alpha", //
+						List.of( //
+								new Child("Alf"), //
+								new Child("Bob"), //
+								new Child("Flo") //
+						) //
+				) //
+		);
+
+		WithListAndCompositeId reloaded = template.findById(entity.embeddedPk, WithListAndCompositeId.class);
+
+		assertThat(reloaded).isEqualTo(entity);
+	}
+
+	@Test // GH-574
+	void sortByCompositeIdParts() {
+
+		SimpleEntityWithEmbeddedPk alpha = template.insert( //
+				new SimpleEntityWithEmbeddedPk( //
+						new EmbeddedPk(23L, "x"), "alpha" //
+				));
+		SimpleEntityWithEmbeddedPk bravo = template.insert( //
+				new SimpleEntityWithEmbeddedPk( //
+						new EmbeddedPk(22L, "a"), "bravo" //
+				));
+		SimpleEntityWithEmbeddedPk charlie = template.insert( //
+				new SimpleEntityWithEmbeddedPk( //
+						new EmbeddedPk(21L, "z"), "charlie" //
+				) //
+		);
+
+		assertThat( //
+				template.findAll(SimpleEntityWithEmbeddedPk.class, Sort.by("embeddedPk.one"))) //
+				.containsExactly( //
+						charlie, bravo, alpha //
+				);
+
+		assertThat( //
+				template.findAll(SimpleEntityWithEmbeddedPk.class, Sort.by("embeddedPk.two").descending())) //
+				.containsExactly( //
+						charlie, alpha, bravo //
+				);
+	}
+
+	@Test // GH-574
+	void projectByCompositeIdParts() {
+
+		template.insert( //
+				new SimpleEntityWithEmbeddedPk( //
+						new EmbeddedPk(23L, "x"), "alpha" //
+				));
+
+		Query projectingQuery = Query.empty().columns("embeddedPk.two", "name");
+		SimpleEntityWithEmbeddedPk projected = template.findOne(projectingQuery, SimpleEntityWithEmbeddedPk.class)
+				.orElseThrow();
+
+		assertThat(projected).isEqualTo(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(null, "x"), "alpha"));
+
+		projectingQuery = Query.empty().columns("embeddedPk", "name");
+		projected = template.findOne(projectingQuery, SimpleEntityWithEmbeddedPk.class).orElseThrow();
+
+		assertThat(projected).isEqualTo(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"));
+	}
+
+	private record WrappedPk(Long id) {
+	}
+
+	private record SimpleEntity( //
+			@Id WrappedPk wrappedPk, //
+			String name //
+	) {
+	}
+
+	private record Child(String name) {
+	}
+
+	private record WithList( //
+			@Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) WrappedPk wrappedPk, //
+			String name, List<Child> children) {
+	}
+
+	private record EmbeddedPk(Long one, String two) {
+	}
+
+	private record SimpleEntityWithEmbeddedPk( //
+			@Id EmbeddedPk embeddedPk, //
+			String name //
+	) {
+	}
+
+	private record SingleReference( //
+			@Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, //
+			String name, //
+			Child child) {
+	}
+
+	private record WithListAndCompositeId( //
+			@Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, //
+			String name, //
+			List<Child> child) {
+	}
+
+	@Configuration
+	@Import(TestConfiguration.class)
+	static class Config {
+
+		@Bean
+		Class<?> testClass() {
+			return CompositeIdAggregateTemplateHsqlIntegrationTests.class;
+		}
+
+		@Bean
+		JdbcAggregateOperations operations(ApplicationEventPublisher publisher, RelationalMappingContext context,
+				DataAccessStrategy dataAccessStrategy, JdbcConverter converter) {
+			return new JdbcAggregateTemplate(publisher, context, converter, dataAccessStrategy);
+		}
+	}
+}
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java
index 78d05c03dc..bf0b41a044 100644
--- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java
@@ -120,7 +120,8 @@ public void idGenerationOfChildInList() {
 		assertThat(newRoot.list.get(0).id).isEqualTo(24L);
 	}
 
-	@Test // GH-537
+	@Test
+	// GH-537
 	void populatesIdsIfNecessaryForAllRootsThatWereProcessed() {
 
 		DummyEntity root1 = new DummyEntity().withId(123L);
@@ -166,7 +167,8 @@ PersistentPropertyPath<RelationalPersistentProperty> getPersistentPropertyPath(S
 	}
 
 	Identifier createBackRef(long value) {
-		return JdbcIdentifierBuilder.forBackReferences(converter, toAggregatePath("content"), value).build();
+		return JdbcIdentifierBuilder.forBackReferences(converter, toAggregatePath("content"),
+				JdbcAggregateChangeExecutionContext.getIdMapper(value, toAggregatePath("content"), converter)).build();
 	}
 
 	PersistentPropertyPath<RelationalPersistentProperty> toPath(String path) {
@@ -180,10 +182,8 @@ PersistentPropertyPath<RelationalPersistentProperty> toPath(String path) {
 
 	private static final class DummyEntity {
 
-		@Id
-		private final Long id;
-		@Version
-		private final long version;
+		@Id private final Long id;
+		@Version private final long version;
 
 		private final Content content;
 
@@ -221,14 +221,16 @@ public List<Content> getList() {
 		}
 
 		public boolean equals(final Object o) {
-			if (o == this) return true;
+			if (o == this)
+				return true;
 			if (!(o instanceof final DummyEntity other))
 				return false;
 			final Object this$id = this.getId();
 			final Object other$id = other.getId();
 			if (!Objects.equals(this$id, other$id))
 				return false;
-			if (this.getVersion() != other.getVersion()) return false;
+			if (this.getVersion() != other.getVersion())
+				return false;
 			final Object this$content = this.getContent();
 			final Object other$content = other.getContent();
 			if (!Objects.equals(this$content, other$content))
@@ -253,7 +255,8 @@ public int hashCode() {
 		}
 
 		public String toString() {
-			return "JdbcAggregateChangeExecutorContextImmutableUnitTests.DummyEntity(id=" + this.getId() + ", version=" + this.getVersion() + ", content=" + this.getContent() + ", list=" + this.getList() + ")";
+			return "JdbcAggregateChangeExecutorContextImmutableUnitTests.DummyEntity(id=" + this.getId() + ", version="
+					+ this.getVersion() + ", content=" + this.getContent() + ", list=" + this.getList() + ")";
 		}
 
 		public DummyEntity withId(Long id) {
@@ -274,8 +277,7 @@ public DummyEntity withList(List<Content> list) {
 	}
 
 	private static final class Content {
-		@Id
-		private final Long id;
+		@Id private final Long id;
 
 		Content() {
 			id = null;
@@ -290,7 +292,8 @@ public Long getId() {
 		}
 
 		public boolean equals(final Object o) {
-			if (o == this) return true;
+			if (o == this)
+				return true;
 			if (!(o instanceof final Content other))
 				return false;
 			final Object this$id = this.getId();
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java
index eef22d5c94..afb0f224c0 100644
--- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java
@@ -18,6 +18,7 @@
 import static java.util.Collections.*;
 import static org.assertj.core.api.Assertions.*;
 import static org.mockito.Mockito.*;
+import static org.springframework.data.jdbc.core.JdbcAggregateChangeExecutionContext.*;
 import static org.springframework.data.jdbc.core.convert.JdbcIdentifierBuilder.*;
 
 import java.util.ArrayList;
@@ -257,7 +258,8 @@ PersistentPropertyPath<RelationalPersistentProperty> getPersistentPropertyPath(S
 	}
 
 	Identifier createBackRef(long value) {
-		return forBackReferences(converter, toAggregatePath("content"), value).build();
+		return forBackReferences(converter, toAggregatePath("content"),
+				getIdMapper(value, toAggregatePath("content"), converter)).build();
 	}
 
 	PersistentPropertyPath<RelationalPersistentProperty> toPath(String path) {
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java
index 5873ce23a1..5c0daa2e0b 100644
--- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java
@@ -21,109 +21,193 @@
 import java.util.List;
 import java.util.Map;
 import java.util.UUID;
+import java.util.function.Function;
 
+import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.jdbc.core.PersistentPropertyPathTestUtils;
 import org.springframework.data.jdbc.core.mapping.JdbcMappingContext;
+import org.springframework.data.mapping.PersistentPropertyPathAccessor;
 import org.springframework.data.relational.core.mapping.AggregatePath;
+import org.springframework.data.relational.core.mapping.Embedded;
+import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
 
 /**
  * Unit tests for the {@link JdbcIdentifierBuilder}.
  *
  * @author Jens Schauder
  */
-public class JdbcIdentifierBuilderUnitTests {
+class JdbcIdentifierBuilderUnitTests {
 
 	JdbcMappingContext context = new JdbcMappingContext();
 	JdbcConverter converter = new MappingJdbcConverter(context, (identifier, path) -> {
 		throw new UnsupportedOperationException();
 	});
 
-	@Test // DATAJDBC-326
-	public void parametersWithPropertyKeysUseTheParentPropertyJdbcType() {
-
-		Identifier identifier = JdbcIdentifierBuilder.forBackReferences(converter, getPath("child"), "eins").build();
+	@Nested
+	class WithSimpleId {
+		@Test // DATAJDBC-326
+		void parametersWithPropertyKeysUseTheParentPropertyJdbcType() {
+
+			Identifier identifier = JdbcIdentifierBuilder
+					.forBackReferences(converter, getPath("child"), getValueProvider("eins", getPath("child"), converter))
+					.build();
+
+			assertThat(identifier.getParts()) //
+					.extracting("name", "value", "targetType") //
+					.containsExactly( //
+							tuple(quoted("DUMMY_ENTITY"), "eins", UUID.class) //
+					);
+		}
+
+		@Test // DATAJDBC-326
+		void qualifiersForMaps() {
+
+			AggregatePath path = getPath("children");
+
+			Identifier identifier = JdbcIdentifierBuilder //
+					.forBackReferences(converter, path, getValueProvider("parent-eins", path, converter)) //
+					.withQualifier(path, "map-key-eins") //
+					.build();
+
+			assertThat(identifier.getParts()) //
+					.extracting("name", "value", "targetType") //
+					.containsExactlyInAnyOrder( //
+							tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class), //
+							tuple(quoted("DUMMY_ENTITY_KEY"), "map-key-eins", String.class) //
+					);
+		}
+
+		@Test // DATAJDBC-326
+		void qualifiersForLists() {
+
+			AggregatePath path = getPath("moreChildren");
+
+			Identifier identifier = JdbcIdentifierBuilder //
+					.forBackReferences(converter, path, getValueProvider("parent-eins", path, converter)) //
+					.withQualifier(path, "list-index-eins") //
+					.build();
+
+			assertThat(identifier.getParts()) //
+					.extracting("name", "value", "targetType") //
+					.containsExactlyInAnyOrder( //
+							tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class), //
+							tuple(quoted("DUMMY_ENTITY_KEY"), "list-index-eins", Integer.class) //
+					);
+		}
+
+		@Test // DATAJDBC-326
+		void backreferenceAcrossEmbeddable() {
+
+			Identifier identifier = JdbcIdentifierBuilder //
+					.forBackReferences(converter, getPath("embeddable.child"),
+							getValueProvider("parent-eins", getPath("embeddable.child"), converter)) //
+					.build();
+
+			assertThat(identifier.getParts()) //
+					.extracting("name", "value", "targetType") //
+					.containsExactly( //
+							tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class) //
+					);
+		}
+
+		@Test // DATAJDBC-326
+		void backreferenceAcrossNoId() {
+
+			Identifier identifier = JdbcIdentifierBuilder //
+					.forBackReferences(converter, getPath("noId.child"),
+							getValueProvider("parent-eins", getPath("noId.child"), converter)) //
+					.build();
+
+			assertThat(identifier.getParts()) //
+					.extracting("name", "value", "targetType") //
+					.containsExactly( //
+							tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class) //
+					);
+		}
+
+		private AggregatePath getPath(String dotPath) {
+			return JdbcIdentifierBuilderUnitTests.this.getPath(dotPath, DummyEntity.class);
+		}
+	}
 
-		assertThat(identifier.getParts()) //
-				.extracting("name", "value", "targetType") //
-				.containsExactly( //
-						tuple(quoted("DUMMY_ENTITY"), "eins", UUID.class) //
-				);
+	/**
+	 * copied from JdbcAggregateChangeExecutionContext
+	 */
+	static Function<AggregatePath, Object> getValueProvider(Object idValue, AggregatePath path, JdbcConverter converter) {
+
+		RelationalPersistentEntity<?> entity = converter.getMappingContext()
+				.getPersistentEntity(path.getIdDefiningParentPath().getRequiredIdProperty().getType());
+
+		Function<AggregatePath, Object> valueProvider = ap -> {
+			if (entity == null) {
+				return idValue;
+			} else {
+
+				ap = ap.getTail();
+				PersistentPropertyPathAccessor<Object> propertyPathAccessor = entity.getPropertyPathAccessor(idValue);
+				return propertyPathAccessor.getProperty(ap.getRequiredPersistentPropertyPath());
+			}
+		};
+		return valueProvider;
 	}
 
-	@Test // DATAJDBC-326
-	public void qualifiersForMaps() {
+	@Nested
+	class WithCompositeId {
 
-		AggregatePath path = getPath("children");
+		CompositeId exampleId = new CompositeId("parent-eins", 23);
 
-		Identifier identifier = JdbcIdentifierBuilder //
-				.forBackReferences(converter, path, "parent-eins") //
-				.withQualifier(path, "map-key-eins") //
-				.build();
+		@Test // GH-574
+		void forBackReferences() {
 
-		assertThat(identifier.getParts()) //
-				.extracting("name", "value", "targetType") //
-				.containsExactlyInAnyOrder( //
-						tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class), //
-						tuple(quoted("DUMMY_ENTITY_KEY"), "map-key-eins", String.class) //
-				);
-	}
+			AggregatePath path = getPath("children");
 
-	@Test // DATAJDBC-326
-	public void qualifiersForLists() {
+			Identifier identifier = JdbcIdentifierBuilder //
+					.forBackReferences(converter, path, getValueProvider(exampleId, path, converter)) //
+					.build();
 
-		AggregatePath path = getPath("moreChildren");
+			assertThat(identifier.getParts()) //
+					.extracting("name", "value", "targetType") //
+					.containsExactlyInAnyOrder( //
+							tuple(quoted("DUMMY_ENTITY_WITH_COMPOSITE_ID_ONE"), exampleId.one, String.class), //
+							tuple(quoted("DUMMY_ENTITY_WITH_COMPOSITE_ID_TWO"), exampleId.two, Integer.class) //
+					);
+		}
 
-		Identifier identifier = JdbcIdentifierBuilder //
-				.forBackReferences(converter, path, "parent-eins") //
-				.withQualifier(path, "list-index-eins") //
-				.build();
+		private AggregatePath getPath(String dotPath) {
+			return JdbcIdentifierBuilderUnitTests.this.getPath(dotPath, DummyEntityWithCompositeId.class);
+		}
+	}
 
-		assertThat(identifier.getParts()) //
-				.extracting("name", "value", "targetType") //
-				.containsExactlyInAnyOrder( //
-						tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class), //
-						tuple(quoted("DUMMY_ENTITY_KEY"), "list-index-eins", Integer.class) //
-				);
+	private AggregatePath getPath(String dotPath, Class<?> entityType) {
+		return context.getAggregatePath(PersistentPropertyPathTestUtils.getPath(dotPath, entityType, context));
 	}
 
-	@Test // DATAJDBC-326
-	public void backreferenceAcrossEmbeddable() {
+	@SuppressWarnings("unused")
+	static class DummyEntity {
 
-		Identifier identifier = JdbcIdentifierBuilder //
-				.forBackReferences(converter, getPath("embeddable.child"), "parent-eins") //
-				.build();
+		@Id UUID id;
+		String one;
+		Long two;
+		Child child;
 
-		assertThat(identifier.getParts()) //
-				.extracting("name", "value", "targetType") //
-				.containsExactly( //
-						tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class) //
-				);
-	}
+		Map<String, Child> children;
 
-	@Test // DATAJDBC-326
-	public void backreferenceAcrossNoId() {
+		List<Child> moreChildren;
 
-		Identifier identifier = JdbcIdentifierBuilder //
-				.forBackReferences(converter, getPath("noId.child"), "parent-eins") //
-				.build();
+		Embeddable embeddable;
 
-		assertThat(identifier.getParts()) //
-				.extracting("name", "value", "targetType") //
-				.containsExactly( //
-						tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class) //
-				);
+		NoId noId;
 	}
 
-	private AggregatePath getPath(String dotPath) {
-		return context.getAggregatePath(PersistentPropertyPathTestUtils.getPath(dotPath, DummyEntity.class, context));
+	record CompositeId(String one, Integer two) {
 	}
 
-	@SuppressWarnings("unused")
-	static class DummyEntity {
+	static class DummyEntityWithCompositeId {
 
-		@Id UUID id;
+		@Embedded(onEmpty = Embedded.OnEmpty.USE_NULL)
+		@Id CompositeId id;
 		String one;
 		Long two;
 		Child child;
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorContextBasedNamingStrategyUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorContextBasedNamingStrategyUnitTests.java
index 745698211b..f25f421c30 100644
--- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorContextBasedNamingStrategyUnitTests.java
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorContextBasedNamingStrategyUnitTests.java
@@ -89,7 +89,7 @@ public void cascadingDeleteFirstLevel() {
 			assertThat(sql).isEqualTo( //
 					"DELETE FROM " //
 							+ user + ".referenced_entity WHERE " //
-							+ user + ".referenced_entity.dummy_entity = :rootId" //
+							+ user + ".referenced_entity.dummy_entity = :id" //
 			);
 		});
 	}
@@ -107,7 +107,7 @@ public void cascadingDeleteAllSecondLevel() {
 					"DELETE FROM " + user + ".second_level_referenced_entity " //
 							+ "WHERE " + user + ".second_level_referenced_entity.referenced_entity IN " //
 							+ "(SELECT " + user + ".referenced_entity.l1id FROM " + user + ".referenced_entity " //
-							+ "WHERE " + user + ".referenced_entity.dummy_entity = :rootId)");
+							+ "WHERE " + user + ".referenced_entity.dummy_entity = :id)");
 		});
 	}
 
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java
index 7c510617b2..9a011a8802 100644
--- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java
@@ -17,20 +17,21 @@
 
 import static java.util.Collections.*;
 import static org.assertj.core.api.Assertions.*;
-import static org.assertj.core.api.SoftAssertions.*;
 
 import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
+
 import org.springframework.data.annotation.Id;
 import org.springframework.data.jdbc.core.PersistentPropertyPathTestUtils;
 import org.springframework.data.jdbc.core.mapping.AggregateReference;
 import org.springframework.data.jdbc.core.mapping.JdbcMappingContext;
+import org.springframework.data.mapping.PersistentPropertyPath;
 import org.springframework.data.relational.core.mapping.Column;
 import org.springframework.data.relational.core.mapping.Embedded;
 import org.springframework.data.relational.core.mapping.Embedded.OnEmpty;
 import org.springframework.data.relational.core.mapping.RelationalMappingContext;
 import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
+import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
 import org.springframework.data.relational.core.mapping.Table;
 import org.springframework.data.relational.core.sql.Aliased;
 import org.springframework.data.relational.core.sql.SqlIdentifier;
@@ -41,10 +42,11 @@
  *
  * @author Bastian Wilhelm
  * @author Mark Paluch
+ * @author Jens Schauder
  */
 class SqlGeneratorEmbeddedUnitTests {
 
-	private final RelationalMappingContext context = new JdbcMappingContext();
+	private RelationalMappingContext context = new JdbcMappingContext();
 	private JdbcConverter converter = new MappingJdbcConverter(context, (identifier, path) -> {
 		throw new UnsupportedOperationException();
 	});
@@ -63,133 +65,252 @@ SqlGenerator createSqlGenerator(Class<?> type) {
 
 	@Test // DATAJDBC-111
 	void findOne() {
-		final String sql = sqlGenerator.getFindOne();
-
-		assertSoftly(softly -> {
-
-			softly.assertThat(sql).startsWith("SELECT") //
-					.contains("dummy_entity.id1 AS id1") //
-					.contains("dummy_entity.test AS test") //
-					.contains("dummy_entity.attr1 AS attr1") //
-					.contains("dummy_entity.attr2 AS attr2") //
-					.contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") //
-					.contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") //
-					.contains("dummy_entity.prefix_test AS prefix_test") //
-					.contains("dummy_entity.prefix_attr1 AS prefix_attr1") //
-					.contains("dummy_entity.prefix_attr2 AS prefix_attr2") //
-					.contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") //
-					.contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") //
-					.contains("WHERE dummy_entity.id1 = :id") //
-					.doesNotContain("JOIN").doesNotContain("embeddable"); //
-		});
+		String sql = sqlGenerator.getFindOne();
+
+		assertThat(sql).startsWith("SELECT") //
+				.contains("dummy_entity.id1 AS id1") //
+				.contains("dummy_entity.test AS test") //
+				.contains("dummy_entity.attr1 AS attr1") //
+				.contains("dummy_entity.attr2 AS attr2") //
+				.contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") //
+				.contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") //
+				.contains("dummy_entity.prefix_test AS prefix_test") //
+				.contains("dummy_entity.prefix_attr1 AS prefix_attr1") //
+				.contains("dummy_entity.prefix_attr2 AS prefix_attr2") //
+				.contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") //
+				.contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") //
+				.contains("WHERE dummy_entity.id1 = :id") //
+				.doesNotContain("JOIN").doesNotContain("embeddable"); //
+	}
+
+	@Test // GH-574
+	void findOneWrappedId() {
+
+		SqlGenerator sqlGenerator = createSqlGenerator(WithWrappedId.class);
+
+		String sql = sqlGenerator.getFindOne();
+
+		assertThat(sql).startsWith("SELECT") //
+				.contains("with_wrapped_id.name AS name") //
+				.contains("with_wrapped_id.id") //
+				.contains("WHERE with_wrapped_id.id = :id");
+	}
+
+	@Test // GH-574
+	void findOneEmbeddedId() {
+
+		SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class);
+
+		String sql = sqlGenerator.getFindOne();
+
+		assertThat(sql).startsWith("SELECT") //
+				.contains("with_embedded_id.name AS name") //
+				.contains("with_embedded_id.one") //
+				.contains("with_embedded_id.two") //
+				.contains(" WHERE ") //
+				.contains("with_embedded_id.one = :one") //
+				.contains("with_embedded_id.two = :two");
+	}
+
+	@Test // GH-574
+	void deleteByIdEmbeddedId() {
+
+		SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class);
+
+		String sql = sqlGenerator.getDeleteById();
+
+		assertThat(sql).startsWith("DELETE") //
+				.contains(" WHERE ") //
+				.contains("with_embedded_id.one = :one") //
+				.contains("with_embedded_id.two = :two");
+	}
+
+	@Test // GH-574
+	void deleteByIdInEmbeddedId() {
+
+		SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class);
+
+		String sql = sqlGenerator.getDeleteByIdIn();
+
+		assertThat(sql).startsWith("DELETE") //
+				.contains(" WHERE ") //
+				.contains("(with_embedded_id.one, with_embedded_id.two) IN (:ids)");
+	}
+
+	@Test // GH-574
+	void deleteByPathEmbeddedId() {
+
+		SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class);
+		PersistentPropertyPath<RelationalPersistentProperty> path = PersistentPropertyPathTestUtils.getPath("other",
+				WithEmbeddedIdAndReference.class, context);
+
+		String sql = sqlGenerator.createDeleteByPath(path);
+
+		assertThat(sql).startsWith("DELETE FROM other_entity WHERE") //
+				.contains("other_entity.with_embedded_id_and_reference_one = :one") //
+				.contains("other_entity.with_embedded_id_and_reference_two = :two");
+	}
+
+	@Test // GH-574
+	void deleteInByPathEmbeddedId() {
+
+		SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class);
+		PersistentPropertyPath<RelationalPersistentProperty> path = PersistentPropertyPathTestUtils.getPath("other",
+				WithEmbeddedIdAndReference.class, context);
+
+		String sql = sqlGenerator.createDeleteInByPath(path);
+
+		assertThat(sql).startsWith("DELETE FROM other_entity WHERE") //
+				.contains(" WHERE ") //
+				.contains(
+						"(other_entity.with_embedded_id_and_reference_one, other_entity.with_embedded_id_and_reference_two) IN (:ids)");
+	}
+
+	@Test // GH-574
+	void updateWithEmbeddedId() {
+
+		SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class);
+
+		String sql = sqlGenerator.getUpdate();
+
+		assertThat(sql).startsWith("UPDATE") //
+				.contains(" WHERE ") //
+				.contains("with_embedded_id.one = :one") //
+				.contains("with_embedded_id.two = :two");
+	}
+
+	@Test // GH-574
+	void existsByIdEmbeddedId() {
+
+		SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class);
+
+		String sql = sqlGenerator.getExists();
+
+		assertThat(sql).startsWith("SELECT COUNT") //
+				.contains(" WHERE ") //
+				.contains("with_embedded_id.one = :one") //
+				.contains("with_embedded_id.two = :two");
 	}
 
 	@Test // DATAJDBC-111
 	void findAll() {
-		final String sql = sqlGenerator.getFindAll();
-
-		assertSoftly(softly -> {
-
-			softly.assertThat(sql).startsWith("SELECT") //
-					.contains("dummy_entity.id1 AS id1") //
-					.contains("dummy_entity.test AS test") //
-					.contains("dummy_entity.attr1 AS attr1") //
-					.contains("dummy_entity.attr2 AS attr2") //
-					.contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") //
-					.contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") //
-					.contains("dummy_entity.prefix_test AS prefix_test") //
-					.contains("dummy_entity.prefix_attr1 AS prefix_attr1") //
-					.contains("dummy_entity.prefix_attr2 AS prefix_attr2") //
-					.contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") //
-					.contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") //
-					.doesNotContain("JOIN") //
-					.doesNotContain("embeddable");
-		});
+		String sql = sqlGenerator.getFindAll();
+
+		assertThat(sql).startsWith("SELECT") //
+				.contains("dummy_entity.id1 AS id1") //
+				.contains("dummy_entity.test AS test") //
+				.contains("dummy_entity.attr1 AS attr1") //
+				.contains("dummy_entity.attr2 AS attr2") //
+				.contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") //
+				.contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") //
+				.contains("dummy_entity.prefix_test AS prefix_test") //
+				.contains("dummy_entity.prefix_attr1 AS prefix_attr1") //
+				.contains("dummy_entity.prefix_attr2 AS prefix_attr2") //
+				.contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") //
+				.contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") //
+				.doesNotContain("JOIN") //
+				.doesNotContain("embeddable");
 	}
 
 	@Test // DATAJDBC-111
 	void findAllInList() {
-		final String sql = sqlGenerator.getFindAllInList();
-
-		assertSoftly(softly -> {
-
-			softly.assertThat(sql).startsWith("SELECT") //
-					.contains("dummy_entity.id1 AS id1") //
-					.contains("dummy_entity.test AS test") //
-					.contains("dummy_entity.attr1 AS attr1") //
-					.contains("dummy_entity.attr2 AS attr2").contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") //
-					.contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") //
-					.contains("dummy_entity.prefix_test AS prefix_test") //
-					.contains("dummy_entity.prefix_attr1 AS prefix_attr1") //
-					.contains("dummy_entity.prefix_attr2 AS prefix_attr2") //
-					.contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") //
-					.contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") //
-					.contains("WHERE dummy_entity.id1 IN (:ids)") //
-					.doesNotContain("JOIN") //
-					.doesNotContain("embeddable");
-		});
+
+		String sql = sqlGenerator.getFindAllInList();
+
+		assertThat(sql).startsWith("SELECT") //
+				.contains("dummy_entity.id1 AS id1") //
+				.contains("dummy_entity.test AS test") //
+				.contains("dummy_entity.attr1 AS attr1") //
+				.contains("dummy_entity.attr2 AS attr2").contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") //
+				.contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") //
+				.contains("dummy_entity.prefix_test AS prefix_test") //
+				.contains("dummy_entity.prefix_attr1 AS prefix_attr1") //
+				.contains("dummy_entity.prefix_attr2 AS prefix_attr2") //
+				.contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") //
+				.contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") //
+				.contains("WHERE dummy_entity.id1 IN (:ids)") //
+				.doesNotContain("JOIN") //
+				.doesNotContain("embeddable");
+	}
+
+	@Test // GH-574
+	void findAllInListEmbeddedId() {
+
+		SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class);
+
+		String sql = sqlGenerator.getFindAllInList();
+
+		assertThat(sql).startsWith("SELECT") //
+				.contains("with_embedded_id.name AS name") //
+				.contains("with_embedded_id.one") //
+				.contains("with_embedded_id.two") //
+				.contains(" WHERE (with_embedded_id.one, with_embedded_id.two) IN (:ids)");
+	}
+
+	@Test // GH-574
+	void findOneWithReference() {
+
+		SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedIdAndReference.class);
+
+		String sql = sqlGenerator.getFindOne();
+
+		assertThat(sql).startsWith("SELECT") //
+				.contains(" LEFT OUTER JOIN other_entity other ") //
+				.contains(" ON ") //
+				.contains(" other.with_embedded_id_and_reference_one = with_embedded_id_and_reference.one ") //
+				.contains(" other.with_embedded_id_and_reference_two = with_embedded_id_and_reference.two ") //
+				.contains(" WHERE ") //
+				.contains("with_embedded_id_and_reference.one = :one") //
+				.contains("with_embedded_id_and_reference.two = :two");
 	}
 
 	@Test // DATAJDBC-111
 	void insert() {
-		final String sql = sqlGenerator.getInsert(emptySet());
-
-		assertSoftly(softly -> {
-
-			softly.assertThat(sql) //
-					.startsWith("INSERT INTO") //
-					.contains("dummy_entity") //
-					.contains(":test") //
-					.contains(":attr1") //
-					.contains(":attr2") //
-					.contains(":prefix2_attr1") //
-					.contains(":prefix2_attr2") //
-					.contains(":prefix_test") //
-					.contains(":prefix_attr1") //
-					.contains(":prefix_attr2") //
-					.contains(":prefix_prefix2_attr1") //
-					.contains(":prefix_prefix2_attr2");
-		});
+		String sql = sqlGenerator.getInsert(emptySet());
+
+		assertThat(sql) //
+				.startsWith("INSERT INTO") //
+				.contains("dummy_entity") //
+				.contains(":test") //
+				.contains(":attr1") //
+				.contains(":attr2") //
+				.contains(":prefix2_attr1") //
+				.contains(":prefix2_attr2") //
+				.contains(":prefix_test") //
+				.contains(":prefix_attr1") //
+				.contains(":prefix_attr2") //
+				.contains(":prefix_prefix2_attr1") //
+				.contains(":prefix_prefix2_attr2");
 	}
 
 	@Test // DATAJDBC-111
 	void update() {
-		final String sql = sqlGenerator.getUpdate();
-
-		assertSoftly(softly -> {
-
-			softly.assertThat(sql) //
-					.startsWith("UPDATE") //
-					.contains("dummy_entity") //
-					.contains("test = :test") //
-					.contains("attr1 = :attr1") //
-					.contains("attr2 = :attr2") //
-					.contains("prefix2_attr1 = :prefix2_attr1") //
-					.contains("prefix2_attr2 = :prefix2_attr2") //
-					.contains("prefix_test = :prefix_test") //
-					.contains("prefix_attr1 = :prefix_attr1") //
-					.contains("prefix_attr2 = :prefix_attr2") //
-					.contains("prefix_prefix2_attr1 = :prefix_prefix2_attr1") //
-					.contains("prefix_prefix2_attr2 = :prefix_prefix2_attr2");
-		});
+		String sql = sqlGenerator.getUpdate();
+
+		assertThat(sql) //
+				.startsWith("UPDATE") //
+				.contains("dummy_entity") //
+				.contains("test = :test") //
+				.contains("attr1 = :attr1") //
+				.contains("attr2 = :attr2") //
+				.contains("prefix2_attr1 = :prefix2_attr1") //
+				.contains("prefix2_attr2 = :prefix2_attr2") //
+				.contains("prefix_test = :prefix_test") //
+				.contains("prefix_attr1 = :prefix_attr1") //
+				.contains("prefix_attr2 = :prefix_attr2") //
+				.contains("prefix_prefix2_attr1 = :prefix_prefix2_attr1") //
+				.contains("prefix_prefix2_attr2 = :prefix_prefix2_attr2");
 	}
 
 	@Test // DATAJDBC-340
-	@Disabled // this is just broken right now
 	void deleteByPath() {
 
-		final String sql = sqlGenerator
+		sqlGenerator = createSqlGenerator(DummyEntity2.class);
+
+		String sql = sqlGenerator
 				.createDeleteByPath(PersistentPropertyPathTestUtils.getPath("embedded.other", DummyEntity2.class, context));
 
-		assertThat(sql).containsSequence("DELETE FROM other_entity", //
-				"WHERE", //
-				"embedded_with_reference IN (", //
-				"SELECT ", //
-				"id ", //
-				"FROM", //
-				"dummy_entity2", //
-				"WHERE", //
-				"embedded_with_reference = :rootId");
+		assertThat(sql).isEqualTo("DELETE FROM other_entity WHERE other_entity.dummy_entity2 = :id");
 	}
 
 	@Test // DATAJDBC-340
@@ -275,14 +396,9 @@ void joinForEmbeddedWithReference() {
 
 		SqlGenerator.Join join = generateJoin("embedded.other", DummyEntity2.class);
 
-		assertSoftly(softly -> {
-
-			softly.assertThat(join.getJoinTable().getName()).isEqualTo(SqlIdentifier.unquoted("other_entity"));
-			softly.assertThat(join.getJoinColumn().getTable()).isEqualTo(join.getJoinTable());
-			softly.assertThat(join.getJoinColumn().getName()).isEqualTo(SqlIdentifier.unquoted("dummy_entity2"));
-			softly.assertThat(join.getParentId().getName()).isEqualTo(SqlIdentifier.unquoted("id"));
-			softly.assertThat(join.getParentId().getTable().getName()).isEqualTo(SqlIdentifier.unquoted("dummy_entity2"));
-		});
+		assertThat(join.joinTable().getName()).isEqualTo(SqlIdentifier.unquoted("other_entity"));
+		assertThat(join.condition())
+				.isEqualTo(SqlGeneratorUnitTests.equalsCondition("dummy_entity2", "id", join.joinTable(), "dummy_entity2"));
 	}
 
 	@Test // DATAJDBC-340
@@ -301,6 +417,7 @@ void columnForEmbeddedWithReferenceProperty() {
 						SqlIdentifier.unquoted("prefix_other_value"));
 	}
 
+	@Nullable
 	private SqlGenerator.Join generateJoin(String path, Class<?> type) {
 		return createSqlGenerator(type)
 				.getJoin(context.getAggregatePath(PersistentPropertyPathTestUtils.getPath(path, type, context)));
@@ -315,6 +432,7 @@ private SqlIdentifier getAlias(Object maybeAliased) {
 		return null;
 	}
 
+	@Nullable
 	private org.springframework.data.relational.core.sql.Column generatedColumn(String path, Class<?> type) {
 
 		return createSqlGenerator(type)
@@ -332,15 +450,47 @@ static class DummyEntity {
 		@Embedded(onEmpty = OnEmpty.USE_NULL) CascadedEmbedded embeddable;
 	}
 
+	record WrappedId(Long id) {
+	}
+
+	static class WithWrappedId {
+
+		@Id
+		@Embedded(onEmpty = OnEmpty.USE_NULL) WrappedId wrappedId;
+
+		String name;
+	}
+
+	record EmbeddedId(Long one, String two) {
+	}
+
+	static class WithEmbeddedId {
+
+		@Id
+		@Embedded(onEmpty = OnEmpty.USE_NULL) EmbeddedId embeddedId;
+
+		String name;
+
+	}
+
+	static class WithEmbeddedIdAndReference {
+
+		@Id
+		@Embedded(onEmpty = OnEmpty.USE_NULL) EmbeddedId embeddedId;
+
+		String name;
+		OtherEntity other;
+	}
+
 	@SuppressWarnings("unused")
 	static class CascadedEmbedded {
 		String test;
-		@Embedded(onEmpty = OnEmpty.USE_NULL, prefix = "prefix2_") Embeddable prefixedEmbeddable;
-		@Embedded(onEmpty = OnEmpty.USE_NULL) Embeddable embeddable;
+		@Embedded(onEmpty = OnEmpty.USE_NULL, prefix = "prefix2_") NoId prefixedEmbeddable;
+		@Embedded(onEmpty = OnEmpty.USE_NULL) NoId embeddable;
 	}
 
 	@SuppressWarnings("unused")
-	static class Embeddable {
+	static class NoId {
 		Long attr1;
 		String attr2;
 	}
@@ -362,8 +512,7 @@ static class OtherEntity {
 	}
 
 	@Table("a")
-	private
-	record WithEmbeddedAndAggregateReference(@Id long id,
+	private record WithEmbeddedAndAggregateReference(@Id long id,
 			@Embedded.Nullable(prefix = "nested_") WithAggregateReference nested) {
 	}
 
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorFixedNamingStrategyUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorFixedNamingStrategyUnitTests.java
index 502b310b52..5ecbdd9cc8 100644
--- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorFixedNamingStrategyUnitTests.java
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorFixedNamingStrategyUnitTests.java
@@ -30,11 +30,12 @@
 import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
 
 /**
- * Unit tests the {@link SqlGenerator} with a fixed {@link NamingStrategy} implementation containing a hard wired
+ * Unit tests the {@link SqlGenerator} with a fixed {@link NamingStrategy} implementation containing a hard-wired
  * schema, table, and property prefix.
  *
  * @author Greg Turnquist
  * @author Mark Paluch
+ * @author Jens Schauder
  */
 class SqlGeneratorFixedNamingStrategyUnitTests {
 
@@ -90,7 +91,7 @@ void findOneWithOverriddenFixedTableName() {
 								+ "FROM \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" "
 								+ "LEFT OUTER JOIN \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\" \"ref\" ON \"ref\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" = \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\".\"FIXEDCUSTOMPROPERTYPREFIX_ID\" L"
 								+ "EFT OUTER JOIN \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_SECONDLEVELREFERENCEDENTITY\" \"ref_further\" ON \"ref_further\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\" = \"ref\".\"FIXEDCUSTOMPROPERTYPREFIX_L1ID\" "
-								+ "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\".\"FIXEDCUSTOMPROPERTYPREFIX_ID\" = :id");
+								+ "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\".\"FIXEDCUSTOMPROPERTYPREFIX_ID\" = :FixedCustomPropertyPrefix_id");
 		softAssertions.assertAll();
 	}
 
@@ -121,7 +122,7 @@ void cascadingDeleteFirstLevel() {
 		String sql = sqlGenerator.createDeleteByPath(getPath("ref"));
 
 		assertThat(sql).isEqualTo("DELETE FROM \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\" "
-				+ "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" = :rootId");
+				+ "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" = :FixedCustomPropertyPrefix_id");
 	}
 
 	@Test // DATAJDBC-107
@@ -136,7 +137,7 @@ void cascadingDeleteAllSecondLevel() {
 						+ "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_SECONDLEVELREFERENCEDENTITY\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\" IN "
 						+ "(SELECT \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\".\"FIXEDCUSTOMPROPERTYPREFIX_L1ID\" "
 						+ "FROM \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\" "
-						+ "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" = :rootId)");
+						+ "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" = :FixedCustomPropertyPrefix_id)");
 	}
 
 	@Test // DATAJDBC-107
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java
index 62e95245d7..7e7989ef48 100644
--- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java
@@ -57,6 +57,7 @@
 import org.springframework.data.relational.core.query.Criteria;
 import org.springframework.data.relational.core.query.Query;
 import org.springframework.data.relational.core.sql.Aliased;
+import org.springframework.data.relational.core.sql.Comparison;
 import org.springframework.data.relational.core.sql.LockMode;
 import org.springframework.data.relational.core.sql.SqlIdentifier;
 import org.springframework.data.relational.core.sql.Table;
@@ -92,6 +93,22 @@ class SqlGeneratorUnitTests {
 	});
 	private SqlGenerator sqlGenerator;
 
+	static Comparison equalsCondition(Table parentTable, SqlIdentifier parentId, Table joinedTable,
+			SqlIdentifier joinedColumn) {
+		return org.springframework.data.relational.core.sql.Column.create(joinedColumn, joinedTable)
+				.isEqualTo(org.springframework.data.relational.core.sql.Column.create(parentId, parentTable));
+	}
+
+	static Comparison equalsCondition(SqlIdentifier parentTable, SqlIdentifier parentId, Table joinedTable,
+			SqlIdentifier joinedColumn) {
+		return equalsCondition(Table.create(parentTable), parentId, joinedTable, joinedColumn);
+	}
+
+	static Comparison equalsCondition(String parentTable, String parentId, Table joinedTable, String joinedColumn) {
+		return equalsCondition(SqlIdentifier.unquoted(parentTable), SqlIdentifier.unquoted(parentId), joinedTable,
+				SqlIdentifier.unquoted(joinedColumn));
+	}
+
 	@BeforeEach
 	void setUp() {
 		this.sqlGenerator = createSqlGenerator(DummyEntity.class);
@@ -160,7 +177,7 @@ void cascadingDeleteFirstLevel() {
 
 		String sql = sqlGenerator.createDeleteByPath(getPath("ref", DummyEntity.class));
 
-		assertThat(sql).isEqualTo("DELETE FROM referenced_entity WHERE referenced_entity.dummy_entity = :rootId");
+		assertThat(sql).isEqualTo("DELETE FROM referenced_entity WHERE referenced_entity.dummy_entity = :id1");
 	}
 
 	@Test // GH-537
@@ -177,7 +194,7 @@ void cascadingDeleteByPathSecondLevel() {
 		String sql = sqlGenerator.createDeleteByPath(getPath("ref.further", DummyEntity.class));
 
 		assertThat(sql).isEqualTo(
-				"DELETE FROM second_level_referenced_entity WHERE second_level_referenced_entity.referenced_entity IN (SELECT referenced_entity.x_l1id FROM referenced_entity WHERE referenced_entity.dummy_entity = :rootId)");
+				"DELETE FROM second_level_referenced_entity WHERE second_level_referenced_entity.referenced_entity IN (SELECT referenced_entity.x_l1id FROM referenced_entity WHERE referenced_entity.dummy_entity = :id1)");
 	}
 
 	@Test // GH-537
@@ -227,7 +244,7 @@ void deleteMapByPath() {
 
 		String sql = sqlGenerator.createDeleteByPath(getPath("mappedElements", DummyEntity.class));
 
-		assertThat(sql).isEqualTo("DELETE FROM element WHERE element.dummy_entity = :rootId");
+		assertThat(sql).isEqualTo("DELETE FROM element WHERE element.dummy_entity = :id1");
 	}
 
 	@Test // DATAJDBC-101
@@ -702,7 +719,7 @@ void readOnlyPropertyIncludedIntoQuery_when_generateFindOneSql() {
 						+ "entity_with_read_only_property.x_name AS x_name, " //
 						+ "entity_with_read_only_property.x_read_only_value AS x_read_only_value " //
 						+ "FROM entity_with_read_only_property " //
-						+ "WHERE entity_with_read_only_property.x_id = :id" //
+						+ "WHERE entity_with_read_only_property.x_id = :x_id" //
 		);
 	}
 
@@ -721,7 +738,7 @@ void deletingLongChain() {
 						"WHERE chain2.chain3 IN (" + //
 						"SELECT chain3.x_three " + //
 						"FROM chain3 " + //
-						"WHERE chain3.chain4 = :rootId" + //
+						"WHERE chain3.chain4 = :x_four" + //
 						")))");
 	}
 
@@ -730,7 +747,7 @@ void deletingLongChainNoId() {
 
 		assertThat(createSqlGenerator(NoIdChain4.class)
 				.createDeleteByPath(getPath("chain3.chain2.chain1.chain0", NoIdChain4.class))) //
-				.isEqualTo("DELETE FROM no_id_chain0 WHERE no_id_chain0.no_id_chain4 = :rootId");
+				.isEqualTo("DELETE FROM no_id_chain0 WHERE no_id_chain0.no_id_chain4 = :x_four");
 	}
 
 	@Test // DATAJDBC-359
@@ -746,7 +763,7 @@ void deletingLongChainNoIdWithBackreferenceNotReferencingTheRoot() {
 								+ "WHERE no_id_chain4.id_no_id_chain IN (" //
 								+ "SELECT id_no_id_chain.x_id " //
 								+ "FROM id_no_id_chain " //
-								+ "WHERE id_no_id_chain.id_id_no_id_chain = :rootId" //
+								+ "WHERE id_no_id_chain.id_id_no_id_chain = :x_id" //
 								+ "))");
 	}
 
@@ -762,11 +779,10 @@ void joinForSimpleReference() {
 
 		assertSoftly(softly -> {
 
-			softly.assertThat(join.getJoinTable().getName()).isEqualTo(SqlIdentifier.quoted("REFERENCED_ENTITY"));
-			softly.assertThat(join.getJoinColumn().getTable()).isEqualTo(join.getJoinTable());
-			softly.assertThat(join.getJoinColumn().getName()).isEqualTo(SqlIdentifier.quoted("DUMMY_ENTITY"));
-			softly.assertThat(join.getParentId().getName()).isEqualTo(SqlIdentifier.quoted("id1"));
-			softly.assertThat(join.getParentId().getTable().getName()).isEqualTo(SqlIdentifier.quoted("DUMMY_ENTITY"));
+			softly.assertThat(join.joinTable().getName()).isEqualTo(SqlIdentifier.quoted("REFERENCED_ENTITY"));
+			softly.assertThat(join.condition()).isEqualTo(equalsCondition(SqlIdentifier.quoted("DUMMY_ENTITY"),
+					SqlIdentifier.quoted("id1"), join.joinTable(), SqlIdentifier.quoted("DUMMY_ENTITY")));
+
 		});
 	}
 
@@ -793,13 +809,11 @@ void joinForSecondLevelReference() {
 		SqlGenerator.Join join = generateJoin("ref.further", DummyEntity.class);
 
 		assertSoftly(softly -> {
+			softly.assertThat(join.joinTable().getName()).isEqualTo(SqlIdentifier.quoted("SECOND_LEVEL_REFERENCED_ENTITY"));
+			softly.assertThat(join.condition())
+					.isEqualTo(equalsCondition(Table.create("REFERENCED_ENTITY").as(SqlIdentifier.quoted("ref")),
+							SqlIdentifier.quoted("X_L1ID"), join.joinTable(), SqlIdentifier.quoted("REFERENCED_ENTITY")));
 
-			softly.assertThat(join.getJoinTable().getName())
-					.isEqualTo(SqlIdentifier.quoted("SECOND_LEVEL_REFERENCED_ENTITY"));
-			softly.assertThat(join.getJoinColumn().getTable()).isEqualTo(join.getJoinTable());
-			softly.assertThat(join.getJoinColumn().getName()).isEqualTo(SqlIdentifier.quoted("REFERENCED_ENTITY"));
-			softly.assertThat(join.getParentId().getName()).isEqualTo(SqlIdentifier.quoted("X_L1ID"));
-			softly.assertThat(join.getParentId().getTable().getName()).isEqualTo(SqlIdentifier.quoted("REFERENCED_ENTITY"));
 		});
 	}
 
@@ -807,19 +821,15 @@ void joinForSecondLevelReference() {
 	void joinForOneToOneWithoutId() {
 
 		SqlGenerator.Join join = generateJoin("child", ParentOfNoIdChild.class);
-		Table joinTable = join.getJoinTable();
+		Table joinTable = join.joinTable();
 
 		assertSoftly(softly -> {
 
 			softly.assertThat(joinTable.getName()).isEqualTo(SqlIdentifier.quoted("NO_ID_CHILD"));
 			softly.assertThat(joinTable).isInstanceOf(Aliased.class);
 			softly.assertThat(((Aliased) joinTable).getAlias()).isEqualTo(SqlIdentifier.quoted("child"));
-			softly.assertThat(join.getJoinColumn().getTable()).isEqualTo(joinTable);
-			softly.assertThat(join.getJoinColumn().getName()).isEqualTo(SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD"));
-			softly.assertThat(join.getParentId().getName()).isEqualTo(SqlIdentifier.quoted("X_ID"));
-			softly.assertThat(join.getParentId().getTable().getName())
-					.isEqualTo(SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD"));
-
+			softly.assertThat(join.condition()).isEqualTo(equalsCondition(SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD"),
+					SqlIdentifier.quoted("X_ID"), join.joinTable(), SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD")));
 		});
 	}
 
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryTest.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryUnitTests.java
similarity index 86%
rename from spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryTest.java
rename to spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryUnitTests.java
index 9efdb3aeab..b7371c6a7f 100644
--- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryTest.java
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryUnitTests.java
@@ -26,6 +26,7 @@
 import java.util.List;
 import java.util.Objects;
 
+import org.assertj.core.api.SoftAssertions;
 import org.junit.jupiter.api.Test;
 import org.springframework.core.convert.converter.Converter;
 import org.springframework.data.annotation.Id;
@@ -34,6 +35,7 @@
 import org.springframework.data.jdbc.core.mapping.JdbcMappingContext;
 import org.springframework.data.relational.core.conversion.IdValueSource;
 import org.springframework.data.relational.core.mapping.Column;
+import org.springframework.data.relational.core.mapping.Embedded;
 import org.springframework.data.relational.core.mapping.RelationalMappingContext;
 import org.springframework.data.relational.core.sql.SqlIdentifier;
 import org.springframework.jdbc.core.JdbcOperations;
@@ -43,7 +45,7 @@
  *
  * @author Chirag Tailor
  */
-class SqlParametersFactoryTest {
+class SqlParametersFactoryUnitTests {
 
 	RelationalMappingContext context = new JdbcMappingContext();
 	RelationResolver relationResolver = mock(RelationResolver.class);
@@ -51,20 +53,20 @@ class SqlParametersFactoryTest {
 	SqlParametersFactory sqlParametersFactory = new SqlParametersFactory(context, converter);
 
 	@Test // DATAJDBC-412
-	public void considersConfiguredWriteConverterForIdValueObjects_onRead() {
+	void considersConfiguredWriteConverterForIdValueObjects_onRead() {
 
 		SqlParametersFactory sqlParametersFactory = createSqlParametersFactoryWithConverters(
 				singletonList(IdValueToStringConverter.INSTANCE));
 
 		String rawId = "batman";
 		SqlIdentifierParameterSource sqlParameterSource = sqlParametersFactory.forQueryById(new IdValue(rawId),
-				WithValueObjectId.class, SqlGenerator.ID_SQL_PARAMETER);
+				WithValueObjectId.class);
 
 		assertThat(sqlParameterSource.getValue("id")).isEqualTo(rawId);
 	}
 
 	@Test // DATAJDBC-349
-	public void considersConfiguredWriteConverterForIdValueObjectsWhichReferencedInOneToManyRelationship() {
+	void considersConfiguredWriteConverterForIdValueObjectsWhichReferencedInOneToManyRelationship() {
 
 		SqlParametersFactory sqlParametersFactory = createSqlParametersFactoryWithConverters(
 				singletonList(IdValueToStringConverter.INSTANCE));
@@ -85,8 +87,7 @@ public void considersConfiguredWriteConverterForIdValueObjectsWhichReferencedInO
 		assertThat(sqlParameterSource.getValue("DUMMYENTITYROOT")).isEqualTo(rawId);
 	}
 
-	@Test
-	// DATAJDBC-146
+	@Test // DATAJDBC-146
 	void identifiersGetAddedAsParameters() {
 
 		long id = 4711L;
@@ -100,8 +101,7 @@ void identifiersGetAddedAsParameters() {
 		assertThat(sqlParameterSource.getValue("reference")).isEqualTo(reference);
 	}
 
-	@Test
-	// DATAJDBC-146
+	@Test // DATAJDBC-146
 	void additionalIdentifierForIdDoesNotLeadToDuplicateParameters() {
 
 		long id = 4711L;
@@ -113,8 +113,7 @@ void additionalIdentifierForIdDoesNotLeadToDuplicateParameters() {
 		assertThat(sqlParameterSource.getValue("id")).isEqualTo(id);
 	}
 
-	@Test
-	// DATAJDBC-235
+	@Test // DATAJDBC-235
 	void considersConfiguredWriteConverter() {
 
 		SqlParametersFactory sqlParametersFactory = createSqlParametersFactoryWithConverters(
@@ -128,8 +127,7 @@ void considersConfiguredWriteConverter() {
 		assertThat(sqlParameterSource.getValue("flag")).isEqualTo("T");
 	}
 
-	@Test
-	// DATAJDBC-412
+	@Test // DATAJDBC-412
 	void considersConfiguredWriteConverterForIdValueObjects_onWrite() {
 
 		SqlParametersFactory sqlParametersFactory = createSqlParametersFactoryWithConverters(
@@ -146,8 +144,7 @@ void considersConfiguredWriteConverterForIdValueObjects_onWrite() {
 		assertThat(sqlParameterSource.getValue("value")).isEqualTo(value);
 	}
 
-	@Test
-	// GH-1405
+	@Test // GH-1405
 	void parameterNamesGetSanitized() {
 
 		WithIllegalCharacters entity = new WithIllegalCharacters(23L, "aValue");
@@ -162,6 +159,22 @@ void parameterNamesGetSanitized() {
 		assertThat(sqlParameterSource.getValue("val&ue")).isNull();
 	}
 
+	@Test // GH-574
+	void parametersForInsertForEmbeddedWrappedId() {
+
+		SingleEmbeddedIdEntity entity = new SingleEmbeddedIdEntity(new WrappedPk(23L), "alpha");
+
+		SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forInsert(entity, SingleEmbeddedIdEntity.class,
+				Identifier.empty(), IdValueSource.PROVIDED);
+
+		SoftAssertions.assertSoftly(softly -> {
+
+			softly.assertThat(parameterSource.getParameterNames()).containsExactlyInAnyOrder("id", "name");
+			softly.assertThat(parameterSource.getValue("id")).isEqualTo(23L);
+			softly.assertThat(parameterSource.getValue("name")).isEqualTo("alpha");
+		});
+	}
+
 	@WritingConverter
 	enum IdValueToStringConverter implements Converter<IdValue, String> {
 
@@ -299,6 +312,17 @@ private SqlParametersFactory createSqlParametersFactoryWithConverters(List<?> co
 
 		MappingJdbcConverter converter = new MappingJdbcConverter(context, relationResolver,
 				new JdbcCustomConversions(converters), new DefaultJdbcTypeFactory(mock(JdbcOperations.class)));
+		context.setSimpleTypeHolder(converter.getConversions().getSimpleTypeHolder());
+
 		return new SqlParametersFactory(context, converter);
 	}
+
+	private record WrappedPk(Long id) {
+	}
+
+	private record SingleEmbeddedIdEntity( //
+			@Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) WrappedPk wrappedPk, //
+			String name //
+	) {
+	}
 }
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryEmbeddedWithCollectionIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryEmbeddedWithCollectionIntegrationTests.java
index 4e566f054c..456d9fd0b0 100644
--- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryEmbeddedWithCollectionIntegrationTests.java
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryEmbeddedWithCollectionIntegrationTests.java
@@ -18,7 +18,6 @@
 import static java.util.Arrays.*;
 import static org.assertj.core.api.Assertions.*;
 
-import java.sql.SQLException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -68,7 +67,7 @@ DummyEntityRepository dummyEntityRepository(JdbcRepositoryFactory factory) {
 	@Autowired Dialect dialect;
 
 	@Test // DATAJDBC-111
-	void savesAnEntity() throws SQLException {
+	void savesAnEntity() {
 
 		DummyEntity entity = repository.save(createDummyEntity());
 
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java
index a941d1830c..3738e1a6c9 100644
--- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java
@@ -29,6 +29,7 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.junit.jupiter.MockitoExtension;
+
 import org.springframework.data.annotation.Id;
 import org.springframework.data.jdbc.core.convert.JdbcConverter;
 import org.springframework.data.jdbc.core.convert.MappingJdbcConverter;
@@ -38,10 +39,11 @@
 import org.springframework.data.jdbc.core.mapping.JdbcMappingContext;
 import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
 import org.springframework.data.relational.core.dialect.Escaper;
-import org.springframework.data.relational.core.dialect.H2Dialect;
 import org.springframework.data.relational.core.mapping.Embedded;
+import org.springframework.data.relational.core.mapping.Embedded.Nullable;
 import org.springframework.data.relational.core.mapping.MappedCollection;
 import org.springframework.data.relational.core.mapping.Table;
+import org.springframework.data.relational.core.sql.Column;
 import org.springframework.data.relational.core.sql.LockMode;
 import org.springframework.data.relational.repository.Lock;
 import org.springframework.data.relational.repository.query.RelationalParametersParameterAccessor;
@@ -66,23 +68,38 @@
 public class PartTreeJdbcQueryUnitTests {
 
 	private static final String TABLE = "\"users\"";
-	private static final String ALL_FIELDS = "\"users\".\"ID\" AS \"ID\", \"users\".\"AGE\" AS \"AGE\", \"users\".\"ACTIVE\" AS \"ACTIVE\", \"users\".\"LAST_NAME\" AS \"LAST_NAME\", \"users\".\"FIRST_NAME\" AS \"FIRST_NAME\", \"users\".\"DATE_OF_BIRTH\" AS \"DATE_OF_BIRTH\", \"users\".\"HOBBY_REFERENCE\" AS \"HOBBY_REFERENCE\", \"hated\".\"NAME\" AS \"HATED_NAME\", \"users\".\"USER_CITY\" AS \"USER_CITY\", \"users\".\"USER_STREET\" AS \"USER_STREET\"";
-	private static final String JOIN_CLAUSE = "FROM \"users\" LEFT OUTER JOIN \"HOBBY\" \"hated\" ON \"hated\".\"USERS\" = \"users\".\"ID\"";
-	private static final String BASE_SELECT = "SELECT " + ALL_FIELDS + " " + JOIN_CLAUSE;
+	private static final String JOIN_CLAUSE = "FROM \"users\" LEFT OUTER JOIN \"HOBBY\" \"hated\" ON \"hated\".\"USERS_ID\" = \"users\".\"ID\" AND \"hated\".\"USERS_SUB_ID\" = \"users\".\"SUB_ID\"";
+
+	private JdbcMappingContext mappingContext = new JdbcMappingContext();
+	private JdbcConverter converter = new MappingJdbcConverter(mappingContext, mock(RelationResolver.class));
+	private ReturnedType returnedType = mock(ReturnedType.class);
+
+	private org.springframework.data.relational.core.sql.Table users = org.springframework.data.relational.core.sql.Table
+			.create("users");
 
-	JdbcMappingContext mappingContext = new JdbcMappingContext();
-	JdbcConverter converter = new MappingJdbcConverter(mappingContext, mock(RelationResolver.class));
-	ReturnedType returnedType = mock(ReturnedType.class);
+	private org.springframework.data.relational.core.sql.Table hobby = org.springframework.data.relational.core.sql.Table
+			.create("hobby").as("hated");
+	private List<Column> columns = List.of(users.column("ID"), //
+			users.column("SUB_ID"), //
+			users.column("AGE"), //
+			users.column("ACTIVE"), //
+			users.column("USER_STREET"), //
+			users.column("USER_CITY"), //
+			users.column("LAST_NAME"), //
+			users.column("FIRST_NAME"), //
+			users.column("DATE_OF_BIRTH"), //
+			users.column("HOBBY_REFERENCE"), //
+			hobby.column("NAME").as("HATED_NAME"));
 
 	@Test // DATAJDBC-318
-	public void shouldFailForQueryByReference() throws Exception {
+	void shouldFailForQueryByReference() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByHated", Hobby.class);
 		assertThatIllegalArgumentException().isThrownBy(() -> createQuery(queryMethod));
 	}
 
 	@Test // GH-922
-	public void createQueryByAggregateReference() throws Exception {
+	void createQueryByAggregateReference() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByHobbyReference", Hobby.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
@@ -90,13 +107,9 @@ public void createQueryByAggregateReference() throws Exception {
 		hobby.name = "twentythree";
 		ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { hobby }), returnedType);
 
-		assertSoftly(softly -> {
-
-			softly.assertThat(query.getQuery())
-					.isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference");
-
-			softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("hobby_reference")).isEqualTo("twentythree");
-		});
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference")
+				.hasBindValue("hobby_reference", "twentythree");
 	}
 
 	@Test // GH-922
@@ -114,8 +127,7 @@ void createQueryWithPessimisticWriteLock() throws Exception {
 
 			softly.assertThat(query.getQuery().toUpperCase()).endsWith("FOR UPDATE");
 
-			softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo(firstname);
-			softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("last_name")).isEqualTo(lastname);
+			QueryAssert.assertThat(query).hasBindValue("first_name", firstname).hasBindValue("last_name", lastname);
 		});
 	}
 
@@ -135,27 +147,26 @@ void createQueryWithPessimisticReadLock() throws Exception {
 			// this is also for update since h2 dialect does not distinguish between lockmodes
 			softly.assertThat(query.getQuery().toUpperCase()).endsWith("FOR UPDATE");
 
-			softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo(firstname);
-			softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("age")).isEqualTo(age);
+			QueryAssert.assertThat(query).hasBindValue("first_name", firstname).hasBindValue("age", age);
 		});
 	}
 
 	@Test // DATAJDBC-318
-	public void shouldFailForQueryByList() throws Exception {
+	void shouldFailForQueryByList() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByHobbies", Object.class);
 		assertThatIllegalArgumentException().isThrownBy(() -> createQuery(queryMethod));
 	}
 
 	@Test // DATAJDBC-318
-	public void shouldFailForQueryByEmbeddedList() throws Exception {
+	void shouldFailForQueryByEmbeddedList() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findByAnotherEmbeddedList", Object.class);
 		assertThatIllegalArgumentException().isThrownBy(() -> createQuery(queryMethod));
 	}
 
 	@Test // GH-922
-	public void createQueryForQueryByAggregateReference() throws Exception {
+	void createQueryForQueryByAggregateReference() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findViaReferenceByHobbyReference", AggregateReference.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
@@ -164,15 +175,14 @@ public void createQueryForQueryByAggregateReference() throws Exception {
 
 		assertSoftly(softly -> {
 
-			softly.assertThat(query.getQuery())
-					.isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference");
-
-			softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("hobby_reference")).isEqualTo("twentythree");
+			QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+					.contains(" WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference")
+					.hasBindValue("hobby_reference", "twentythree");
 		});
 	}
 
 	@Test // GH-922
-	public void createQueryForQueryByAggregateReferenceId() throws Exception {
+	void createQueryForQueryByAggregateReferenceId() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findViaIdByHobbyReference", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
@@ -181,25 +191,25 @@ public void createQueryForQueryByAggregateReferenceId() throws Exception {
 
 		assertSoftly(softly -> {
 
-			softly.assertThat(query.getQuery())
-					.isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference");
-
-			softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("hobby_reference")).isEqualTo("twentythree");
+			QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+					.contains(" WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference")
+					.hasBindValue("hobby_reference", "twentythree");
 		});
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByStringAttribute() throws Exception {
+	void createsQueryToFindAllEntitiesByStringAttribute() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { "John" }), returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name");
 	}
 
 	@Test // GH-971
-	public void createsQueryToFindAllEntitiesByProjectionAttribute() throws Exception {
+	void createsQueryToFindAllEntitiesByProjectionAttribute() throws Exception {
 
 		when(returnedType.needsCustomConstruction()).thenReturn(true);
 		when(returnedType.getInputProperties()).thenReturn(Collections.singletonList("firstName"));
@@ -213,17 +223,18 @@ public void createsQueryToFindAllEntitiesByProjectionAttribute() throws Exceptio
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryWithIsNullCondition() throws Exception {
+	void createsQueryWithIsNullCondition() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		ParametrizedQuery query = jdbcQuery.createQuery((getAccessor(queryMethod, new Object[] { null })), returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" IS NULL");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" IS NULL");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryWithLimitForExistsProjection() throws Exception {
+	void createsQueryWithLimitForExistsProjection() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("existsByFirstName", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
@@ -234,31 +245,31 @@ public void createsQueryWithLimitForExistsProjection() throws Exception {
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByTwoStringAttributes() throws Exception {
+	void createsQueryToFindAllEntitiesByTwoStringAttributes() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameAndFirstName", String.class, String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { "Doe", "John" }),
 				returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"LAST_NAME\" = :last_name AND (" + TABLE
-				+ ".\"FIRST_NAME\" = :first_name)");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"LAST_NAME\" = :last_name AND (" + TABLE + ".\"FIRST_NAME\" = :first_name)");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByOneOfTwoStringAttributes() throws Exception {
+	void createsQueryToFindAllEntitiesByOneOfTwoStringAttributes() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameOrFirstName", String.class, String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { "Doe", "John" }),
 				returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"LAST_NAME\" = :last_name OR (" + TABLE
-				+ ".\"FIRST_NAME\" = :first_name)");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"LAST_NAME\" = :last_name OR (" + TABLE + ".\"FIRST_NAME\" = :first_name)");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByDateAttributeBetween() throws Exception {
+	void createsQueryToFindAllEntitiesByDateAttributeBetween() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthBetween", Date.class, Date.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
@@ -269,251 +280,261 @@ public void createsQueryToFindAllEntitiesByDateAttributeBetween() throws Excepti
 
 		assertSoftly(softly -> {
 
-			softly.assertThat(query.getQuery())
-					.isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"DATE_OF_BIRTH\" BETWEEN :date_of_birth AND :date_of_birth1");
-
-			softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("date_of_birth")).isEqualTo(from);
-			softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("date_of_birth1")).isEqualTo(to);
+			QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+					.contains(" WHERE " + TABLE + ".\"DATE_OF_BIRTH\" BETWEEN :date_of_birth AND :date_of_birth1")
+					.hasBindValue("date_of_birth", from).hasBindValue("date_of_birth1", to);
 		});
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByIntegerAttributeLessThan() throws Exception {
+	void createsQueryToFindAllEntitiesByIntegerAttributeLessThan() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeLessThan", Integer.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" < :age");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns).contains(" WHERE " + TABLE + ".\"AGE\" < :age");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByIntegerAttributeLessThanEqual() throws Exception {
+	void createsQueryToFindAllEntitiesByIntegerAttributeLessThanEqual() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeLessThanEqual", Integer.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" <= :age");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"AGE\" <= :age");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThan() throws Exception {
+	void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThan() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeGreaterThan", Integer.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" > :age");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns).contains(" WHERE " + TABLE + ".\"AGE\" > :age");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThanEqual() throws Exception {
+	void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThanEqual() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeGreaterThanEqual", Integer.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" >= :age");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"AGE\" >= :age");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByDateAttributeAfter() throws Exception {
+	void createsQueryToFindAllEntitiesByDateAttributeAfter() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthAfter", Date.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { new Date() });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"DATE_OF_BIRTH\" > :date_of_birth");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"DATE_OF_BIRTH\" > :date_of_birth");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByDateAttributeBefore() throws Exception {
+	void createsQueryToFindAllEntitiesByDateAttributeBefore() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthBefore", Date.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { new Date() });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"DATE_OF_BIRTH\" < :date_of_birth");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"DATE_OF_BIRTH\" < :date_of_birth");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByIntegerAttributeIsNull() throws Exception {
+	void createsQueryToFindAllEntitiesByIntegerAttributeIsNull() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIsNull");
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]);
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" IS NULL");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"AGE\" IS NULL");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByIntegerAttributeIsNotNull() throws Exception {
+	void createsQueryToFindAllEntitiesByIntegerAttributeIsNotNull() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIsNotNull");
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]);
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" IS NOT NULL");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"AGE\" IS NOT NULL");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByStringAttributeLike() throws Exception {
+	void createsQueryToFindAllEntitiesByStringAttributeLike() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameLike", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "%John%" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByStringAttributeNotLike() throws Exception {
+	void createsQueryToFindAllEntitiesByStringAttributeNotLike() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotLike", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "%John%" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByStringAttributeStartingWith() throws Exception {
+	void createsQueryToFindAllEntitiesByStringAttributeStartingWith() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameStartingWith", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "Jo" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name");
 	}
 
 	@Test // DATAJDBC-318
-	public void appendsLikeOperatorParameterWithPercentSymbolForStartingWithQuery() throws Exception {
+	void appendsLikeOperatorParameterWithPercentSymbolForStartingWithQuery() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameStartingWith", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "Jo" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name");
-		assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo("Jo%");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "Jo%");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByStringAttributeEndingWith() throws Exception {
+	void createsQueryToFindAllEntitiesByStringAttributeEndingWith() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameEndingWith", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "hn" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%hn");
 	}
 
 	@Test // DATAJDBC-318
-	public void prependsLikeOperatorParameterWithPercentSymbolForEndingWithQuery() throws Exception {
+	void prependsLikeOperatorParameterWithPercentSymbolForEndingWithQuery() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameEndingWith", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "hn" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name");
-		assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo("%hn");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%hn");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByStringAttributeContaining() throws Exception {
+	void createsQueryToFindAllEntitiesByStringAttributeContaining() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameContaining", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%oh%");
 	}
 
 	@Test // DATAJDBC-318
-	public void wrapsLikeOperatorParameterWithPercentSymbolsForContainingQuery() throws Exception {
+	void wrapsLikeOperatorParameterWithPercentSymbolsForContainingQuery() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameContaining", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name");
-		assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo("%oh%");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%oh%");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByStringAttributeNotContaining() throws Exception {
+	void createsQueryToFindAllEntitiesByStringAttributeNotContaining() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotContaining", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name").hasBindValue("first_name", "%oh%");
 	}
 
 	@Test // DATAJDBC-318
-	public void wrapsLikeOperatorParameterWithPercentSymbolsForNotContainingQuery() throws Exception {
+	void wrapsLikeOperatorParameterWithPercentSymbolsForNotContainingQuery() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotContaining", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name");
-		assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo("%oh%");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name").hasBindValue("first_name", "%oh%");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByIntegerAttributeWithDescendingOrderingByStringAttribute()
-			throws Exception {
+	void createsQueryToFindAllEntitiesByIntegerAttributeWithDescendingOrderingByStringAttribute() throws Exception {
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeOrderByLastNameDesc", Integer.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 123 });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery())
-				.isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" = :age ORDER BY \"users\".\"LAST_NAME\" DESC");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"AGE\" = :age ORDER BY \"users\".\"LAST_NAME\" DESC");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByIntegerAttributeWithAscendingOrderingByStringAttribute() throws Exception {
+	void createsQueryToFindAllEntitiesByIntegerAttributeWithAscendingOrderingByStringAttribute() throws Exception {
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeOrderByLastNameAsc", Integer.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 123 });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery())
-				.isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" = :age ORDER BY \"users\".\"LAST_NAME\" ASC");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"AGE\" = :age ORDER BY \"users\".\"LAST_NAME\" ASC");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByStringAttributeNot() throws Exception {
+	void createsQueryToFindAllEntitiesByStringAttributeNot() throws Exception {
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameNot", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "Doe" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"LAST_NAME\" != :last_name");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"LAST_NAME\" != :last_name");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByIntegerAttributeIn() throws Exception {
+	void createsQueryToFindAllEntitiesByIntegerAttributeIn() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIn", Collection.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
@@ -521,56 +542,60 @@ public void createsQueryToFindAllEntitiesByIntegerAttributeIn() throws Exception
 				new Object[] { Collections.singleton(25) });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" IN (:age)");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"AGE\" IN (:age)");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByIntegerAttributeNotIn() throws Exception {
+	void createsQueryToFindAllEntitiesByIntegerAttributeNotIn() throws Exception {
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeNotIn", Collection.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod,
 				new Object[] { Collections.singleton(25) });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" NOT IN (:age)");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"AGE\" NOT IN (:age)");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByBooleanAttributeTrue() throws Exception {
+	void createsQueryToFindAllEntitiesByBooleanAttributeTrue() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByActiveTrue");
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]);
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"ACTIVE\" = :active");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"ACTIVE\" = :active");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByBooleanAttributeFalse() throws Exception {
+	void createsQueryToFindAllEntitiesByBooleanAttributeFalse() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByActiveFalse");
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]);
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"ACTIVE\" = :active");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"ACTIVE\" = :active");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindAllEntitiesByStringAttributeIgnoringCase() throws Exception {
+	void createsQueryToFindAllEntitiesByStringAttributeIgnoringCase() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameIgnoreCase", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "John" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		assertThat(query.getQuery())
-				.isEqualTo(BASE_SELECT + " WHERE UPPER(" + TABLE + ".\"FIRST_NAME\") = UPPER(:first_name)");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE UPPER(" + TABLE + ".\"FIRST_NAME\") = UPPER(:first_name)");
 	}
 
 	@Test // DATAJDBC-318
-	public void throwsExceptionWhenIgnoringCaseIsImpossible() throws Exception {
+	void throwsExceptionWhenIgnoringCaseIsImpossible() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findByIdIgnoringCase", Long.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
@@ -580,7 +605,7 @@ public void throwsExceptionWhenIgnoringCaseIsImpossible() throws Exception {
 	}
 
 	@Test // DATAJDBC-318
-	public void throwsExceptionWhenConditionKeywordIsUnsupported() throws Exception {
+	void throwsExceptionWhenConditionKeywordIsUnsupported() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByIdIsEmpty");
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
@@ -590,7 +615,7 @@ public void throwsExceptionWhenConditionKeywordIsUnsupported() throws Exception
 	}
 
 	@Test // DATAJDBC-318
-	public void throwsExceptionWhenInvalidNumberOfParameterIsGiven() throws Exception {
+	void throwsExceptionWhenInvalidNumberOfParameterIsGiven() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
@@ -600,31 +625,31 @@ public void throwsExceptionWhenInvalidNumberOfParameterIsGiven() throws Exceptio
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryWithLimitToFindEntitiesByStringAttribute() throws Exception {
+	void createsQueryWithLimitToFindEntitiesByStringAttribute() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findTop3ByFirstName", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "John" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		String expectedSql = BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name LIMIT 3";
-		assertThat(query.getQuery()).isEqualTo(expectedSql);
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name LIMIT 3");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryToFindFirstEntityByStringAttribute() throws Exception {
+	void createsQueryToFindFirstEntityByStringAttribute() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findFirstByFirstName", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "John" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		String expectedSql = BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name LIMIT 1";
-		assertThat(query.getQuery()).isEqualTo(expectedSql);
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name LIMIT 1");
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryByEmbeddedObject() throws Exception {
+	void createsQueryByEmbeddedObject() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findByAddress", Address.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
@@ -635,7 +660,8 @@ public void createsQueryByEmbeddedObject() throws Exception {
 		String actualSql = query.getQuery();
 
 		assertThat(actualSql) //
-				.startsWith(BASE_SELECT + " WHERE (" + TABLE + ".\"USER_") //
+				.contains(JOIN_CLAUSE) //
+				.contains(" WHERE (" + TABLE + ".\"USER_") //
 				.endsWith(")") //
 				.contains(TABLE + ".\"USER_STREET\" = :user_street", //
 						" AND ", //
@@ -645,21 +671,19 @@ public void createsQueryByEmbeddedObject() throws Exception {
 	}
 
 	@Test // DATAJDBC-318
-	public void createsQueryByEmbeddedProperty() throws Exception {
+	void createsQueryByEmbeddedProperty() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("findByAddressStreet", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
 		RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "Hello" });
 		ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType);
 
-		String expectedSql = BASE_SELECT + " WHERE " + TABLE + ".\"USER_STREET\" = :user_street";
-
-		assertThat(query.getQuery()).isEqualTo(expectedSql);
-		assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("user_street")).isEqualTo("Hello");
+		QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns)
+				.contains(" WHERE " + TABLE + ".\"USER_STREET\" = :user_street").hasBindValue("user_street", "Hello");
 	}
 
 	@Test // DATAJDBC-534
-	public void createsQueryForCountProjection() throws Exception {
+	void createsQueryForCountProjection() throws Exception {
 
 		JdbcQueryMethod queryMethod = getQueryMethod("countByFirstName", String.class);
 		PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
@@ -779,7 +803,8 @@ interface UserRepository extends Repository<User, Long> {
 	@Table("users")
 	static class User {
 
-		@Id Long id;
+		@Id
+		@Nullable UserId id;
 		String firstName;
 		String lastName;
 		Date dateOfBirth;
@@ -787,7 +812,7 @@ static class User {
 		Boolean active;
 
 		@Embedded(prefix = "user_", onEmpty = Embedded.OnEmpty.USE_NULL) Address address;
-		@Embedded.Nullable AnotherEmbedded anotherEmbedded;
+		@Nullable AnotherEmbedded anotherEmbedded;
 
 		List<Hobby> hobbies;
 		Hobby hated;
@@ -795,6 +820,9 @@ static class User {
 		AggregateReference<Hobby, String> hobbyReference;
 	}
 
+	record UserId(Long id, String subId) {
+	}
+
 	record Address(String street, String city) {
 	}
 
diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/QueryAssert.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/QueryAssert.java
new file mode 100644
index 0000000000..b656c82c27
--- /dev/null
+++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/QueryAssert.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.jdbc.repository.query;
+
+import java.util.Collection;
+
+import org.assertj.core.api.AbstractAssert;
+import org.assertj.core.api.Assertions;
+
+import org.springframework.data.jdbc.core.dialect.JdbcH2Dialect;
+import org.springframework.data.relational.core.dialect.Dialect;
+import org.springframework.data.relational.core.dialect.Escaper;
+import org.springframework.data.relational.core.sql.Aliased;
+import org.springframework.data.relational.core.sql.Column;
+import org.springframework.data.relational.core.sql.SqlIdentifier;
+import org.springframework.jdbc.core.namedparam.SqlParameterSource;
+
+/**
+ * AssertJ Assertions for {@link ParametrizedQuery} allowing to assert the query string and bind values.
+ *
+ * @author Mark Paluch
+ */
+class QueryAssert extends AbstractAssert<QueryAssert, ParametrizedQuery> {
+
+	private static final Dialect DIALECT = JdbcH2Dialect.INSTANCE;
+
+	private QueryAssert(ParametrizedQuery parametrizedQuery) {
+		super(parametrizedQuery, QueryAssert.class);
+	}
+
+	/**
+	 * Entrypoint.
+	 *
+	 * @param parametrizedQuery
+	 * @return
+	 */
+	public static QueryAssert assertThat(ParametrizedQuery parametrizedQuery) {
+		return new QueryAssert(parametrizedQuery).as("Query: ", parametrizedQuery.getQuery());
+	}
+
+	/**
+	 * Assert that the query contains the given columns, quoted and aliased.
+	 *
+	 * @param columns
+	 * @return
+	 */
+	public QueryAssert containsQuotedAliasedColumns(Collection<Column> columns) {
+
+		isNotNull();
+
+		for (Column column : columns) {
+
+			String item = quote(column.getTable().getReferenceName()) + "." + quote(column.getName());
+			if (column instanceof Aliased aliased) {
+				item += " AS " + quote(aliased.getAlias());
+			} else {
+				item += " AS " + quote(column.getReferenceName());
+
+			}
+
+			Assertions.assertThat(actual.getQuery()).contains(item);
+		}
+
+		return this;
+	}
+
+	/**
+	 * Assert that the query contains the given string.
+	 *
+	 * @param expected
+	 * @return
+	 */
+	public QueryAssert contains(String expected) {
+
+		isNotNull();
+
+		Assertions.assertThat(actual.getQuery()).contains(expected);
+
+		return this;
+	}
+
+	/**
+	 * Assert that the query defines a bind value for the given key and that the value matches the expected value.
+	 *
+	 * @param key
+	 * @param value
+	 * @return
+	 */
+	public QueryAssert hasBindValue(String key, Object value) {
+
+		SqlParameterSource parameterSource = actual.getParameterSource(Escaper.DEFAULT);
+		Assertions.assertThat(parameterSource.getValue(key))
+				.describedAs("Parameter source [%s] shouldn contain value [%s] for key [%s]", parameterSource, value, key)
+				.isEqualTo(value);
+
+		return this;
+	}
+
+	private static String quote(SqlIdentifier identifier) {
+		return DIALECT.getIdentifierProcessing().quote(identifier.getReference());
+	}
+}
diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/CompositeIdAggregateTemplateHsqlIntegrationTests-hsql.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/CompositeIdAggregateTemplateHsqlIntegrationTests-hsql.sql
new file mode 100644
index 0000000000..604cbefb2a
--- /dev/null
+++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/CompositeIdAggregateTemplateHsqlIntegrationTests-hsql.sql
@@ -0,0 +1,46 @@
+CREATE TABLE SIMPLE_ENTITY
+(
+    ID   BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    NAME VARCHAR(100)
+);
+
+CREATE TABLE WITH_LIST_AND_COMPOSITE_ID
+(
+    ONE  BIGINT,
+    TWO  VARCHAR(100),
+    NAME VARCHAR(100),
+    PRIMARY KEY (ONE, TWO)
+);
+CREATE TABLE WITH_LIST
+(
+    ID   BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    NAME VARCHAR(100)
+);
+
+CREATE TABLE CHILD
+(
+    WITH_LIST_ID                   BIGINT REFERENCES WITH_LIST (ID),
+    WITH_LIST_KEY                  INT,
+    WITH_LIST_AND_COMPOSITE_ID_ONE BIGINT,
+    WITH_LIST_AND_COMPOSITE_ID_TWO VARCHAR(100),
+    WITH_LIST_AND_COMPOSITE_ID_KEY INT,
+    NAME                           VARCHAR(100),
+    SINGLE_REFERENCE_ONE           BIGINT,
+    SINGLE_REFERENCE_TWO           VARCHAR(100)
+);
+
+CREATE TABLE SIMPLE_ENTITY_WITH_EMBEDDED_PK
+(
+    ONE  BIGINT,
+    TWO  VARCHAR(100),
+    NAME VARCHAR(100),
+    PRIMARY KEY (ONE, TWO)
+);
+
+CREATE TABLE SINGLE_REFERENCE
+(
+    ONE  BIGINT,
+    TWO  VARCHAR(100),
+    NAME VARCHAR(100),
+    PRIMARY KEY (ONE, TWO)
+);
diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql
index 824959d5d7..392e9cc101 100644
--- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql
+++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql
@@ -1,328 +1,328 @@
 CREATE TABLE LEGO_SET
 (
-  "id1"  BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
-  NAME VARCHAR(30)
+    "id1" BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    NAME  VARCHAR(30)
 );
 CREATE TABLE MANUAL
 (
-  "id2"         BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
-  LEGO_SET    BIGINT,
-  "alternative" BIGINT,
-  CONTENT     VARCHAR(2000)
+    "id2"         BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    LEGO_SET      BIGINT,
+    "alternative" BIGINT,
+    CONTENT       VARCHAR(2000)
 );
 
 ALTER TABLE MANUAL
-  ADD FOREIGN KEY (LEGO_SET)
-    REFERENCES LEGO_SET ("id1");
+    ADD FOREIGN KEY (LEGO_SET)
+        REFERENCES LEGO_SET ("id1");
 
 CREATE TABLE ONE_TO_ONE_PARENT
 (
-  "id3"     BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
-  content VARCHAR(30)
+    "id3"   BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    content VARCHAR(30)
 );
 CREATE TABLE Child_No_Id
 (
-  ONE_TO_ONE_PARENT INTEGER PRIMARY KEY,
-  content           VARCHAR(30)
+    ONE_TO_ONE_PARENT INTEGER PRIMARY KEY,
+    content           VARCHAR(30)
 );
 
 CREATE TABLE SIMPLE_LIST_PARENT
 (
-  ID  BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ) PRIMARY KEY,
-  NAME VARCHAR(100)
+    ID   BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ) PRIMARY KEY,
+    NAME VARCHAR(100)
 );
 CREATE TABLE LIST_PARENT
 (
-  "id4"  BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ) PRIMARY KEY,
-  NAME VARCHAR(100)
+    "id4" BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ) PRIMARY KEY,
+    NAME  VARCHAR(100)
 );
 CREATE TABLE ELEMENT_NO_ID
 (
-  CONTENT         VARCHAR(100),
-  SIMPLE_LIST_PARENT_KEY BIGINT,
-  SIMPLE_LIST_PARENT     BIGINT,
-  LIST_PARENT_KEY BIGINT,
-  LIST_PARENT     BIGINT
+    CONTENT                VARCHAR(100),
+    SIMPLE_LIST_PARENT_KEY BIGINT,
+    SIMPLE_LIST_PARENT     BIGINT,
+    LIST_PARENT_KEY        BIGINT,
+    LIST_PARENT            BIGINT
 );
 ALTER TABLE ELEMENT_NO_ID
-  ADD FOREIGN KEY (LIST_PARENT)
-    REFERENCES LIST_PARENT ("id4");
+    ADD FOREIGN KEY (LIST_PARENT)
+        REFERENCES LIST_PARENT ("id4");
 
 CREATE TABLE ARRAY_OWNER
 (
-  ID               BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
-  DIGITS           VARCHAR(20) ARRAY[10] NOT NULL,
-  MULTIDIMENSIONAL VARCHAR(20) ARRAY[10] NULL
+    ID               BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    DIGITS           VARCHAR(20) ARRAY[10] NOT NULL,
+    MULTIDIMENSIONAL VARCHAR(20) ARRAY[10] NULL
 );
 
 CREATE TABLE BYTE_ARRAY_OWNER
 (
-  ID          BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
-  BINARY_DATA VARBINARY(20) NOT NULL
+    ID          BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    BINARY_DATA VARBINARY(20) NOT NULL
 );
 
 CREATE TABLE DOUBLE_LIST_OWNER
 (
-    ID          BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
-    DIGITS      DOUBLE PRECISION ARRAY[10]
+    ID     BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    DIGITS DOUBLE PRECISION ARRAY[10]
 );
 
 CREATE TABLE FLOAT_LIST_OWNER
 (
-    ID          BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
-    DIGITS      FLOAT ARRAY[10]
+    ID     BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    DIGITS FLOAT ARRAY[10]
 );
 
 CREATE TABLE CHAIN4
 (
-  FOUR       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY,
-  FOUR_VALUE VARCHAR(20)
+    FOUR       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY,
+    FOUR_VALUE VARCHAR(20)
 );
 
 CREATE TABLE CHAIN3
 (
-  THREE       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 30) PRIMARY KEY,
-  THREE_VALUE VARCHAR(20),
-  CHAIN4      BIGINT,
-  FOREIGN KEY (CHAIN4) REFERENCES CHAIN4 (FOUR)
+    THREE       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 30) PRIMARY KEY,
+    THREE_VALUE VARCHAR(20),
+    CHAIN4      BIGINT,
+    FOREIGN KEY (CHAIN4) REFERENCES CHAIN4 (FOUR)
 );
 
 CREATE TABLE CHAIN2
 (
-  TWO       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 20) PRIMARY KEY,
-  TWO_VALUE VARCHAR(20),
-  CHAIN3    BIGINT,
-  FOREIGN KEY (CHAIN3) REFERENCES CHAIN3 (THREE)
+    TWO       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 20) PRIMARY KEY,
+    TWO_VALUE VARCHAR(20),
+    CHAIN3    BIGINT,
+    FOREIGN KEY (CHAIN3) REFERENCES CHAIN3 (THREE)
 );
 
 CREATE TABLE CHAIN1
 (
-  ONE       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 10) PRIMARY KEY,
-  ONE_VALUE VARCHAR(20),
-  CHAIN2    BIGINT,
-  FOREIGN KEY (CHAIN2) REFERENCES CHAIN2 (TWO)
+    ONE       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 10) PRIMARY KEY,
+    ONE_VALUE VARCHAR(20),
+    CHAIN2    BIGINT,
+    FOREIGN KEY (CHAIN2) REFERENCES CHAIN2 (TWO)
 );
 
 CREATE TABLE CHAIN0
 (
-  ZERO       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 0) PRIMARY KEY,
-  ZERO_VALUE VARCHAR(20),
-  CHAIN1     BIGINT,
-  FOREIGN KEY (CHAIN1) REFERENCES CHAIN1 (ONE)
+    ZERO       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 0) PRIMARY KEY,
+    ZERO_VALUE VARCHAR(20),
+    CHAIN1     BIGINT,
+    FOREIGN KEY (CHAIN1) REFERENCES CHAIN1 (ONE)
 );
 
 CREATE TABLE NO_ID_CHAIN4
 (
-  FOUR       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY,
-  FOUR_VALUE VARCHAR(20)
+    FOUR       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY,
+    FOUR_VALUE VARCHAR(20)
 );
 
 CREATE TABLE NO_ID_CHAIN3
 (
-  THREE_VALUE  VARCHAR(20),
-  NO_ID_CHAIN4 BIGINT,
-  FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR)
+    THREE_VALUE  VARCHAR(20),
+    NO_ID_CHAIN4 BIGINT,
+    FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR)
 );
 
 CREATE TABLE NO_ID_CHAIN2
 (
-  TWO_VALUE    VARCHAR(20),
-  NO_ID_CHAIN4 BIGINT,
-  FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR)
+    TWO_VALUE    VARCHAR(20),
+    NO_ID_CHAIN4 BIGINT,
+    FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR)
 );
 
 CREATE TABLE NO_ID_CHAIN1
 (
-  ONE_VALUE    VARCHAR(20),
-  NO_ID_CHAIN4 BIGINT,
-  FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR)
+    ONE_VALUE    VARCHAR(20),
+    NO_ID_CHAIN4 BIGINT,
+    FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR)
 );
 
 CREATE TABLE NO_ID_CHAIN0
 (
-  ZERO_VALUE   VARCHAR(20),
-  NO_ID_CHAIN4 BIGINT,
-  FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR)
+    ZERO_VALUE   VARCHAR(20),
+    NO_ID_CHAIN4 BIGINT,
+    FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR)
 );
 
 
 CREATE TABLE NO_ID_LIST_CHAIN4
 (
-  FOUR       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY,
-  FOUR_VALUE VARCHAR(20)
+    FOUR       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY,
+    FOUR_VALUE VARCHAR(20)
 );
 
 CREATE TABLE NO_ID_LIST_CHAIN3
 (
-  THREE_VALUE           VARCHAR(20),
-  NO_ID_LIST_CHAIN4     BIGINT,
-  NO_ID_LIST_CHAIN4_KEY BIGINT,
-  PRIMARY KEY (NO_ID_LIST_CHAIN4,
-               NO_ID_LIST_CHAIN4_KEY),
-  FOREIGN KEY (NO_ID_LIST_CHAIN4) REFERENCES NO_ID_LIST_CHAIN4 (FOUR)
+    THREE_VALUE           VARCHAR(20),
+    NO_ID_LIST_CHAIN4     BIGINT,
+    NO_ID_LIST_CHAIN4_KEY BIGINT,
+    PRIMARY KEY (NO_ID_LIST_CHAIN4,
+                 NO_ID_LIST_CHAIN4_KEY),
+    FOREIGN KEY (NO_ID_LIST_CHAIN4) REFERENCES NO_ID_LIST_CHAIN4 (FOUR)
 );
 
 CREATE TABLE NO_ID_LIST_CHAIN2
 (
-  TWO_VALUE             VARCHAR(20),
-  NO_ID_LIST_CHAIN4     BIGINT,
-  NO_ID_LIST_CHAIN4_KEY BIGINT,
-  NO_ID_LIST_CHAIN3_KEY BIGINT,
-  PRIMARY KEY (NO_ID_LIST_CHAIN4,
-               NO_ID_LIST_CHAIN4_KEY,
-               NO_ID_LIST_CHAIN3_KEY),
-  FOREIGN KEY (
-               NO_ID_LIST_CHAIN4,
-               NO_ID_LIST_CHAIN4_KEY
-    ) REFERENCES NO_ID_LIST_CHAIN3 (
-                                    NO_ID_LIST_CHAIN4,
-                                    NO_ID_LIST_CHAIN4_KEY
-    )
+    TWO_VALUE             VARCHAR(20),
+    NO_ID_LIST_CHAIN4     BIGINT,
+    NO_ID_LIST_CHAIN4_KEY BIGINT,
+    NO_ID_LIST_CHAIN3_KEY BIGINT,
+    PRIMARY KEY (NO_ID_LIST_CHAIN4,
+                 NO_ID_LIST_CHAIN4_KEY,
+                 NO_ID_LIST_CHAIN3_KEY),
+    FOREIGN KEY (
+                 NO_ID_LIST_CHAIN4,
+                 NO_ID_LIST_CHAIN4_KEY
+        ) REFERENCES NO_ID_LIST_CHAIN3 (
+                                        NO_ID_LIST_CHAIN4,
+                                        NO_ID_LIST_CHAIN4_KEY
+        )
 );
 
 CREATE TABLE NO_ID_LIST_CHAIN1
 (
-  ONE_VALUE             VARCHAR(20),
-  NO_ID_LIST_CHAIN4     BIGINT,
-  NO_ID_LIST_CHAIN4_KEY BIGINT,
-  NO_ID_LIST_CHAIN3_KEY BIGINT,
-  NO_ID_LIST_CHAIN2_KEY BIGINT,
-  PRIMARY KEY (NO_ID_LIST_CHAIN4,
-               NO_ID_LIST_CHAIN4_KEY,
-               NO_ID_LIST_CHAIN3_KEY,
-               NO_ID_LIST_CHAIN2_KEY),
-  FOREIGN KEY (
-               NO_ID_LIST_CHAIN4,
-               NO_ID_LIST_CHAIN4_KEY,
-               NO_ID_LIST_CHAIN3_KEY
-    ) REFERENCES NO_ID_LIST_CHAIN2 (
-                                    NO_ID_LIST_CHAIN4,
-                                    NO_ID_LIST_CHAIN4_KEY,
-                                    NO_ID_LIST_CHAIN3_KEY
-    )
+    ONE_VALUE             VARCHAR(20),
+    NO_ID_LIST_CHAIN4     BIGINT,
+    NO_ID_LIST_CHAIN4_KEY BIGINT,
+    NO_ID_LIST_CHAIN3_KEY BIGINT,
+    NO_ID_LIST_CHAIN2_KEY BIGINT,
+    PRIMARY KEY (NO_ID_LIST_CHAIN4,
+                 NO_ID_LIST_CHAIN4_KEY,
+                 NO_ID_LIST_CHAIN3_KEY,
+                 NO_ID_LIST_CHAIN2_KEY),
+    FOREIGN KEY (
+                 NO_ID_LIST_CHAIN4,
+                 NO_ID_LIST_CHAIN4_KEY,
+                 NO_ID_LIST_CHAIN3_KEY
+        ) REFERENCES NO_ID_LIST_CHAIN2 (
+                                        NO_ID_LIST_CHAIN4,
+                                        NO_ID_LIST_CHAIN4_KEY,
+                                        NO_ID_LIST_CHAIN3_KEY
+        )
 );
 
 CREATE TABLE NO_ID_LIST_CHAIN0
 (
-  ZERO_VALUE            VARCHAR(20),
-  NO_ID_LIST_CHAIN4     BIGINT,
-  NO_ID_LIST_CHAIN4_KEY BIGINT,
-  NO_ID_LIST_CHAIN3_KEY BIGINT,
-  NO_ID_LIST_CHAIN2_KEY BIGINT,
-  NO_ID_LIST_CHAIN1_KEY BIGINT,
-  PRIMARY KEY (NO_ID_LIST_CHAIN4,
-               NO_ID_LIST_CHAIN4_KEY,
-               NO_ID_LIST_CHAIN3_KEY,
-               NO_ID_LIST_CHAIN2_KEY,
-               NO_ID_LIST_CHAIN1_KEY),
-  FOREIGN KEY (
-               NO_ID_LIST_CHAIN4,
-               NO_ID_LIST_CHAIN4_KEY,
-               NO_ID_LIST_CHAIN3_KEY,
-               NO_ID_LIST_CHAIN2_KEY
-    ) REFERENCES NO_ID_LIST_CHAIN1 (
-                                    NO_ID_LIST_CHAIN4,
-                                    NO_ID_LIST_CHAIN4_KEY,
-                                    NO_ID_LIST_CHAIN3_KEY,
-                                    NO_ID_LIST_CHAIN2_KEY
-    )
+    ZERO_VALUE            VARCHAR(20),
+    NO_ID_LIST_CHAIN4     BIGINT,
+    NO_ID_LIST_CHAIN4_KEY BIGINT,
+    NO_ID_LIST_CHAIN3_KEY BIGINT,
+    NO_ID_LIST_CHAIN2_KEY BIGINT,
+    NO_ID_LIST_CHAIN1_KEY BIGINT,
+    PRIMARY KEY (NO_ID_LIST_CHAIN4,
+                 NO_ID_LIST_CHAIN4_KEY,
+                 NO_ID_LIST_CHAIN3_KEY,
+                 NO_ID_LIST_CHAIN2_KEY,
+                 NO_ID_LIST_CHAIN1_KEY),
+    FOREIGN KEY (
+                 NO_ID_LIST_CHAIN4,
+                 NO_ID_LIST_CHAIN4_KEY,
+                 NO_ID_LIST_CHAIN3_KEY,
+                 NO_ID_LIST_CHAIN2_KEY
+        ) REFERENCES NO_ID_LIST_CHAIN1 (
+                                        NO_ID_LIST_CHAIN4,
+                                        NO_ID_LIST_CHAIN4_KEY,
+                                        NO_ID_LIST_CHAIN3_KEY,
+                                        NO_ID_LIST_CHAIN2_KEY
+        )
 );
 
 
 
-
 CREATE TABLE NO_ID_MAP_CHAIN4
 (
-  FOUR       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY,
-  FOUR_VALUE VARCHAR(20)
+    FOUR       BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY,
+    FOUR_VALUE VARCHAR(20)
 );
 
 CREATE TABLE NO_ID_MAP_CHAIN3
 (
-  THREE_VALUE           VARCHAR(20),
-  NO_ID_MAP_CHAIN4     BIGINT,
-  NO_ID_MAP_CHAIN4_KEY VARCHAR(20),
-  PRIMARY KEY (NO_ID_MAP_CHAIN4,
-               NO_ID_MAP_CHAIN4_KEY),
-  FOREIGN KEY (NO_ID_MAP_CHAIN4) REFERENCES NO_ID_MAP_CHAIN4 (FOUR)
+    THREE_VALUE          VARCHAR(20),
+    NO_ID_MAP_CHAIN4     BIGINT,
+    NO_ID_MAP_CHAIN4_KEY VARCHAR(20),
+    PRIMARY KEY (NO_ID_MAP_CHAIN4,
+                 NO_ID_MAP_CHAIN4_KEY),
+    FOREIGN KEY (NO_ID_MAP_CHAIN4) REFERENCES NO_ID_MAP_CHAIN4 (FOUR)
 );
 
 CREATE TABLE NO_ID_MAP_CHAIN2
 (
-  TWO_VALUE             VARCHAR(20),
-  NO_ID_MAP_CHAIN4     BIGINT,
-  NO_ID_MAP_CHAIN4_KEY VARCHAR(20),
-  NO_ID_MAP_CHAIN3_KEY VARCHAR(20),
-  PRIMARY KEY (NO_ID_MAP_CHAIN4,
-               NO_ID_MAP_CHAIN4_KEY,
-               NO_ID_MAP_CHAIN3_KEY),
-  FOREIGN KEY (
-               NO_ID_MAP_CHAIN4,
-               NO_ID_MAP_CHAIN4_KEY
-    ) REFERENCES NO_ID_MAP_CHAIN3 (
-                                    NO_ID_MAP_CHAIN4,
-                                    NO_ID_MAP_CHAIN4_KEY
-    )
+    TWO_VALUE            VARCHAR(20),
+    NO_ID_MAP_CHAIN4     BIGINT,
+    NO_ID_MAP_CHAIN4_KEY VARCHAR(20),
+    NO_ID_MAP_CHAIN3_KEY VARCHAR(20),
+    PRIMARY KEY (NO_ID_MAP_CHAIN4,
+                 NO_ID_MAP_CHAIN4_KEY,
+                 NO_ID_MAP_CHAIN3_KEY),
+    FOREIGN KEY (
+                 NO_ID_MAP_CHAIN4,
+                 NO_ID_MAP_CHAIN4_KEY
+        ) REFERENCES NO_ID_MAP_CHAIN3 (
+                                       NO_ID_MAP_CHAIN4,
+                                       NO_ID_MAP_CHAIN4_KEY
+        )
 );
 
 CREATE TABLE NO_ID_MAP_CHAIN1
 (
-  ONE_VALUE             VARCHAR(20),
-  NO_ID_MAP_CHAIN4     BIGINT,
-  NO_ID_MAP_CHAIN4_KEY VARCHAR(20),
-  NO_ID_MAP_CHAIN3_KEY VARCHAR(20),
-  NO_ID_MAP_CHAIN2_KEY VARCHAR(20),
-  PRIMARY KEY (NO_ID_MAP_CHAIN4,
-               NO_ID_MAP_CHAIN4_KEY,
-               NO_ID_MAP_CHAIN3_KEY,
-               NO_ID_MAP_CHAIN2_KEY),
-  FOREIGN KEY (
-               NO_ID_MAP_CHAIN4,
-               NO_ID_MAP_CHAIN4_KEY,
-               NO_ID_MAP_CHAIN3_KEY
-    ) REFERENCES NO_ID_MAP_CHAIN2 (
-                                    NO_ID_MAP_CHAIN4,
-                                    NO_ID_MAP_CHAIN4_KEY,
-                                    NO_ID_MAP_CHAIN3_KEY
-    )
+    ONE_VALUE            VARCHAR(20),
+    NO_ID_MAP_CHAIN4     BIGINT,
+    NO_ID_MAP_CHAIN4_KEY VARCHAR(20),
+    NO_ID_MAP_CHAIN3_KEY VARCHAR(20),
+    NO_ID_MAP_CHAIN2_KEY VARCHAR(20),
+    PRIMARY KEY (NO_ID_MAP_CHAIN4,
+                 NO_ID_MAP_CHAIN4_KEY,
+                 NO_ID_MAP_CHAIN3_KEY,
+                 NO_ID_MAP_CHAIN2_KEY),
+    FOREIGN KEY (
+                 NO_ID_MAP_CHAIN4,
+                 NO_ID_MAP_CHAIN4_KEY,
+                 NO_ID_MAP_CHAIN3_KEY
+        ) REFERENCES NO_ID_MAP_CHAIN2 (
+                                       NO_ID_MAP_CHAIN4,
+                                       NO_ID_MAP_CHAIN4_KEY,
+                                       NO_ID_MAP_CHAIN3_KEY
+        )
 );
 
 CREATE TABLE NO_ID_MAP_CHAIN0
 (
-  ZERO_VALUE            VARCHAR(20),
-  NO_ID_MAP_CHAIN4     BIGINT,
-  NO_ID_MAP_CHAIN4_KEY VARCHAR(20),
-  NO_ID_MAP_CHAIN3_KEY VARCHAR(20),
-  NO_ID_MAP_CHAIN2_KEY VARCHAR(20),
-  NO_ID_MAP_CHAIN1_KEY VARCHAR(20),
-  PRIMARY KEY (NO_ID_MAP_CHAIN4,
-               NO_ID_MAP_CHAIN4_KEY,
-               NO_ID_MAP_CHAIN3_KEY,
-               NO_ID_MAP_CHAIN2_KEY,
-               NO_ID_MAP_CHAIN1_KEY),
-  FOREIGN KEY (
-               NO_ID_MAP_CHAIN4,
-               NO_ID_MAP_CHAIN4_KEY,
-               NO_ID_MAP_CHAIN3_KEY,
-               NO_ID_MAP_CHAIN2_KEY
-    ) REFERENCES NO_ID_MAP_CHAIN1 (
-                                    NO_ID_MAP_CHAIN4,
-                                    NO_ID_MAP_CHAIN4_KEY,
-                                    NO_ID_MAP_CHAIN3_KEY,
-                                    NO_ID_MAP_CHAIN2_KEY
-    )
-);
-
-CREATE TABLE WITH_READ_ONLY (
-    ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY,
-    NAME VARCHAR(200),
+    ZERO_VALUE           VARCHAR(20),
+    NO_ID_MAP_CHAIN4     BIGINT,
+    NO_ID_MAP_CHAIN4_KEY VARCHAR(20),
+    NO_ID_MAP_CHAIN3_KEY VARCHAR(20),
+    NO_ID_MAP_CHAIN2_KEY VARCHAR(20),
+    NO_ID_MAP_CHAIN1_KEY VARCHAR(20),
+    PRIMARY KEY (NO_ID_MAP_CHAIN4,
+                 NO_ID_MAP_CHAIN4_KEY,
+                 NO_ID_MAP_CHAIN3_KEY,
+                 NO_ID_MAP_CHAIN2_KEY,
+                 NO_ID_MAP_CHAIN1_KEY),
+    FOREIGN KEY (
+                 NO_ID_MAP_CHAIN4,
+                 NO_ID_MAP_CHAIN4_KEY,
+                 NO_ID_MAP_CHAIN3_KEY,
+                 NO_ID_MAP_CHAIN2_KEY
+        ) REFERENCES NO_ID_MAP_CHAIN1 (
+                                       NO_ID_MAP_CHAIN4,
+                                       NO_ID_MAP_CHAIN4_KEY,
+                                       NO_ID_MAP_CHAIN3_KEY,
+                                       NO_ID_MAP_CHAIN2_KEY
+        )
+);
+
+CREATE TABLE WITH_READ_ONLY
+(
+    ID        BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY,
+    NAME      VARCHAR(200),
     READ_ONLY VARCHAR(200) DEFAULT 'from-db'
 );
 
 CREATE TABLE VERSIONED_AGGREGATE
 (
-  ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
-  VERSION BIGINT
+    ID      BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    VERSION BIGINT
 );
 
 
@@ -334,7 +334,7 @@ CREATE TABLE WITH_LOCAL_DATE_TIME
 
 CREATE TABLE WITH_INSERT_ONLY
 (
-    ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    ID          BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
     INSERT_ONLY VARCHAR(100)
 );
 
@@ -345,30 +345,30 @@ CREATE TABLE WITH_ID_ONLY
 
 CREATE TABLE MULTIPLE_COLLECTIONS
 (
-    ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    ID   BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
     NAME VARCHAR(100)
 );
 
 CREATE TABLE SET_ELEMENT
 (
     MULTIPLE_COLLECTIONS BIGINT,
-    NAME VARCHAR(100)
+    NAME                 VARCHAR(100)
 );
 
 CREATE TABLE LIST_ELEMENT
 (
-    MULTIPLE_COLLECTIONS BIGINT,
+    MULTIPLE_COLLECTIONS     BIGINT,
     MULTIPLE_COLLECTIONS_KEY INT,
-    NAME VARCHAR(100)
+    NAME                     VARCHAR(100)
 );
 
 CREATE TABLE MAP_ELEMENT
 (
-    MULTIPLE_COLLECTIONS BIGINT,
+    MULTIPLE_COLLECTIONS     BIGINT,
     MULTIPLE_COLLECTIONS_KEY VARCHAR(10),
-    ENUM_MAP_OWNER BIGINT,
-    ENUM_MAP_OWNER_KEY VARCHAR(10),
-    NAME VARCHAR(100)
+    ENUM_MAP_OWNER           BIGINT,
+    ENUM_MAP_OWNER_KEY       VARCHAR(10),
+    NAME                     VARCHAR(100)
 );
 
 CREATE TABLE AUTHOR
@@ -379,12 +379,12 @@ CREATE TABLE AUTHOR
 CREATE TABLE BOOK
 (
     AUTHOR BIGINT,
-    NAME VARCHAR(100)
+    NAME   VARCHAR(100)
 );
 
 CREATE TABLE ENUM_MAP_OWNER
 (
-    ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    ID   BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
     NAME VARCHAR(100)
 );
 
@@ -397,7 +397,7 @@ CREATE TABLE WITH_ONE_TO_ONE
 CREATE TABLE REFERENCED
 (
     "renamed" VARCHAR(100),
-    ID BIGINT
+    ID        BIGINT
 );
 
 CREATE TABLE FIRST
@@ -416,11 +416,17 @@ CREATE TABLE SEC
 
 CREATE TABLE THIRD
 (
-    SEC BIGINT      NOT NULL,
-    NAME   VARCHAR(20) NOT NULL,
+    SEC  BIGINT      NOT NULL,
+    NAME VARCHAR(20) NOT NULL,
     FOREIGN KEY (SEC) REFERENCES SEC (ID)
 );
 
+CREATE TABLE SINGLE_EMBEDDED_ID_ENTITY
+(
+    ID   BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY,
+    NAME VARCHAR(100)
+);
+
 CREATE TABLE BEFORE_CONVERT_CALLBACK_FOR_SAVE_BATCH
 (
     ID VARCHAR PRIMARY KEY,
diff --git a/spring-data-r2dbc/pom.xml b/spring-data-r2dbc/pom.xml
index 3ee76fd3c1..d2b28e8abc 100644
--- a/spring-data-r2dbc/pom.xml
+++ b/spring-data-r2dbc/pom.xml
@@ -6,7 +6,7 @@
 	<modelVersion>4.0.0</modelVersion>
 
 	<artifactId>spring-data-r2dbc</artifactId>
-	<version>4.0.0-SNAPSHOT</version>
+	<version>4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT</version>
 
 	<name>Spring Data R2DBC</name>
 	<description>Spring Data module for R2DBC</description>
@@ -15,7 +15,7 @@
 	<parent>
 		<groupId>org.springframework.data</groupId>
 		<artifactId>spring-data-relational-parent</artifactId>
-		<version>4.0.0-SNAPSHOT</version>
+		<version>4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT</version>
 	</parent>
 
 	<properties>
diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java
index 3508facbb8..1fecd89e8a 100644
--- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java
+++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java
@@ -31,12 +31,16 @@
 import org.springframework.data.domain.ScrollPosition;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.domain.Window;
+import org.springframework.data.mapping.PersistentPropertyAccessor;
+import org.springframework.data.mapping.PropertyHandler;
+import org.springframework.data.mapping.context.MappingContext;
 import org.springframework.data.r2dbc.convert.R2dbcConverter;
 import org.springframework.data.r2dbc.core.R2dbcEntityOperations;
 import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
 import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy;
 import org.springframework.data.r2dbc.core.ReactiveSelectOperation;
 import org.springframework.data.r2dbc.repository.R2dbcRepository;
+import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
 import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
 import org.springframework.data.relational.core.query.Criteria;
 import org.springframework.data.relational.core.query.Query;
@@ -67,6 +71,7 @@ public class SimpleR2dbcRepository<T, ID> implements R2dbcRepository<T, ID> {
 	private final R2dbcEntityOperations entityOperations;
 	private final Lazy<RelationalPersistentProperty> idProperty;
 	private final RelationalExampleMapper exampleMapper;
+	private MappingContext<? extends RelationalPersistentEntity<?>, ? extends RelationalPersistentProperty> mappingContext;
 
 	/**
 	 * Create a new {@link SimpleR2dbcRepository}.
@@ -81,11 +86,11 @@ public SimpleR2dbcRepository(RelationalEntityInformation<T, ID> entity, R2dbcEnt
 
 		this.entity = entity;
 		this.entityOperations = entityOperations;
-		this.idProperty = Lazy.of(() -> converter //
-				.getMappingContext() //
+		this.mappingContext = converter.getMappingContext();
+		this.idProperty = Lazy.of(() -> mappingContext //
 				.getRequiredPersistentEntity(this.entity.getJavaType()) //
 				.getRequiredIdProperty());
-		this.exampleMapper = new RelationalExampleMapper(converter.getMappingContext());
+		this.exampleMapper = new RelationalExampleMapper(mappingContext);
 	}
 
 	/**
@@ -359,7 +364,29 @@ private RelationalPersistentProperty getIdProperty() {
 	}
 
 	private Query getIdQuery(Object id) {
-		return Query.query(Criteria.where(getIdProperty().getName()).is(id));
+
+		Criteria criteria;
+
+		RelationalPersistentProperty idProperty = getIdProperty();
+		if (idProperty.isEmbedded()) {
+
+			Criteria[] criteriaHolder = new Criteria[] { Criteria.empty() };
+
+			RelationalPersistentEntity<?> idEntity = mappingContext.getRequiredPersistentEntity(idProperty.getType());
+			PersistentPropertyAccessor<Object> accessor = idEntity.getPropertyAccessor(id);
+			idEntity.doWithProperties(new PropertyHandler<RelationalPersistentProperty>() {
+				@Override
+				public void doWithPersistentProperty(RelationalPersistentProperty persistentProperty) {
+					criteriaHolder[0] = criteriaHolder[0].and(persistentProperty.getName())
+							.is(accessor.getProperty(persistentProperty));
+				}
+			});
+			criteria = criteriaHolder[0];
+		} else {
+			criteria = Criteria.where(idProperty.getName()).is(id);
+		}
+
+		return Query.query(criteria);
 	}
 
 	/**
diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplateUnitTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplateUnitTests.java
index 77707d4dda..8c11f18781 100644
--- a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplateUnitTests.java
+++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplateUnitTests.java
@@ -299,8 +299,6 @@ void shouldSelectOneDoNotOverrideExistingLimit() {
 	@Test // GH-220
 	void shouldUpdateByQuery() {
 
-		MockRowMetadata metadata = MockRowMetadata.builder()
-				.columnMetadata(MockColumnMetadata.builder().name("name").type(R2dbcType.VARCHAR).build()).build();
 		MockResult result = MockResult.builder().rowsUpdated(1).build();
 
 		recorder.addStubbing(s -> s.startsWith("UPDATE"), result);
@@ -321,8 +319,6 @@ void shouldUpdateByQuery() {
 	@Test // GH-220
 	void shouldDeleteByQuery() {
 
-		MockRowMetadata metadata = MockRowMetadata.builder()
-				.columnMetadata(MockColumnMetadata.builder().name("name").type(R2dbcType.VARCHAR).build()).build();
 		MockResult result = MockResult.builder().rowsUpdated(1).build();
 
 		recorder.addStubbing(s -> s.startsWith("DELETE"), result);
diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/CompositeIdRepositoryIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/CompositeIdRepositoryIntegrationTests.java
new file mode 100644
index 0000000000..9e868577fb
--- /dev/null
+++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/CompositeIdRepositoryIntegrationTests.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.r2dbc.repository;
+
+import static org.assertj.core.api.Assertions.*;
+
+import io.r2dbc.spi.ConnectionFactory;
+import reactor.test.StepVerifier;
+
+import javax.sql.DataSource;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.FilterType;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.r2dbc.config.AbstractR2dbcConfiguration;
+import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
+import org.springframework.data.r2dbc.testing.H2TestSupport;
+import org.springframework.data.relational.core.mapping.Embedded;
+import org.springframework.data.relational.core.mapping.Table;
+import org.springframework.data.repository.reactive.ReactiveCrudRepository;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+/**
+ * Integration tests for repositories of entities with a composite id.
+ *
+ * @author Jens Schauder
+ */
+@ExtendWith(SpringExtension.class)
+public class CompositeIdRepositoryIntegrationTests {
+
+	@Autowired private WithCompositeIdRepository repository;
+	private JdbcTemplate jdbc;
+
+	@Configuration
+	@EnableR2dbcRepositories(includeFilters = @ComponentScan.Filter(value = WithCompositeIdRepository.class,
+			type = FilterType.ASSIGNABLE_TYPE), considerNestedRepositories = true)
+	static class TestConfiguration extends AbstractR2dbcConfiguration {
+		@Override
+		public ConnectionFactory connectionFactory() {
+			return H2TestSupport.createConnectionFactory();
+		}
+
+	}
+
+	@BeforeEach
+	void before() {
+
+		this.jdbc = new JdbcTemplate(createDataSource());
+
+		try {
+			this.jdbc.execute("DROP TABLE with_composite_id");
+		} catch (DataAccessException e) {}
+
+		this.jdbc.execute("""
+				CREATE TABLE with_composite_id (
+				    one int,
+				    two varchar(255),
+				    name varchar(255),
+				    primary key (one, two))""");
+		this.jdbc.execute("INSERT INTO with_composite_id VALUES (42, 'HBAR','Walter')");
+		this.jdbc.execute("INSERT INTO with_composite_id VALUES (23, '2PI','Jesse')");
+	}
+
+	/**
+	 * Creates a {@link DataSource} to be used in this test.
+	 *
+	 * @return the {@link DataSource} to be used in this test.
+	 */
+	protected DataSource createDataSource() {
+		return H2TestSupport.createDataSource();
+	}
+
+	/**
+	 * Creates a {@link ConnectionFactory} to be used in this test.
+	 *
+	 * @return the {@link ConnectionFactory} to be used in this test.
+	 */
+	protected ConnectionFactory createConnectionFactory() {
+		return H2TestSupport.createConnectionFactory();
+	}
+
+	@Test // GH-574
+	void findAllById() {
+		repository.findById(new CompositeId(42, "HBAR")) //
+				.as(StepVerifier::create) //
+				.consumeNextWith(actual -> {
+					assertThat(actual.name).isEqualTo("Walter");
+				}).verifyComplete();
+	}
+
+	interface WithCompositeIdRepository extends ReactiveCrudRepository<WithCompositeId, CompositeId> {
+
+	}
+
+	@Table("with_composite_id")
+	record WithCompositeId(@Id @Embedded.Nullable CompositeId pk, String name) {
+	}
+
+	record CompositeId(Integer one, String two) {
+	}
+
+}
diff --git a/spring-data-relational/pom.xml b/spring-data-relational/pom.xml
index 8fd6d7a6f0..14ea2ad98c 100644
--- a/spring-data-relational/pom.xml
+++ b/spring-data-relational/pom.xml
@@ -6,7 +6,7 @@
 	<modelVersion>4.0.0</modelVersion>
 
 	<artifactId>spring-data-relational</artifactId>
-	<version>4.0.0-SNAPSHOT</version>
+	<version>4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT</version>
 
 	<name>Spring Data Relational</name>
 	<description>Spring Data Relational support</description>
@@ -14,7 +14,7 @@
 	<parent>
 		<groupId>org.springframework.data</groupId>
 		<artifactId>spring-data-relational-parent</artifactId>
-		<version>4.0.0-SNAPSHOT</version>
+		<version>4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT</version>
 	</parent>
 
 	<properties>
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MappingRelationalConverter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MappingRelationalConverter.java
index 395c64e677..82e7e133c3 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MappingRelationalConverter.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MappingRelationalConverter.java
@@ -44,7 +44,16 @@
 import org.springframework.data.mapping.PersistentPropertyAccessor;
 import org.springframework.data.mapping.PersistentPropertyPathAccessor;
 import org.springframework.data.mapping.context.MappingContext;
-import org.springframework.data.mapping.model.*;
+import org.springframework.data.mapping.model.CachingValueExpressionEvaluatorFactory;
+import org.springframework.data.mapping.model.ConvertingPropertyAccessor;
+import org.springframework.data.mapping.model.EntityInstantiator;
+import org.springframework.data.mapping.model.ParameterValueProvider;
+import org.springframework.data.mapping.model.PersistentEntityParameterValueProvider;
+import org.springframework.data.mapping.model.PropertyValueProvider;
+import org.springframework.data.mapping.model.SimpleTypeHolder;
+import org.springframework.data.mapping.model.SpELContext;
+import org.springframework.data.mapping.model.ValueExpressionEvaluator;
+import org.springframework.data.mapping.model.ValueExpressionParameterValueProvider;
 import org.springframework.data.projection.EntityProjection;
 import org.springframework.data.projection.EntityProjectionIntrospector;
 import org.springframework.data.projection.EntityProjectionIntrospector.ProjectionPredicate;
@@ -578,6 +587,10 @@ private Object readEmbedded(ConversionContext conversionContext, RelationalPrope
 	private boolean shouldReadEmbeddable(ConversionContext context, RelationalPersistentProperty property,
 			RelationalPersistentEntity<?> unwrappedEntity, RelationalPropertyValueProvider propertyValueProvider) {
 
+		if (property.isIdProperty() && !property.isAnnotationPresent(Embedded.class)) {
+			return true;
+		}
+
 		OnEmpty onEmpty = property.getRequiredAnnotation(Embedded.class).onEmpty();
 
 		if (onEmpty.equals(OnEmpty.USE_EMPTY)) {
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java
index 2a68bc061e..cc706f7cb5 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java
@@ -24,6 +24,7 @@
 import org.springframework.data.mapping.PersistentPropertyPath;
 import org.springframework.data.relational.core.mapping.RelationalMappingContext;
 import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
+import org.springframework.data.relational.core.mapping.RelationalPredicates;
 import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
@@ -124,7 +125,7 @@ private List<DbAction<?>> deleteReferencedEntities(Object id, AggregateChange<?>
 	private void forAllTableRepresentingPaths(Class<?> entityType,
 			Consumer<PersistentPropertyPath<RelationalPersistentProperty>> pathConsumer) {
 
-		context.findPersistentPropertyPaths(entityType, property -> property.isEntity() && !property.isEmbedded()) //
+		context.findPersistentPropertyPaths(entityType, RelationalPredicates.isRelation()) //
 				.filter(path -> context.getAggregatePath(path).isWritable()) //
 				.forEach(pathConsumer);
 	}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java
index 84be15dfb2..6602e72841 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java
@@ -27,6 +27,7 @@
 import org.springframework.data.relational.core.mapping.RelationalMappingContext;
 import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
 import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
+import org.springframework.data.relational.core.mapping.RelationalPredicates;
 import org.springframework.data.util.Pair;
 import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
@@ -61,7 +62,7 @@ class WritingContext<T> {
 		this.aggregateChange = aggregateChange;
 		this.rootIdValueSource = IdValueSource.forInstance(root,
 				context.getRequiredPersistentEntity(aggregateChange.getEntityType()));
-		this.paths = context.findPersistentPropertyPaths(entityType, (p) -> p.isEntity() && !p.isEmbedded()) //
+		this.paths = context.findPersistentPropertyPaths(entityType, RelationalPredicates::isRelation) //
 				.filter(ppp -> context.getAggregatePath(ppp).isWritable()).toList();
 	}
 
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java
index abd3e084d3..f5befcb930 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java
@@ -16,25 +16,40 @@
 
 package org.springframework.data.relational.core.mapping;
 
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import java.util.function.BinaryOperator;
 import java.util.function.Predicate;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 
 import org.springframework.data.mapping.PersistentProperty;
 import org.springframework.data.mapping.PersistentPropertyPath;
+import org.springframework.data.mapping.PropertyHandler;
+import org.springframework.data.relational.core.sql.Column;
 import org.springframework.data.relational.core.sql.SqlIdentifier;
+import org.springframework.data.relational.core.sql.Table;
 import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 /**
  * Represents a path within an aggregate starting from the aggregate root. The path can be iterated from the leaf to its
  * root.
+ * <p>
+ * It implements {@link Comparable} so that collections of {@code AggregatePath} instances can be sorted in a consistent
+ * way.
  *
- * @since 3.2
  * @author Jens Schauder
  * @author Mark Paluch
+ * @since 3.2
  */
-public interface AggregatePath extends Iterable<AggregatePath> {
+public interface AggregatePath extends Iterable<AggregatePath>, Comparable<AggregatePath> {
 
 	/**
 	 * Returns the path that has the same beginning but is one segment shorter than this path.
@@ -52,6 +67,15 @@ public interface AggregatePath extends Iterable<AggregatePath> {
 	 */
 	AggregatePath append(RelationalPersistentProperty property);
 
+	/**
+	 * Creates a new path by extending the current path by the path passed as an argument.
+	 *
+	 * @param path must not be {@literal null}.
+	 * @return Guaranteed to be not {@literal null}.
+	 * @since 4.0
+	 */
+	AggregatePath append(AggregatePath path);
+
 	/**
 	 * @return {@literal true} if this is a root path for the underlying type.
 	 */
@@ -222,47 +246,69 @@ default Stream<AggregatePath> stream() {
 	/**
 	 * Returns the longest ancestor path that has an {@link org.springframework.data.annotation.Id} property.
 	 *
-	 * @return A path that starts just as this path but is shorter. Guaranteed to be not {@literal null}. TODO: throws
-	 *         NoSuchElementException: No value present for empty paths
+	 * @return a path that starts just as this path but is shorter. Guaranteed to be not {@literal null}.
+	 * @throws IllegalStateException if the current path is not a {@link #isRoot() root} path or if there is no identifier
+	 *           associated.
 	 */
 	AggregatePath getIdDefiningParentPath();
 
-	record TableInfo(
-
-			/*
-			 * The fully qualified name of the table this path is tied to or of the longest ancestor path that is actually
-			 * tied to a table.
-			 */
-			SqlIdentifier qualifiedTableName,
-
-			/*
-			 * The alias used for the table on which this path is based.
-			 */
-			@Nullable SqlIdentifier tableAlias,
-
-			ColumnInfo reverseColumnInfo,
+	/**
+	 * The path resulting from removing the first element of the {@link AggregatePath}.
+	 *
+	 * @return {@literal null} for any {@link AggregatePath} having less than two elements.
+	 * @since 4.0
+	 */
+	@Nullable
+	AggregatePath getTail();
 
-			/*
-			 * The column used for the list index or map key of the leaf property of this path.
-			 */
-			@Nullable ColumnInfo qualifierColumnInfo,
+	/**
+	 * Subtract the {@literal basePath} from {@literal this} {@literal AggregatePath} by removing the {@literal basePath}
+	 * from the beginning of {@literal this}.
+	 *
+	 * @param basePath the path to be removed.
+	 * @return an AggregatePath that ends like the original {@literal AggregatePath} but has {@literal basePath} removed
+	 *         from the beginning.
+	 * @since 4.0
+	 */
+	@Nullable
+	AggregatePath subtract(@Nullable AggregatePath basePath);
 
-			/*
-			 * The type of the qualifier column of the leaf property of this path or {@literal null} if this is not
-			 * applicable.
-			 */
-			@Nullable Class<?> qualifierColumnType,
+	/**
+	 * Compares this {@code AggregatePath} to another {@code AggregatePath} based on their dot path notation.
+	 * <p>
+	 * This is used to get {@code AggregatePath} instances sorted in a consistent way. Since this order affects generated
+	 * SQL this also affects query caches and similar.
+	 *
+	 * @param other the {@code AggregatePath} to compare to. Must not be {@literal null}.
+	 * @return a negative integer, zero, or a positive integer as this object's path is less than, equal to, or greater
+	 *         than the specified object's path.
+	 * @since 4.0
+	 */
+	@Override
+	default int compareTo(AggregatePath other) {
+		return toDotPath().compareTo(other.toDotPath());
+	}
 
-			/*
-			 * The column name of the id column of the ancestor path that represents an actual table.
-			 */
-			SqlIdentifier idColumnName,
+	AggregatePath getSubPathBasedOn(Class<?> baseType);
 
-			/*
-			 * If the table owning ancestor has an id the column name of that id property is returned. Otherwise the reverse
-			 * column is returned.
-			 */
-			SqlIdentifier effectiveIdColumnName) {
+	/**
+	 * Information about a table underlying an entity.
+	 *
+	 * @param qualifiedTableName the fully qualified name of the table this path is tied to or of the longest ancestor
+	 *          path that is actually tied to a table. Must not be {@literal null}.
+	 * @param tableAlias the alias used for the table on which this path is based. May be {@literal null}.
+	 * @param backReferenceColumnInfos information about the columns used to reference back to the owning entity. Must not
+	 *          be {@literal null}. Since 3.5.
+	 * @param qualifierColumnInfo the column used for the list index or map key of the leaf property of this path. May be
+	 *          {@literal null}.
+	 * @param qualifierColumnType the type of the qualifier column of the leaf property of this path or {@literal null} if
+	 *          this is not applicable. May be {@literal null}.
+	 * @param idColumnInfos the column name of the id column of the ancestor path that represents an actual table. Must
+	 *          not be {@literal null}.
+	 */
+	record TableInfo(SqlIdentifier qualifiedTableName, @Nullable SqlIdentifier tableAlias,
+			ColumnInfos backReferenceColumnInfos, @Nullable ColumnInfo qualifierColumnInfo,
+			@Nullable Class<?> qualifierColumnType, ColumnInfos idColumnInfos) {
 
 		static TableInfo of(AggregatePath path) {
 
@@ -273,18 +319,7 @@ static TableInfo of(AggregatePath path) {
 
 			SqlIdentifier tableAlias = tableOwner.isRoot() ? null : AggregatePathTableUtils.constructTableAlias(tableOwner);
 
-			ColumnInfo reverseColumnInfo = null;
-			if (!tableOwner.isRoot()) {
-
-				AggregatePath idDefiningParentPath = tableOwner.getIdDefiningParentPath();
-				RelationalPersistentProperty leafProperty = tableOwner.getRequiredLeafProperty();
-
-				SqlIdentifier reverseColumnName = leafProperty
-						.getReverseColumnName(idDefiningParentPath.getRequiredLeafEntity());
-
-				reverseColumnInfo = new ColumnInfo(reverseColumnName,
-						AggregatePathTableUtils.prefixWithTableAlias(path, reverseColumnName));
-			}
+			ColumnInfos backReferenceColumnInfos = computeBackReferenceColumnInfos(path);
 
 			ColumnInfo qualifierColumnInfo = null;
 			if (!path.isRoot()) {
@@ -300,27 +335,128 @@ static TableInfo of(AggregatePath path) {
 				qualifierColumnType = path.getRequiredLeafProperty().getQualifierColumnType();
 			}
 
-			SqlIdentifier idColumnName = leafEntity.hasIdProperty() ? leafEntity.getIdColumn() : null;
+			ColumnInfos idColumnInfos = computeIdColumnInfos(tableOwner, leafEntity);
+
+			return new TableInfo(qualifiedTableName, tableAlias, backReferenceColumnInfos, qualifierColumnInfo,
+					qualifierColumnType, idColumnInfos);
 
-			SqlIdentifier effectiveIdColumnName = tableOwner.isRoot() ? idColumnName : reverseColumnInfo.name();
+		}
 
-			return new TableInfo(qualifiedTableName, tableAlias, reverseColumnInfo, qualifierColumnInfo, qualifierColumnType,
-					idColumnName, effectiveIdColumnName);
+		private static ColumnInfos computeIdColumnInfos(AggregatePath tableOwner,
+				RelationalPersistentEntity<?> leafEntity) {
+
+			ColumnInfos idColumnInfos = ColumnInfos.empty();
+			if (!leafEntity.hasIdProperty()) {
+				return idColumnInfos;
+			}
 
+			RelationalPersistentProperty idProperty = leafEntity.getRequiredIdProperty();
+			AggregatePath idPath = tableOwner.append(idProperty);
+
+			if (idProperty.isEntity()) {
+				ColumInfosBuilder ciBuilder = new ColumInfosBuilder(idPath);
+				idPath.getRequiredLeafEntity().doWithProperties((PropertyHandler<RelationalPersistentProperty>) p -> {
+					AggregatePath idElementPath = idPath.append(p);
+					ciBuilder.add(idElementPath, ColumnInfo.of(idElementPath));
+				});
+				return ciBuilder.build();
+			} else {
+				ColumInfosBuilder ciBuilder = new ColumInfosBuilder(idPath.getParentPath());
+				ciBuilder.add(idPath, ColumnInfo.of(idPath));
+				return ciBuilder.build();
+			}
 		}
-	}
 
-	record ColumnInfo(
+		private static ColumnInfos computeBackReferenceColumnInfos(AggregatePath path) {
 
-			/* The name of the column used to represent this property in the database. */
-			SqlIdentifier name, /* The alias for the column used to represent this property in the database. */
-			SqlIdentifier alias) {
+			AggregatePath tableOwner = AggregatePathTraversal.getTableOwningPath(path);
+
+			if (tableOwner.isRoot()) {
+				return ColumnInfos.empty();
+			}
+
+			AggregatePath idDefiningParentPath = tableOwner.getIdDefiningParentPath();
+			RelationalPersistentProperty idProperty = idDefiningParentPath.getRequiredLeafEntity().getIdProperty();
+
+			AggregatePath basePath = idProperty != null && idProperty.isEntity() ? idDefiningParentPath.append(idProperty)
+					: idDefiningParentPath;
+			ColumInfosBuilder ciBuilder = new ColumInfosBuilder(basePath);
+
+			if (idProperty != null && idProperty.isEntity()) {
+
+				RelationalPersistentEntity<?> idEntity = basePath.getRequiredLeafEntity();
+				idEntity.doWithProperties((PropertyHandler<RelationalPersistentProperty>) p -> {
+					AggregatePath idElementPath = basePath.append(p);
+					SqlIdentifier name = idElementPath.getColumnInfo().name();
+					name = name.transform(n -> idDefiningParentPath.getTableInfo().qualifiedTableName.getReference() + "_" + n);
+
+					ciBuilder.add(idElementPath, name, name);
+				});
+
+			} else {
+
+				RelationalPersistentProperty leafProperty = tableOwner.getRequiredLeafProperty();
+				SqlIdentifier reverseColumnName = leafProperty
+						.getReverseColumnName(idDefiningParentPath.getRequiredLeafEntity());
+				SqlIdentifier alias = AggregatePathTableUtils.prefixWithTableAlias(path, reverseColumnName);
+
+				if (idProperty != null) {
+					ciBuilder.add(idProperty, reverseColumnName, alias);
+				} else {
+					ciBuilder.add(idDefiningParentPath, reverseColumnName, alias);
+				}
+			}
+			return ciBuilder.build();
+		}
+
+		@Override
+		public ColumnInfos backReferenceColumnInfos() {
+			return backReferenceColumnInfos;
+		}
+
+		/**
+		 * Returns the unique {@link ColumnInfo} referencing the parent table, if such exists.
+		 *
+		 * @return guaranteed not to be {@literal null}.
+		 * @throws IllegalStateException if there is not exactly one back referencing column.
+		 * @deprecated since there might be more than one reverse column instead. Use {@link #backReferenceColumnInfos()}
+		 *             instead.
+		 */
+		@Deprecated(forRemoval = true)
+		public ColumnInfo reverseColumnInfo() {
+			return backReferenceColumnInfos.unique();
+		}
+
+		/**
+		 * The id columns of the underlying table.
+		 * <p>
+		 * These might be:
+		 * <ul>
+		 * <li>the columns representing the id of the entity in question.</li>
+		 * <li>the columns representing the id of a parent entity, which _owns_ the table. Note that this case also covers
+		 * the first case.</li>
+		 * <li>or the backReferenceColumns.</li>
+		 * </ul>
+		 *
+		 * @return ColumnInfos representing the effective id of this entity. Guaranteed not to be {@literal null}.
+		 */
+		public ColumnInfos effectiveIdColumnInfos() {
+			return backReferenceColumnInfos.isEmpty() ? idColumnInfos : backReferenceColumnInfos;
+		}
+	}
+
+	/**
+	 * @param name the name of the column used to represent this property in the database.
+	 * @param alias the alias for the column used to represent this property in the database.
+	 * @since 3.2
+	 */
+	record ColumnInfo(SqlIdentifier name, SqlIdentifier alias) {
 
 		/**
 		 * Create a {@link ColumnInfo} from an aggregate path. ColumnInfo can be created for simple type single-value
 		 * properties only.
 		 *
-		 * @param path
+		 * @param path the path to the {@literal ColumnInfo} for.
 		 * @return the {@link ColumnInfo}.
 		 * @throws IllegalArgumentException if the path is {@link #isRoot()}, {@link #isEmbedded()} or
 		 *           {@link #isMultiValued()}.
@@ -338,4 +474,202 @@ static ColumnInfo of(AggregatePath path) {
 			return new ColumnInfo(columnName, AggregatePathTableUtils.prefixWithTableAlias(path, columnName));
 		}
 	}
+
+	/**
+	 * A group of {@link ColumnInfo} values referenced by there respective {@link AggregatePath}. It is used in a similar
+	 * way as {@literal ColumnInfo} when one needs to consider more than a single column. This is relevant for composite
+	 * ids and references to such ids.
+	 *
+	 * @author Jens Schauder
+	 * @author Mark Paluch
+	 * @since 4.0
+	 */
+	class ColumnInfos {
+
+		private final Map<AggregatePath, ColumnInfo> columnInfos;
+		private final Map<Table, List<Column>> columnCache;
+
+		/**
+		 * Creates a new ColumnInfos instances based on the arguments.
+		 *
+		 * @param columnInfos A map, mapping {@literal AggregatePath} instances to the respective {@literal ColumnInfo}
+		 */
+		ColumnInfos(Map<AggregatePath, ColumnInfo> columnInfos) {
+
+			this.columnInfos = new TreeMap<>();
+			this.columnInfos.putAll(columnInfos);
+			this.columnCache = new HashMap<>(columnInfos.size(), 1f);
+		}
+
+		/**
+		 * An empty {@literal ColumnInfos} instance with a fixed base path. Useful as a base when collecting
+		 * {@link ColumnInfo} instances into an {@literal ColumnInfos} instance.
+		 *
+		 * @return an empty instance save the {@literal basePath}.
+		 */
+		public static ColumnInfos empty() {
+			return new ColumnInfos(new HashMap<>());
+		}
+
+		/**
+		 * If this instance contains exactly one {@link ColumnInfo} it will be returned.
+		 *
+		 * @return the unique {@literal ColumnInfo} if present.
+		 * @throws IllegalStateException if the number of contained {@literal ColumnInfo} instances is not exactly 1.
+		 */
+		public ColumnInfo unique() {
+
+			Collection<ColumnInfo> values = columnInfos.values();
+			Assert.state(values.size() == 1, "ColumnInfo is not unique");
+			return values.iterator().next();
+		}
+
+		/**
+		 * Any of the contained {@link ColumnInfo} instances.
+		 *
+		 * @return a {@link ColumnInfo} instance.
+		 * @throws java.util.NoSuchElementException if no instance is available.
+		 */
+		public ColumnInfo any() {
+
+			Collection<ColumnInfo> values = columnInfos.values();
+			return values.iterator().next();
+		}
+
+		/**
+		 * Create a {@link Map} of {@link AggregatePath} to {@link Column} using the given {@link Table} as column source.
+		 *
+		 * @param table
+		 * @return a {@link Map} of {@link AggregatePath} to {@link Column}s.
+		 * @since 4.0
+		 */
+		public Map<AggregatePath, Column> toMap(Table table) {
+
+			Map<AggregatePath, Column> columns = new TreeMap<>();
+
+			columnInfos.forEach((ag, ci) -> columns.put(ag, table.column(ci.name())));
+
+			return columns;
+		}
+
+		/**
+		 * Converts the given {@link Table} into a list of {@link Column}s. This method retrieves and caches the list of
+		 * columns for the specified table. If the columns are not already cached, it computes the list by mapping
+		 * {@code columnInfos} to their corresponding {@link Column} in the provided table and then stores the result in the
+		 * cache.
+		 *
+		 * @param table the {@link Table} for which the columns should be generated; must not be {@literal null}.
+		 * @return a list of {@link Column}s associated with the specified {@link Table}. Guaranteed no to be
+		 *         {@literal null}.
+		 */
+		public List<Column> toColumnList(Table table) {
+			return columnCache.computeIfAbsent(table, t -> toColumnList((__, ci) -> t.column(ci.name)));
+		}
+
+		/**
+		 * Creates a {@link List} of {@link Column} instances by applying the provided mapping function to each
+		 * {@link AggregatePath} and {@link ColumnInfo}.
+		 *
+		 * @param mappingFunction function to map {@link AggregatePath} and {@link ColumnInfo} to a {@link Column}.
+		 * @return a list of {@link Column}s.
+		 * @since 4.0
+		 */
+		public List<Column> toColumnList(BiFunction<AggregatePath, ColumnInfo, Column> mappingFunction) {
+
+			List<Column> result = new ArrayList<>(columnInfos.size());
+			columnInfos.forEach((ap, ci) -> result.add(mappingFunction.apply(ap, ci)));
+			return result;
+		}
+
+		/**
+		 * Performs a {@link Stream#reduce(Object, BiFunction, BinaryOperator)} on {@link ColumnInfo} and
+		 * {@link AggregatePath} to reduce the results into a single {@code T} return value.
+		 * <p>
+		 * If {@code ColumnInfos} is empty, then {@code identity} is returned. Without invoking {@code combiner}. The
+		 * {@link BinaryOperator combiner} is called with the current state (or initial {@code identity}) and the
+		 * accumulated {@code T} state to combine both into a single return value.
+		 *
+		 * @param identity the identity (initial) value for the combiner function.
+		 * @param accumulator an associative, non-interfering (free of side effects), stateless function for incorporating
+		 *          an additional element into a result.
+		 * @param combiner an associative, non-interfering, stateless function for combining two values, which must be
+		 *          compatible with the {@code accumulator} function.
+		 * @param <T> type of the result.
+		 * @return result of the function.
+		 * @since 4.0
+		 */
+		public <T> T reduce(T identity, BiFunction<AggregatePath, ColumnInfo, T> accumulator, BinaryOperator<T> combiner) {
+
+			T result = identity;
+
+			for (Map.Entry<AggregatePath, ColumnInfo> entry : columnInfos.entrySet()) {
+
+				T mapped = accumulator.apply(entry.getKey(), entry.getValue());
+				result = combiner.apply(result, mapped);
+			}
+
+			return result;
+		}
+
+		/**
+		 * Calls the consumer for each pair of {@link AggregatePath} and {@literal ColumnInfo}.
+		 *
+		 * @param consumer the function to call.
+		 */
+		public void forEach(BiConsumer<AggregatePath, ColumnInfo> consumer) {
+			columnInfos.forEach(consumer);
+		}
+
+		/**
+		 * Calls the {@literal mapper} for each pair one pair of {@link AggregatePath} and {@link ColumnInfo}, if there is
+		 * any.
+		 *
+		 * @param mapper the function to call.
+		 * @return the result of the mapper
+		 * @throws java.util.NoSuchElementException if this {@literal ColumnInfo} is empty.
+		 */
+		public <T> T any(BiFunction<AggregatePath, ColumnInfo, T> mapper) {
+
+			Map.Entry<AggregatePath, ColumnInfo> any = columnInfos.entrySet().iterator().next();
+			return mapper.apply(any.getKey(), any.getValue());
+		}
+
+		/**
+		 * Gets the {@link ColumnInfo} for the provided {@link AggregatePath}
+		 *
+		 * @param path for which to return the {@literal ColumnInfo}
+		 * @return {@literal ColumnInfo} for the given path.
+		 */
+		public ColumnInfo get(AggregatePath path) {
+			return columnInfos.get(path);
+		}
+
+		/**
+		 * Return an {@link Iterable} of {@link AggregatePath} associated with this column infos.
+		 *
+		 * @return
+		 */
+		public Iterable<AggregatePath> paths() {
+			return columnInfos.keySet();
+		}
+
+		/**
+		 * Checks if {@literal this} instance is empty, i.e. does not contain any {@link ColumnInfo} instance.
+		 *
+		 * @return {@literal true} iff the collection of {@literal ColumnInfo} is empty.
+		 */
+		public boolean isEmpty() {
+			return columnInfos.isEmpty();
+		}
+
+		/**
+		 * Number of {@literal ColumnInfo} elements in this instance.
+		 *
+		 * @return the size of the collection of {@literal ColumnInfo}.
+		 */
+		public int size() {
+			return columnInfos.size();
+		}
+
+	}
 }
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java
index 3a5aa3f4a2..3dd353d252 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java
@@ -15,14 +15,22 @@
  */
 package org.springframework.data.relational.core.mapping;
 
-import java.util.NoSuchElementException;
 import java.util.function.Predicate;
 
 /**
+ * Traversal methods for {@link AggregatePath} to find paths that define the ID or own the table.
+ *
  * @author Mark Paluch
+ * @since 3.2
  */
 public class AggregatePathTraversal {
 
+	/**
+	 * Get the path that defines the identifier of the aggregate.
+	 *
+	 * @param aggregatePath
+	 * @return
+	 */
 	public static AggregatePath getIdDefiningPath(AggregatePath aggregatePath) {
 
 		Predicate<AggregatePath> idDefiningPathFilter = ap -> !ap.equals(aggregatePath)
@@ -30,18 +38,25 @@ public static AggregatePath getIdDefiningPath(AggregatePath aggregatePath) {
 
 		AggregatePath result = aggregatePath.filter(idDefiningPathFilter);
 		if (result == null) {
-			throw new NoSuchElementException();
+			throw new IllegalStateException(
+					"No identifier associated within this aggregate path: %s".formatted(aggregatePath));
 		}
 		return result;
 	}
 
+	/**
+	 * Get the path that owns the table of the aggregate.
+	 *
+	 * @param aggregatePath
+	 * @return
+	 */
 	public static AggregatePath getTableOwningPath(AggregatePath aggregatePath) {
 
 		Predicate<AggregatePath> tableOwningPathFilter = ap -> ap.isEntity() && !ap.isEmbedded();
 
 		AggregatePath result = aggregatePath.filter(tableOwningPathFilter);
 		if (result == null) {
-			throw new NoSuchElementException();
+			throw new IllegalStateException("No table associated within this aggregate path: %s".formatted(aggregatePath));
 		}
 		return result;
 	}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java
index 99b48363fc..e216ee4865 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java
@@ -154,6 +154,7 @@ public SqlIdentifier getQualifiedTableName() {
 	}
 
 	@Override
+	@Deprecated(forRemoval = true)
 	public SqlIdentifier getIdColumn() {
 		return getRequiredIdProperty().getColumnName();
 	}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java
index 0538a98103..395174a645 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java
@@ -250,7 +250,7 @@ public boolean isOrdered() {
 
 	@Override
 	public boolean isEmbedded() {
-		return isEmbedded;
+		return isEmbedded || (isIdProperty() && isEntity());
 	}
 
 	@Override
@@ -263,7 +263,8 @@ public boolean shouldCreateEmptyEmbedded() {
 
 		Embedded findAnnotation = findAnnotation(Embedded.class);
 
-		return findAnnotation != null && OnEmpty.USE_EMPTY.equals(findAnnotation.onEmpty());
+		return (findAnnotation != null && OnEmpty.USE_EMPTY.equals(findAnnotation.onEmpty()))
+				|| (isIdProperty() && isEntity());
 	}
 
 	@Override
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/ColumInfosBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/ColumInfosBuilder.java
new file mode 100644
index 0000000000..9f230a492c
--- /dev/null
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/ColumInfosBuilder.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.relational.core.mapping;
+
+import java.util.Map;
+import java.util.TreeMap;
+
+import org.springframework.data.relational.core.sql.SqlIdentifier;
+
+/**
+ * A builder for {@link AggregatePath.ColumnInfos} instances.
+ *
+ * @author Jens Schauder
+ * @since 4.0
+ */
+class ColumInfosBuilder {
+
+	private final AggregatePath basePath;
+	private final Map<AggregatePath, AggregatePath.ColumnInfo> columnInfoMap = new TreeMap<>();
+
+	/**
+	 * Start construction with just the {@literal basePath} which all other paths are build upon.
+	 *
+	 * @param basePath must not be null.
+	 */
+	ColumInfosBuilder(AggregatePath basePath) {
+		this.basePath = basePath;
+	}
+
+	/**
+	 * Adds a {@link AggregatePath.ColumnInfo} to the {@link AggregatePath.ColumnInfos} under construction.
+	 *
+	 * @param path referencing the {@literal ColumnInfo}.
+	 * @param name of the column.
+	 * @param alias alias for the column.
+	 */
+	void add(AggregatePath path, SqlIdentifier name, SqlIdentifier alias) {
+		add(path, new AggregatePath.ColumnInfo(name, alias));
+	}
+
+	/**
+	 * Adds a {@link AggregatePath.ColumnInfo} to the {@link AggregatePath.ColumnInfos} under construction.
+	 *
+	 * @param property referencing the {@literal ColumnInfo}.
+	 * @param name of the column.
+	 * @param alias alias for the column.
+	 */
+	void add(RelationalPersistentProperty property, SqlIdentifier name, SqlIdentifier alias) {
+		add(basePath.append(property), name, alias);
+	}
+
+	/**
+	 * Adds a {@link AggregatePath.ColumnInfo} to the {@link AggregatePath.ColumnInfos} under construction.
+	 *
+	 * @param path the path referencing the {@literal ColumnInfo}
+	 * @param columnInfo the {@literal ColumnInfo} added.
+	 */
+	void add(AggregatePath path, AggregatePath.ColumnInfo columnInfo) {
+		columnInfoMap.put(path, columnInfo);
+	}
+
+	/**
+	 * Build the final {@link AggregatePath.ColumnInfos} instance.
+	 *
+	 * @return a {@literal ColumnInfos} instance containing all the added {@link AggregatePath.ColumnInfo} instances.
+	 */
+	AggregatePath.ColumnInfos build() {
+		return new AggregatePath.ColumnInfos(columnInfoMap);
+	}
+
+}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/DefaultAggregatePath.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/DefaultAggregatePath.java
index b0bcc78cb2..42f25a2475 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/DefaultAggregatePath.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/DefaultAggregatePath.java
@@ -97,6 +97,20 @@ public AggregatePath append(RelationalPersistentProperty property) {
 		return nestedCache.get(property);
 	}
 
+	@Override
+	public AggregatePath append(AggregatePath path) {
+
+		if (path.isRoot()) {
+			return this;
+		}
+
+		RelationalPersistentProperty baseProperty = path.getRequiredBaseProperty();
+		AggregatePath appended = append(baseProperty);
+		AggregatePath tail = path.getTail();
+		return tail == null ? appended : appended.append(tail);
+
+	}
+
 	private AggregatePath doGetAggegatePath(RelationalPersistentProperty property) {
 
 		PersistentPropertyPath<? extends RelationalPersistentProperty> newPath = isRoot() //
@@ -194,14 +208,64 @@ public AggregatePath getIdDefiningParentPath() {
 		return AggregatePathTraversal.getIdDefiningPath(this);
 	}
 
-	/**
-	 * Finds and returns the longest path with ich identical or an ancestor to the current path and maps directly to a
-	 * table.
-	 *
-	 * @return a path. Guaranteed to be not {@literal null}.
-	 */
-	private AggregatePath getTableOwningAncestor() {
-		return AggregatePathTraversal.getTableOwningPath(this);
+	@Override
+	@Nullable
+	public AggregatePath getTail() {
+
+		if (getLength() <= 2) {
+			return null;
+		}
+
+		AggregatePath tail = null;
+		for (RelationalPersistentProperty prop : this.path) {
+			if (tail == null) {
+				tail = context.getAggregatePath(context.getPersistentEntity(prop));
+			} else {
+				tail = tail.append(prop);
+			}
+		}
+		return tail;
+	}
+
+	@Override
+	@Nullable
+	public AggregatePath subtract(@Nullable AggregatePath basePath) {
+
+		if (basePath == null || basePath.isRoot()) {
+			return this;
+		}
+
+		if (this.isRoot()) {
+			throw new IllegalStateException("Can't subtract from root path");
+		}
+
+		if (basePath.getRequiredBaseProperty().equals(getRequiredBaseProperty())) {
+			AggregatePath tail = this.getTail();
+			if (tail == null) {
+				return null;
+			}
+			return tail.subtract(basePath.getTail());
+		}
+
+		throw new IllegalStateException("Can't subtract [%s] from [%s]".formatted(basePath, this));
+	}
+
+	@Override
+	public AggregatePath getSubPathBasedOn(Class<?> baseType) {
+
+		if (isRoot()) {
+			if (rootType.getType() != baseType) {
+				throw new IllegalStateException("No matching path found for [%s]".formatted(baseType));
+			}
+			return this;
+		}
+
+		RelationalPersistentEntity<?> owner = getRequiredBaseProperty().getOwner();
+		if (owner.getType() == baseType) {
+			return this;
+		}
+		
+		return getTail().getSubPathBasedOn(baseType);
 	}
 
 	/**
@@ -240,7 +304,6 @@ public int hashCode() {
 		return Objects.hash(context, rootType, path);
 	}
 
-
 	@Override
 	public String toString() {
 		return "AggregatePath["
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java
index 78bfa01d37..6a3befcdf8 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java
@@ -51,6 +51,7 @@ public SqlIdentifier getTableName() {
 	}
 
 	@Override
+	@Deprecated(forRemoval = true)
 	public SqlIdentifier getIdColumn() {
 		throw new MappingException("Embedded entity does not have an id column");
 	}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java
index eb6409e74e..b8c6315d69 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java
@@ -148,8 +148,8 @@ protected RelationalPersistentProperty createPersistentProperty(Property propert
 	}
 
 	/**
-	 * @since 3.2
 	 * @return iff single query loading is enabled.
+	 * @since 3.2
 	 * @see #setSingleQueryLoadingEnabled(boolean)
 	 */
 	public boolean isSingleQueryLoadingEnabled() {
@@ -161,8 +161,8 @@ public boolean isSingleQueryLoadingEnabled() {
 	 * {@link org.springframework.data.relational.core.dialect.Dialect} supports it, Spring Data JDBC will try to use
 	 * Single Query Loading if possible.
 	 *
-	 * @since 3.2
 	 * @param singleQueryLoadingEnabled
+	 * @since 3.2
 	 */
 	public void setSingleQueryLoadingEnabled(boolean singleQueryLoadingEnabled) {
 		this.singleQueryLoadingEnabled = singleQueryLoadingEnabled;
@@ -217,7 +217,6 @@ private record AggregatePathCacheKey(RelationalPersistentEntity<?> root,
 		 * Create a new AggregatePathCacheKey for a root entity.
 		 *
 		 * @param root the root entity.
-		 * @return
 		 */
 		static AggregatePathCacheKey of(RelationalPersistentEntity<?> root) {
 			return new AggregatePathCacheKey(root, null);
@@ -226,8 +225,7 @@ static AggregatePathCacheKey of(RelationalPersistentEntity<?> root) {
 		/**
 		 * Create a new AggregatePathCacheKey for a property path.
 		 *
-		 * @param path
-		 * @return
+		 * @param path {@Literal AggregatePath} to obtain a cache key for.
 		 */
 		static AggregatePathCacheKey of(PersistentPropertyPath<? extends RelationalPersistentProperty> path) {
 			return new AggregatePathCacheKey(path.getBaseProperty().getOwner(), path);
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java
index 49e9b929c1..7cc9fdc9ba 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java
@@ -50,7 +50,10 @@ default SqlIdentifier getQualifiedTableName() {
 	 * Returns the column representing the identifier.
 	 *
 	 * @return will never be {@literal null}.
+	 * @deprecated because an entity may have multiple id columns. Use
+	 *             {@code AggregatePath.getTableInfo().getIdColumnInfos()} instead.
 	 */
+	@Deprecated(forRemoval = true)
 	SqlIdentifier getIdColumn();
 
 }
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPredicates.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPredicates.java
new file mode 100644
index 0000000000..c308a4a49d
--- /dev/null
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPredicates.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.relational.core.mapping;
+
+import java.util.function.Predicate;
+
+/**
+ * Collection of relational predicates.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+public class RelationalPredicates {
+
+	/**
+	 * Predicate to determine whether a property is a relation (i.e. it is an entity, not an identifier property, and not
+	 * an embedded property).
+	 *
+	 * @return a predicate that tests if the given property is a relation.
+	 */
+	public static Predicate<? super RelationalPersistentProperty> isRelation() {
+		return RelationalPredicates::isRelation;
+	}
+
+	/**
+	 * Determine whether a property is a relation (i.e. it is an entity, not an identifier property, and not an embedded
+	 * property).
+	 *
+	 * @return {@literal true} if the property is a relation; {@literal false} otherwise.
+	 */
+	public static boolean isRelation(RelationalPersistentProperty property) {
+		return !property.isIdProperty() && property.isEntity() && !property.isEmbedded();
+	}
+}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AnalyticFunction.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AnalyticFunction.java
index b3af3e1e86..fb4edc9a9e 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AnalyticFunction.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AnalyticFunction.java
@@ -16,6 +16,7 @@
 package org.springframework.data.relational.core.sql;
 
 import java.util.Arrays;
+import java.util.Collection;
 
 /**
  * Represents an analytic function, also known as windowing function
@@ -44,18 +45,62 @@ private AnalyticFunction(SimpleFunction function, Partition partition, OrderBy o
 		this.orderBy = orderBy;
 	}
 
+	/**
+	 * Specify the {@literal PARTITION BY} clause of an analytic function
+	 * 
+	 * @param partitionBy Typically, column but other expressions are fine to.
+	 * @return a new {@literal AnalyticFunction} is partitioned by the given expressions, overwriting any expression
+	 *         previously present.
+	 */
 	public AnalyticFunction partitionBy(Expression... partitionBy) {
-
 		return new AnalyticFunction(function, new Partition(partitionBy), orderBy);
 	}
 
+	/**
+	 * Specify the {@literal PARTITION BY} clause of an analytic function
+	 * 
+	 * @param partitionBy Typically, column but other expressions are fine to.
+	 * @return a new {@literal AnalyticFunction} is partitioned by the given expressions, overwriting any expression
+	 *         previously present.
+	 * @since 4.0
+	 */
+	public AnalyticFunction partitionBy(Collection<? extends Expression> partitionBy) {
+		return partitionBy(partitionBy.toArray(new Expression[0]));
+	}
+
+	/**
+	 * Specify the {@literal ORDER BY} clause of an analytic function
+	 * 
+	 * @param orderBy Typically, column but other expressions are fine to.
+	 * @return a new {@literal AnalyticFunction} is ordered by the given expressions, overwriting any expression
+	 *         previously present.
+	 */
 	public AnalyticFunction orderBy(OrderByField... orderBy) {
 		return new AnalyticFunction(function, partition, new OrderBy(orderBy));
 	}
 
-	public AnalyticFunction orderBy(Expression... orderByExpression) {
+	/**
+	 * Specify the {@literal ORDER BY} clause of an analytic function
+	 * 
+	 * @param orderBy Typically, column but other expressions are fine to.
+	 * @return a new {@literal AnalyticFunction} is ordered by the given expressions, overwriting any expression
+	 *         previously present.
+	 * @since 4.0
+	 */
+	public AnalyticFunction orderBy(Collection<? extends Expression> orderBy) {
+		return orderBy(orderBy.toArray(new Expression[0]));
+	}
+
+	/**
+	 * Specify the {@literal ORDER BY} clause of an analytic function
+	 * 
+	 * @param orderBy array of {@link Expression}. Typically, column but other expressions are fine to.
+	 * @return a new {@literal AnalyticFunction} is ordered by the given expressions, overwriting any expression
+	 *         previously present.
+	 */
+	public AnalyticFunction orderBy(Expression... orderBy) {
 
-		final OrderByField[] orderByFields = Arrays.stream(orderByExpression) //
+		final OrderByField[] orderByFields = Arrays.stream(orderBy) //
 				.map(OrderByField::from) //
 				.toArray(OrderByField[]::new);
 
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java
index aa7f4e70e7..2013e04450 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java
@@ -45,6 +45,16 @@ public static Condition just(String sql) {
 		return new ConstantCondition(sql);
 	}
 
+	/**
+	 * Empty {@link Condition} that can be used to indicate that no condition is present.
+	 *
+	 * @return empty (unrestricted) condition.
+	 * @since 4.0
+	 */
+	public static Condition unrestricted() {
+		return Unrestricted.INSTANCE;
+	}
+
 	/**
 	 * Creates a nested {@link Condition} that is enclosed with parentheses. Useful to combine {@code AND} and {@code OR}
 	 * statements.
@@ -247,7 +257,7 @@ public static In in(Expression columnOrExpression, Expression... expressions) {
 	 * @param subselect the subselect.
 	 * @return the {@link In} condition.
 	 */
-	public static In in(Column column, Select subselect) {
+	public static In in(Expression column, Select subselect) {
 
 		Assert.notNull(column, "Column must not be null");
 		Assert.notNull(subselect, "Subselect must not be null");
@@ -317,4 +327,5 @@ public static In notIn(Column column, Select subselect) {
 
 	// Utility constructor.
 	private Conditions() {}
+
 }
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Disjunct.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Disjunct.java
new file mode 100644
index 0000000000..8283cd1ed2
--- /dev/null
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Disjunct.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.relational.core.sql;
+
+/**
+ * Disjunct condition that does not match any rows using {@code 1 = 0}
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+enum Disjunct implements Condition {
+
+	INSTANCE;
+
+	@Override
+	public Condition and(Condition other) {
+		return INSTANCE;
+	}
+
+	@Override
+	public Condition not() {
+		return Condition.super.not();
+	}
+
+}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java
index 328c37218a..db6a348ec5 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java
@@ -15,6 +15,8 @@
  */
 package org.springframework.data.relational.core.sql;
 
+import java.util.List;
+
 /**
  * Factory for common {@link Expression}s.
  *
@@ -61,6 +63,26 @@ public static Expression cast(Expression expression, String targetType) {
 		return Cast.create(expression, targetType);
 	}
 
+	/**
+	 * Creates an {@link Expression} based on the provided list of {@link Column}s.
+	 * <p>
+	 * If the list contains only a single column, this method returns that column directly as the resulting
+	 * {@link Expression}. Otherwise, it creates and returns a {@link TupleExpression} that represents multiple columns as
+	 * a single expression.
+	 *
+	 * @param columns the list of {@link Column}s to include in the expression; must not be {@literal null}.
+	 * @return an {@link Expression} corresponding to the input columns: either a single column or a
+	 *         {@link TupleExpression} for multiple columns.
+	 * @since 4.0
+	 */
+	public static Expression of(List<Column> columns) {
+
+		if (columns.size() == 1) {
+			return columns.get(0);
+		}
+		return new TupleExpression(columns);
+	}
+
 	// Utility constructor.
 	private Expressions() {}
 
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/TupleExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/TupleExpression.java
new file mode 100644
index 0000000000..30fac0924f
--- /dev/null
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/TupleExpression.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.relational.core.sql;
+
+import static java.util.stream.Collectors.*;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A tuple as used for {@code IN} predicates. For example:
+ *
+ * <pre class="code">
+ *   WHERE (one, two) IN (select x, y from some_table)
+ * </pre>
+ *
+ * @author Jens Schauder
+ * @since 4.0
+ */
+public class TupleExpression extends AbstractSegment implements Expression {
+
+	private final Collection<? extends Expression> expressions;
+
+	private static Segment[] children(Collection<? extends Expression> expressions) {
+		return expressions.toArray(new Segment[0]);
+	}
+
+	TupleExpression(Collection<? extends Expression> expressions) {
+
+		super(children(expressions));
+
+		this.expressions = expressions;
+	}
+
+	/**
+	 * Creates a {@link TupleExpression} from the given expressions.
+	 *
+	 * @param expressions must not be {@literal null} or empty.
+	 * @return the new {@link TupleExpression}.
+	 */
+	public static TupleExpression create(Expression... expressions) {
+		return new TupleExpression(List.of(expressions));
+	}
+
+	/**
+	 * Creates a {@link TupleExpression} from the given expressions.
+	 *
+	 * @param expressions must not be {@literal null} or empty.
+	 * @return the new {@link TupleExpression}.
+	 */
+	public static TupleExpression create(Collection<? extends Expression> expressions) {
+		return new TupleExpression(expressions);
+	}
+
+	@Override
+	public String toString() {
+		return "(" + expressions.stream().map(Expression::toString).collect(joining(", ")) + ")";
+	}
+}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Unrestricted.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Unrestricted.java
new file mode 100644
index 0000000000..d66e955bd6
--- /dev/null
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Unrestricted.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.relational.core.sql;
+
+/**
+ * Unrestricted condition. Any condition combined with this condition will yield the other condition.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+enum Unrestricted implements Condition {
+
+	INSTANCE;
+
+	@Override
+	public Condition and(Condition other) {
+		return other;
+	}
+
+	@Override
+	public Condition or(Condition other) {
+		return other;
+	}
+
+	@Override
+	public Condition not() {
+		return Disjunct.INSTANCE;
+	}
+}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java
index 32ce15dee1..40c21e1976 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java
@@ -48,7 +48,7 @@ class ExpressionVisitor extends TypedSubtreeVisitor<Expression> implements PartR
 	/**
 	 * Creates an {@code ExpressionVisitor}.
 	 *
-	 * @param context       must not be {@literal null}.
+	 * @param context must not be {@literal null}.
 	 * @param aliasHandling controls if columns should be rendered as their alias or using their table names.
 	 * @since 2.3
 	 */
@@ -78,6 +78,13 @@ Delegation enterMatched(Expression segment) {
 			return Delegation.delegateTo(visitor);
 		}
 
+		if (segment instanceof TupleExpression) {
+
+			TupleVisitor visitor = new TupleVisitor(context);
+			partRenderer = visitor;
+			return Delegation.delegateTo(visitor);
+		}
+
 		if (segment instanceof AnalyticFunction) {
 
 			AnalyticFunctionVisitor visitor = new AnalyticFunctionVisitor(context);
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/JoinVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/JoinVisitor.java
index 94c36b11be..e73061d9c3 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/JoinVisitor.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/JoinVisitor.java
@@ -77,8 +77,12 @@ Delegation leaveNested(Visitable segment) {
 
 			if (hasSeenCondition) {
 
-				joinClause.append(" ON ");
-				joinClause.append(conditionVisitor.getRenderedPart());
+				CharSequence renderedPart = conditionVisitor.getRenderedPart();
+
+				if (!renderedPart.isEmpty()) {
+					joinClause.append(" ON ");
+					joinClause.append(renderedPart);
+				}
 
 				hasSeenCondition = false;
 			}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/TupleVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/TupleVisitor.java
new file mode 100644
index 0000000000..d03fce9d3f
--- /dev/null
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/TupleVisitor.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.relational.core.sql.render;
+
+import org.springframework.data.relational.core.sql.TupleExpression;
+import org.springframework.data.relational.core.sql.Visitable;
+
+/**
+ * Visitor for rendering tuple expressions.
+ *
+ * @author Jens Schauder
+ * @since 4.0
+ */
+class TupleVisitor extends TypedSingleConditionRenderSupport<TupleExpression> implements PartRenderer {
+
+	private final StringBuilder part = new StringBuilder();
+	private boolean needsComma = false;
+
+	TupleVisitor(RenderContext context) {
+		super(context);
+	}
+
+	@Override
+	Delegation leaveNested(Visitable segment) {
+
+		if (hasDelegatedRendering()) {
+
+			if (needsComma) {
+				part.append(", ");
+			}
+
+			part.append(consumeRenderedPart());
+			needsComma = true;
+		}
+
+		return super.leaveNested(segment);
+	}
+
+	@Override
+	Delegation enterMatched(TupleExpression segment) {
+
+		part.append("(");
+
+		return super.enterMatched(segment);
+	}
+
+	@Override
+	Delegation leaveMatched(TupleExpression segment) {
+
+		part.append(")");
+
+		return super.leaveMatched(segment);
+	}
+
+	@Override
+	public CharSequence getRenderedPart() {
+		return part;
+	}
+}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGenerator.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGenerator.java
index 65b0ff095f..2038f721ed 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGenerator.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGenerator.java
@@ -167,9 +167,10 @@ private QueryMeta createInlineQuery(AggregatePath basePath, @Nullable Condition
 		columns.add(rownumber);
 
 		String rowCountAlias = aliases.getRowCountAlias(basePath);
-		Expression count = basePath.isRoot() ? new AliasedExpression(SQL.literalOf(1), rowCountAlias)
-				: AnalyticFunction.create("count", Expressions.just("*"))
-						.partitionBy(table.column(basePath.getTableInfo().reverseColumnInfo().name())).as(rowCountAlias);
+		Expression count = basePath.isRoot() ? new AliasedExpression(SQL.literalOf(1), rowCountAlias) //
+				: AnalyticFunction.create("count", Expressions.just("*")) //
+						.partitionBy(basePath.getTableInfo().backReferenceColumnInfos().toColumnList(table) //
+						).as(rowCountAlias);
 		columns.add(count);
 
 		String backReferenceAlias = null;
@@ -178,7 +179,8 @@ private QueryMeta createInlineQuery(AggregatePath basePath, @Nullable Condition
 		if (!basePath.isRoot()) {
 
 			backReferenceAlias = aliases.getBackReferenceAlias(basePath);
-			columns.add(table.column(basePath.getTableInfo().reverseColumnInfo().name()).as(backReferenceAlias));
+			columns
+					.add(table.column(basePath.getTableInfo().backReferenceColumnInfos().unique().name()).as(backReferenceAlias));
 
 			keyAlias = aliases.getKeyAlias(basePath);
 			Expression keyExpression = basePath.isQualified()
@@ -238,9 +240,10 @@ private String getIdentifierProperty(List<AggregatePath> paths) {
 
 	private static AnalyticFunction createRowNumberExpression(AggregatePath basePath, Table table,
 			String rowNumberAlias) {
+		AggregatePath.ColumnInfos reverseColumnInfos = basePath.getTableInfo().backReferenceColumnInfos();
 		return AnalyticFunction.create("row_number") //
-				.partitionBy(table.column(basePath.getTableInfo().reverseColumnInfo().name())) //
-				.orderBy(table.column(basePath.getTableInfo().reverseColumnInfo().name())) //
+				.partitionBy(reverseColumnInfos.toColumnList(table)) //
+				.orderBy(reverseColumnInfos.toColumnList(table)) //
 				.as(rowNumberAlias);
 	}
 
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathAssertions.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathAssertions.java
new file mode 100644
index 0000000000..33a195d5b6
--- /dev/null
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathAssertions.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.relational.core.mapping;
+
+import org.assertj.core.api.AbstractAssert;
+
+/**
+ * Custom AssertJ assertions for {@link AggregatePath} instances
+ * 
+ * @author Jens Schauder
+ * @since 4.0
+ */
+public class AggregatePathAssertions extends AbstractAssert<AggregatePathAssertions, AggregatePath> {
+
+	/**
+	 * Constructor taking the actual {@link AggregatePath} to assert over.
+	 * 
+	 * @param actual
+	 */
+	public AggregatePathAssertions(AggregatePath actual) {
+		super(actual, AggregatePathAssertions.class);
+	}
+
+	/**
+	 * Entry point for creating assertions for AggregatePath.
+	 */
+	public static AggregatePathAssertions assertThat(AggregatePath actual) {
+		return new AggregatePathAssertions(actual);
+	}
+
+	/**
+	 * Assertion method comparing the path of the actual AggregatePath with the provided String representation of a path
+	 * in dot notation. Note that the assertion does not test the root entity type of the AggregatePath.
+	 */
+	public AggregatePathAssertions hasPath(String expectedPath) {
+		isNotNull();
+
+		if (!actual.toDotPath().equals(expectedPath)) { // Adjust this condition based on your AggregatePath's path logic
+			failWithMessage("Expected path to be <%s> but was <%s>", expectedPath, actual.toString());
+		}
+		return this;
+	}
+
+	/**
+	 * assertion testing if the actual path is a root path.
+	 */
+	public AggregatePathAssertions isRoot() {
+		isNotNull();
+
+		if (!actual.isRoot()) {
+			failWithMessage("Expected AggregatePath to be root path, but it was not");
+		}
+		return this;
+	}
+
+	/**
+	 * assertion testing if the actual path is NOT a root path.
+	 */
+	public AggregatePathAssertions isNotRoot() {
+		isNotNull();
+
+		if (actual.isRoot()) {
+			failWithMessage("Expected AggregatePath not to be root path, but it was.");
+		}
+		return this;
+	}
+}
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathSoftAssertions.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathSoftAssertions.java
new file mode 100644
index 0000000000..3b59af40ba
--- /dev/null
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathSoftAssertions.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.relational.core.mapping;
+
+import java.util.function.Consumer;
+
+import org.assertj.core.api.SoftAssertions;
+import org.assertj.core.api.SoftAssertionsProvider;
+
+/**
+ * Soft assertions for {@link AggregatePath} instances.
+ *
+ * @author Jens Schauder
+ * @since 4.0
+ */
+public class AggregatePathSoftAssertions extends SoftAssertions {
+
+	/**
+	 * Entry point for assertions. The default {@literal assertThat} can't be used, since it collides with {@link SoftAssertions#assertThat(Iterable)}
+	 */
+	public AggregatePathAssertions assertAggregatePath(AggregatePath actual) {
+		return proxy(AggregatePathAssertions.class, AggregatePath.class, actual);
+	}
+
+	static void assertAggregatePathsSoftly(Consumer<AggregatePathSoftAssertions> softly) {
+		SoftAssertionsProvider.assertSoftly(AggregatePathSoftAssertions.class, softly);
+	}
+}
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/ColumnInfosUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/ColumnInfosUnitTests.java
new file mode 100644
index 0000000000..17583ed10e
--- /dev/null
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/ColumnInfosUnitTests.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.relational.core.mapping;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.relational.core.sql.SqlIdentifier;
+import org.springframework.data.relational.core.sql.Table;
+
+/**
+ * Unit tests for the construction of {@link org.springframework.data.relational.core.mapping.AggregatePath.ColumnInfos}
+ *
+ * @author Jens Schauder
+ */
+class ColumnInfosUnitTests {
+
+	static final Table TABLE = Table.create("dummy");
+	static final SqlIdentifier ID = SqlIdentifier.quoted("ID");
+	RelationalMappingContext context = new RelationalMappingContext();
+
+	@Test // GH-574
+	void emptyColumnInfos() {
+
+		AggregatePath.ColumnInfos columnInfos = AggregatePath.ColumnInfos.empty();
+
+		assertThat(columnInfos.isEmpty()).isTrue();
+		assertThrows(NoSuchElementException.class, columnInfos::any);
+		assertThrows(IllegalStateException.class, columnInfos::unique);
+		assertThat(columnInfos.toColumnList(TABLE)).isEmpty();
+	}
+
+	@Test // GH-574
+	void singleElementColumnInfos() {
+
+		AggregatePath.ColumnInfos columnInfos = basePath(DummyEntity.class).getTableInfo().idColumnInfos();
+
+		assertThat(columnInfos.isEmpty()).isFalse();
+		assertThat(columnInfos.any().name()).isEqualTo(ID);
+		assertThat(columnInfos.unique().name()).isEqualTo(ID);
+		assertThat(columnInfos.toColumnList(TABLE)).containsExactly(TABLE.column(ID));
+	}
+
+	@Test // GH-574
+	void multiElementColumnInfos() {
+
+		AggregatePath.ColumnInfos columnInfos = basePath(WithCompositeId.class).getTableInfo().idColumnInfos();
+
+		assertThat(columnInfos.isEmpty()).isFalse();
+		assertThat(columnInfos.any().name()).isEqualTo(SqlIdentifier.quoted("ONE"));
+		assertThrows(IllegalStateException.class, columnInfos::unique);
+		assertThat(columnInfos.toColumnList(TABLE)) //
+				.containsExactly( //
+						TABLE.column(SqlIdentifier.quoted("ONE")), //
+						TABLE.column(SqlIdentifier.quoted("TWO")) //
+				);
+
+		List<String> collector = new ArrayList<>();
+		columnInfos.forEach((ap, ci) -> collector.add(ap.toDotPath() + "+" + ci.name()));
+		assertThat(collector).containsExactly("id.one+\"ONE\"", "id.two+\"TWO\"");
+	}
+
+	private AggregatePath getPath(Class<?> type, String name) {
+		return basePath(type).append(context.getPersistentEntity(type).getPersistentProperty(name));
+	}
+
+	private AggregatePath basePath(Class<?> type) {
+		return context.getAggregatePath(context.getPersistentEntity(type));
+	}
+
+	record DummyEntity(@Id String id, String name) {
+	}
+
+	record CompositeId(String one, String two) {
+	}
+
+	record WithCompositeId(@Id @Embedded.Nullable CompositeId id, String name) {
+	}
+}
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java
index c173d0294f..dfd90d2a43 100644
--- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java
@@ -22,13 +22,17 @@
 
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
 import java.util.stream.Collectors;
 
 import org.junit.jupiter.api.Test;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.annotation.ReadOnlyProperty;
 import org.springframework.data.mapping.PersistentPropertyPath;
+import org.springframework.data.relational.core.sql.Column;
 import org.springframework.data.relational.core.sql.SqlIdentifier;
+import org.springframework.data.relational.core.sql.Table;
 
 /**
  * Tests for {@link AggregatePath}.
@@ -46,7 +50,7 @@ void isNotRootForNonRootPath() {
 
 		AggregatePath path = context.getAggregatePath(context.getPersistentPropertyPath("entityId", DummyEntity.class));
 
-		assertThat(path.isRoot()).isFalse();
+		AggregatePathAssertions.assertThat(path).isNotRoot();
 	}
 
 	@Test // GH-1525
@@ -54,17 +58,17 @@ void isRootForRootPath() {
 
 		AggregatePath path = context.getAggregatePath(entity);
 
-		assertThat(path.isRoot()).isTrue();
+		AggregatePathAssertions.assertThat(path).isRoot();
 	}
 
 	@Test // GH-1525
 	void getParentPath() {
 
-		assertSoftly(softly -> {
+		AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> {
 
-			softly.assertThat(path("second.third2.value").getParentPath()).isEqualTo(path("second.third2"));
-			softly.assertThat(path("second.third2").getParentPath()).isEqualTo(path("second"));
-			softly.assertThat(path("second").getParentPath()).isEqualTo(path());
+			softly.assertAggregatePath(path("second.third2.value").getParentPath()).hasPath("second.third2");
+			softly.assertAggregatePath(path("second.third2").getParentPath()).hasPath("second");
+			softly.assertAggregatePath(path("second").getParentPath()).isRoot();
 
 			softly.assertThatThrownBy(() -> path().getParentPath()).isInstanceOf(IllegalStateException.class);
 		});
@@ -75,13 +79,13 @@ void getRequiredLeafEntity() {
 
 		assertSoftly(softly -> {
 
+			RelationalPersistentEntity<?> secondEntity = context.getRequiredPersistentEntity(Second.class);
+			RelationalPersistentEntity<?> thirdEntity = context.getRequiredPersistentEntity(Third.class);
+
 			softly.assertThat(path().getRequiredLeafEntity()).isEqualTo(entity);
-			softly.assertThat(path("second").getRequiredLeafEntity())
-					.isEqualTo(context.getRequiredPersistentEntity(Second.class));
-			softly.assertThat(path("second.third").getRequiredLeafEntity())
-					.isEqualTo(context.getRequiredPersistentEntity(Third.class));
-			softly.assertThat(path("secondList").getRequiredLeafEntity())
-					.isEqualTo(context.getRequiredPersistentEntity(Second.class));
+			softly.assertThat(path("second").getRequiredLeafEntity()).isEqualTo(secondEntity);
+			softly.assertThat(path("second.third").getRequiredLeafEntity()).isEqualTo(thirdEntity);
+			softly.assertThat(path("secondList").getRequiredLeafEntity()).isEqualTo(secondEntity);
 
 			softly.assertThatThrownBy(() -> path("secondList.third.value").getRequiredLeafEntity())
 					.isInstanceOf(IllegalStateException.class);
@@ -92,16 +96,16 @@ void getRequiredLeafEntity() {
 	@Test // GH-1525
 	void idDefiningPath() {
 
-		assertSoftly(softly -> {
+		AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> {
 
-			softly.assertThat(path("second.third2.value").getIdDefiningParentPath()).isEqualTo(path());
-			softly.assertThat(path("second.third.value").getIdDefiningParentPath()).isEqualTo(path());
-			softly.assertThat(path("secondList.third2.value").getIdDefiningParentPath()).isEqualTo(path());
-			softly.assertThat(path("secondList.third.value").getIdDefiningParentPath()).isEqualTo(path());
-			softly.assertThat(path("second2.third2.value").getIdDefiningParentPath()).isEqualTo(path());
-			softly.assertThat(path("second2.third.value").getIdDefiningParentPath()).isEqualTo(path());
-			softly.assertThat(path("withId.second.third2.value").getIdDefiningParentPath()).isEqualTo(path("withId"));
-			softly.assertThat(path("withId.second.third.value").getIdDefiningParentPath()).isEqualTo(path("withId"));
+			softly.assertAggregatePath(path("second.third2.value").getIdDefiningParentPath()).isRoot();
+			softly.assertAggregatePath(path("second.third.value").getIdDefiningParentPath()).isRoot();
+			softly.assertAggregatePath(path("secondList.third2.value").getIdDefiningParentPath()).isRoot();
+			softly.assertAggregatePath(path("secondList.third.value").getIdDefiningParentPath()).isRoot();
+			softly.assertAggregatePath(path("second2.third2.value").getIdDefiningParentPath()).isRoot();
+			softly.assertAggregatePath(path("second2.third.value").getIdDefiningParentPath()).isRoot();
+			softly.assertAggregatePath(path("withId.second.third2.value").getIdDefiningParentPath()).hasPath("withId");
+			softly.assertAggregatePath(path("withId.second.third.value").getIdDefiningParentPath()).hasPath("withId");
 		});
 	}
 
@@ -121,13 +125,13 @@ void reverseColumnName() {
 
 		assertSoftly(softly -> {
 
-			softly.assertThat(path("second.third2").getTableInfo().reverseColumnInfo().name())
+			softly.assertThat((Object) path("second.third2").getTableInfo().reverseColumnInfo().name())
 					.isEqualTo(quoted("DUMMY_ENTITY"));
-			softly.assertThat(path("second.third").getTableInfo().reverseColumnInfo().name())
+			softly.assertThat((Object) path("second.third").getTableInfo().reverseColumnInfo().name())
 					.isEqualTo(quoted("DUMMY_ENTITY"));
-			softly.assertThat(path("secondList.third2").getTableInfo().reverseColumnInfo().name())
+			softly.assertThat((Object) path("secondList.third2").getTableInfo().reverseColumnInfo().name())
 					.isEqualTo(quoted("DUMMY_ENTITY"));
-			softly.assertThat(path("secondList.third").getTableInfo().reverseColumnInfo().name())
+			softly.assertThat((Object) path("secondList.third").getTableInfo().reverseColumnInfo().name())
 					.isEqualTo(quoted("DUMMY_ENTITY"));
 			softly.assertThat(path("second2.third").getTableInfo().reverseColumnInfo().name())
 					.isEqualTo(quoted("DUMMY_ENTITY"));
@@ -140,6 +144,19 @@ void reverseColumnName() {
 		});
 	}
 
+	@Test // GH-574
+	void reverseColumnNames() {
+
+		assertSoftly(softly -> {
+			softly
+					.assertThat(path(CompoundIdEntity.class, "second").getTableInfo().backReferenceColumnInfos()
+							.toColumnList(Table.create("dummy")))
+					.extracting(Column::getName)
+					.containsExactlyInAnyOrder(quoted("COMPOUND_ID_ENTITY_ONE"), quoted("COMPOUND_ID_ENTITY_TWO"));
+
+		});
+	}
+
 	@Test // GH-1525
 	void getQualifierColumn() {
 
@@ -169,12 +186,11 @@ void getQualifierColumnType() {
 
 	@Test // GH-1525
 	void extendBy() {
+		AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> {
 
-		assertSoftly(softly -> {
-
-			softly.assertThat(path().append(entity.getRequiredPersistentProperty("withId"))).isEqualTo(path("withId"));
-			softly.assertThat(path("withId").append(path("withId").getRequiredIdProperty()))
-					.isEqualTo(path("withId.withIdId"));
+			softly.assertAggregatePath(path().append(entity.getRequiredPersistentProperty("withId"))).hasPath("withId");
+			softly.assertAggregatePath(path("withId").append(path("withId").getRequiredIdProperty()))
+					.hasPath("withId.withIdId");
 		});
 	}
 
@@ -229,11 +245,11 @@ void isMultiValued() {
 			softly.assertThat(path("second").isMultiValued()).isFalse();
 			softly.assertThat(path("second.third2").isMultiValued()).isFalse();
 			softly.assertThat(path("secondList.third2").isMultiValued()).isTrue(); // this seems wrong as third2 is an
-																																							// embedded path into Second, held by
-																																							// List<Second> (so the parent is
-																																							// multi-valued but not third2).
-			// TODO: This test fails because MultiValued considers parents.
-			// softly.assertThat(path("secondList.third.value").isMultiValued()).isFalse();
+
+			// embedded path into Second, held by
+			// List<Second> (so the parent is
+			// multi-valued but not third2).
+			softly.assertThat(path("secondList.third.value").isMultiValued()).isTrue();
 			softly.assertThat(path("secondList").isMultiValued()).isTrue();
 		});
 	}
@@ -306,13 +322,13 @@ void getTableAlias() {
 			softly.assertThat(path("second.third2").getTableInfo().tableAlias()).isEqualTo(quoted("second"));
 			softly.assertThat(path("second.third2.value").getTableInfo().tableAlias()).isEqualTo(quoted("second"));
 			softly.assertThat(path("second.third").getTableInfo().tableAlias()).isEqualTo(quoted("second_third")); // missing
-																																																							// _
+			// _
 			softly.assertThat(path("second.third.value").getTableInfo().tableAlias()).isEqualTo(quoted("second_third")); // missing
-																																																										// _
+			// _
 			softly.assertThat(path("secondList.third2").getTableInfo().tableAlias()).isEqualTo(quoted("secondList"));
 			softly.assertThat(path("secondList.third2.value").getTableInfo().tableAlias()).isEqualTo(quoted("secondList"));
 			softly.assertThat(path("secondList.third").getTableInfo().tableAlias()).isEqualTo(quoted("secondList_third")); // missing
-																																																											// _
+			// _
 			softly.assertThat(path("secondList.third.value").getTableInfo().tableAlias())
 					.isEqualTo(quoted("secondList_third")); // missing _
 			softly.assertThat(path("secondList").getTableInfo().tableAlias()).isEqualTo(quoted("secondList"));
@@ -416,20 +432,6 @@ void getBaseProperty() {
 		});
 	}
 
-	@Test // GH-1525
-	void getIdColumnName() {
-
-		assertSoftly(softly -> {
-
-			softly.assertThat(path().getTableInfo().idColumnName()).isEqualTo(quoted("ENTITY_ID"));
-			softly.assertThat(path("withId").getTableInfo().idColumnName()).isEqualTo(quoted("WITH_ID_ID"));
-
-			softly.assertThat(path("second").getTableInfo().idColumnName()).isNull();
-			softly.assertThat(path("second.third2").getTableInfo().idColumnName()).isNull();
-			softly.assertThat(path("withId.second").getTableInfo().idColumnName()).isNull();
-		});
-	}
-
 	@Test // GH-1525
 	void toDotPath() {
 
@@ -453,42 +455,86 @@ void getRequiredPersistentPropertyPath() {
 	}
 
 	@Test // GH-1525
-	void getEffectiveIdColumnName() {
+	void getLength() {
 
 		assertSoftly(softly -> {
+			softly.assertThat(path().getLength()).isEqualTo(1);
+			softly.assertThat(path().stream().collect(Collectors.toList())).hasSize(1);
 
-			softly.assertThat(path().getTableInfo().effectiveIdColumnName()).isEqualTo(quoted("ENTITY_ID"));
-			softly.assertThat(path("second.third2").getTableInfo().effectiveIdColumnName()).isEqualTo(quoted("DUMMY_ENTITY"));
-			softly.assertThat(path("withId.second.third").getTableInfo().effectiveIdColumnName())
-					.isEqualTo(quoted("WITH_ID"));
-			softly.assertThat(path("withId.second.third2.value").getTableInfo().effectiveIdColumnName())
-					.isEqualTo(quoted("WITH_ID"));
+			softly.assertThat(path("second.third2").getLength()).isEqualTo(3);
+			softly.assertThat(path("second.third2").stream().collect(Collectors.toList())).hasSize(3);
+
+			softly.assertThat(path("withId.second.third").getLength()).isEqualTo(4);
+			softly.assertThat(path("withId.second.third2.value").getLength()).isEqualTo(5);
 		});
 	}
 
-	@Test // GH-1525
-	void getLength() {
+	@Test // GH-574
+	void getTail() {
+
+		AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> {
+
+			softly.assertAggregatePath(path().getTail()).isNull();
+			softly.assertAggregatePath(path("second").getTail()).isNull();
+			softly.assertAggregatePath(path("second.third").getTail()).hasPath("third");
+			softly.assertAggregatePath(path("second.third.value").getTail()).hasPath("third.value");
+		});
+	}
+
+	@Test // GH-74
+	void append() {
+
+		AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> {
+			softly.assertAggregatePath(path("second").append(path())).hasPath("second");
+			softly.assertAggregatePath(path().append(path("second"))).hasPath("second");
+			softly.assertAggregatePath(path().append(path("second.third"))).hasPath("second.third");
+			AggregatePath value = path("second.third.value").getTail().getTail();
+			softly.assertAggregatePath(path("second.third").append(value)).hasPath("second.third.value");
+		});
+	}
+
+	@Test // GH-574
+	void sortPaths() {
+
+		Set<AggregatePath> sorted = new TreeSet<>();
+
+		AggregatePath alpha = path();
+		AggregatePath as = path("second");
+		AggregatePath ast = path("second.third");
+		AggregatePath aw = path("withId");
 
-		assertThat(path().getLength()).isEqualTo(1);
-		assertThat(path().stream().collect(Collectors.toList())).hasSize(1);
+		sorted.add(aw);
+		sorted.add(ast);
+		sorted.add(as);
+		sorted.add(alpha);
 
-		assertThat(path("second.third2").getLength()).isEqualTo(3);
-		assertThat(path("second.third2").stream().collect(Collectors.toList())).hasSize(3);
+		assertThat(sorted).containsExactly(alpha, as, ast, aw);
 
-		assertThat(path("withId.second.third").getLength()).isEqualTo(4);
-		assertThat(path("withId.second.third2.value").getLength()).isEqualTo(5);
 	}
 
 	private AggregatePath path() {
 		return context.getAggregatePath(entity);
 	}
 
+	private AggregatePath path(RelationalPersistentEntity<?> entity) {
+		return context.getAggregatePath(entity);
+	}
+
+	private AggregatePath path(Class<?> entityType, String path) {
+		return context.getAggregatePath(createSimplePath(entityType, path));
+	}
+
 	private AggregatePath path(String path) {
 		return context.getAggregatePath(createSimplePath(path));
 	}
 
 	PersistentPropertyPath<RelationalPersistentProperty> createSimplePath(String path) {
-		return PersistentPropertyPathTestUtils.getPath(context, path, DummyEntity.class);
+		return createSimplePath(entity.getType(), path);
+	}
+
+	PersistentPropertyPath<RelationalPersistentProperty> createSimplePath(Class<?> entityType, String path) {
+
+		return PersistentPropertyPathTestUtils.getPath(context, path, entityType);
 	}
 
 	@SuppressWarnings("unused")
@@ -502,6 +548,12 @@ static class DummyEntity {
 		WithId withId;
 	}
 
+	record CompoundId(Long one, String two) {
+	}
+
+	record CompoundIdEntity(@Id CompoundId id, Second second) {
+	}
+
 	@SuppressWarnings("unused")
 	static class Second {
 		Third third;
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java
index 4af641fb13..231c819cc7 100644
--- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java
@@ -60,7 +60,7 @@ public void canObtainAggregatePath() {
 				EntityWithUuid.class);
 		AggregatePath aggregatePath = context.getAggregatePath(path);
 
-		assertThat(aggregatePath).isNotNull();
+		assertThat((Object) aggregatePath).isNotNull();
 	}
 
 	@Test // GH-1525
@@ -75,7 +75,7 @@ public void innerAggregatePathsGetCached() {
 		AggregatePath one = context.getAggregatePath(path);
 		AggregatePath two = context.getAggregatePath(path);
 
-		assertThat(one).isSameAs(two);
+		assertThat((Object) one).isSameAs(two);
 	}
 
 	@Test // GH-1525
@@ -87,7 +87,7 @@ public void rootAggregatePathsGetCached() {
 		AggregatePath one = context.getAggregatePath(context.getRequiredPersistentEntity(EntityWithUuid.class));
 		AggregatePath two = context.getAggregatePath(context.getRequiredPersistentEntity(EntityWithUuid.class));
 
-		assertThat(one).isSameAs(two);
+		assertThat((Object) one).isSameAs(two);
 	}
 
 	@Test // GH-1586
@@ -117,7 +117,7 @@ void aggregatePathsOfBasePropertyForDifferentInheritedEntitiesAreDifferent() {
 		AggregatePath aggregatePath1 = context.getAggregatePath(path1);
 		AggregatePath aggregatePath2 = context.getAggregatePath(path2);
 
-		assertThat(aggregatePath1).isNotEqualTo(aggregatePath2);
+		assertThat((Object) aggregatePath1).isNotEqualTo(aggregatePath2);
 	}
 
 	static class EntityWithUuid {
@@ -128,6 +128,14 @@ static class WithEmbedded {
 		@Embedded.Empty(prefix = "prnt_") Parent parent;
 	}
 
+	static class WithEmbeddedId {
+		@Embedded.Nullable
+		@Id CompositeId id;
+	}
+
+	private record CompositeId(int a, int b) {
+	}
+
 	static class Parent {
 
 		@Embedded.Empty(prefix = "chld_") Child child;
@@ -144,5 +152,4 @@ static class Base {
 	static class Inherit1 extends Base {}
 
 	static class Inherit2 extends Base {}
-
 }
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java
new file mode 100644
index 0000000000..f673190aad
--- /dev/null
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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
+ *
+ *      https://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.springframework.data.relational.core.sql;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for construction of {@link TupleExpression}.
+ *
+ * @author Jens Schauder
+ */
+class TupleExpressionUnitTests {
+
+	@Test // GH-574
+	void singleExpressionDoesNotGetWrapped() {
+
+		Column testColumn = Column.create("name", Table.create("employee"));
+
+		Expression wrapped = Expressions.of(List.of(testColumn));
+
+		assertThat(wrapped).isSameAs(testColumn);
+	}
+
+	@Test // GH-574
+	void multipleExpressionsDoGetWrapped() {
+
+		Column testColumn1 = Column.create("first", Table.create("employee"));
+		Column testColumn2 = Column.create("last", Table.create("employee"));
+
+		Expression wrapped = Expressions.of(List.of(testColumn1, testColumn2));
+
+		assertThat(wrapped).isInstanceOf(TupleExpression.class);
+	}
+
+}
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/DeleteRendererUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/DeleteRendererUnitTests.java
index b451fea90b..09edd54b55 100644
--- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/DeleteRendererUnitTests.java
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/DeleteRendererUnitTests.java
@@ -27,10 +27,10 @@
  *
  * @author Mark Paluch
  */
-public class DeleteRendererUnitTests {
+class DeleteRendererUnitTests {
 
 	@Test // DATAJDBC-335
-	public void shouldRenderWithoutWhere() {
+	void shouldRenderWithoutWhere() {
 
 		Table bar = SQL.table("bar");
 
@@ -40,7 +40,7 @@ public void shouldRenderWithoutWhere() {
 	}
 
 	@Test // DATAJDBC-335
-	public void shouldRenderWithCondition() {
+	void shouldRenderWithCondition() {
 
 		Table table = Table.create("bar");
 
@@ -52,7 +52,7 @@ public void shouldRenderWithCondition() {
 	}
 
 	@Test // DATAJDBC-335
-	public void shouldConsiderTableAlias() {
+	void shouldConsiderTableAlias() {
 
 		Table table = Table.create("bar").as("my_bar");
 
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java
index 4f2121656e..2a662a2919 100644
--- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java
@@ -17,6 +17,8 @@
 
 import static org.assertj.core.api.Assertions.*;
 
+import java.util.List;
+
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 import org.springframework.data.relational.core.dialect.PostgresDialect;
@@ -24,8 +26,6 @@
 import org.springframework.data.relational.core.sql.*;
 import org.springframework.util.StringUtils;
 
-import java.util.List;
-
 /**
  * Unit tests for {@link SqlRenderer}.
  *
@@ -115,196 +115,6 @@ void shouldRenderCountFunctionWithAliasedColumn() {
 		assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT COUNT(bar.foo), bar.foo AS foo_bar FROM bar");
 	}
 
-	@Test // DATAJDBC-309
-	void shouldRenderSimpleJoin() {
-
-		Table employee = SQL.table("employee");
-		Table department = SQL.table("department");
-
-		Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) //
-				.join(department).on(employee.column("department_id")).equals(department.column("id")) //
-				.build();
-
-		assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee "
-				+ "JOIN department ON employee.department_id = department.id");
-	}
-
-	@Test // DATAJDBC-340
-	void shouldRenderOuterJoin() {
-
-		Table employee = SQL.table("employee");
-		Table department = SQL.table("department");
-
-		Select select = Select.builder().select(employee.column("id"), department.column("name")) //
-				.from(employee) //
-				.leftOuterJoin(department).on(employee.column("department_id")).equals(department.column("id")) //
-				.build();
-
-		assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee "
-				+ "LEFT OUTER JOIN department ON employee.department_id = department.id");
-	}
-
-	@Test // GH-1421
-	void shouldRenderFullOuterJoin() {
-
-		Table employee = SQL.table("employee");
-		Table department = SQL.table("department");
-
-		Select select = Select.builder().select(employee.column("id"), department.column("name")) //
-				.from(employee) //
-				.join(department, Join.JoinType.FULL_OUTER_JOIN).on(employee.column("department_id"))
-				.equals(department.column("id")) //
-				.build();
-
-		assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee "
-				+ "FULL OUTER JOIN department ON employee.department_id = department.id");
-	}
-
-	@Test // DATAJDBC-309
-	void shouldRenderSimpleJoinWithAnd() {
-
-		Table employee = SQL.table("employee");
-		Table department = SQL.table("department");
-
-		Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) //
-				.join(department).on(employee.column("department_id")).equals(department.column("id")) //
-				.and(employee.column("tenant")).equals(department.column("tenant")) //
-				.build();
-
-		assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " //
-				+ "JOIN department ON employee.department_id = department.id " //
-				+ "AND employee.tenant = department.tenant");
-	}
-
-	@Test // #995
-	void shouldRenderArbitraryJoinCondition() {
-
-		Table employee = SQL.table("employee");
-		Table department = SQL.table("department");
-
-		Select select = Select.builder() //
-				.select(employee.column("id"), department.column("name")) //
-				.from(employee) //
-				.join(department) //
-				.on(Conditions.isEqual(employee.column("department_id"), department.column("id")) //
-						.or(Conditions.isNotEqual(employee.column("tenant"), department.column("tenant")) //
-						)).build();
-
-		assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " //
-				+ "JOIN department ON employee.department_id = department.id " //
-				+ "OR employee.tenant != department.tenant");
-	}
-
-	@Test // #1009
-	void shouldRenderJoinWithJustExpression() {
-
-		Table employee = SQL.table("employee");
-		Table department = SQL.table("department");
-
-		Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) //
-				.join(department).on(Expressions.just("alpha")).equals(Expressions.just("beta")) //
-				.build();
-
-		assertThat(SqlRenderer.toString(select))
-				.isEqualTo("SELECT employee.id, department.name FROM employee " + "JOIN department ON alpha = beta");
-	}
-
-	@Test // DATAJDBC-309
-	void shouldRenderMultipleJoinWithAnd() {
-
-		Table employee = SQL.table("employee");
-		Table department = SQL.table("department");
-		Table tenant = SQL.table("tenant").as("tenant_base");
-
-		Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) //
-				.join(department).on(employee.column("department_id")).equals(department.column("id")) //
-				.and(employee.column("tenant")).equals(department.column("tenant")) //
-				.join(tenant).on(tenant.column("tenant_id")).equals(department.column("tenant")) //
-				.build();
-
-		assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " //
-				+ "JOIN department ON employee.department_id = department.id " //
-				+ "AND employee.tenant = department.tenant " //
-				+ "JOIN tenant tenant_base ON tenant_base.tenant_id = department.tenant");
-	}
-
-	@Test // GH-1003
-	void shouldRenderJoinWithInlineQuery() {
-
-		Table employee = SQL.table("employee");
-		Table department = SQL.table("department");
-
-		Select innerSelect = Select.builder()
-				.select(employee.column("id"), employee.column("department_Id"), employee.column("name")).from(employee)
-				.build();
-
-		InlineQuery one = InlineQuery.create(innerSelect, "one");
-
-		Select select = Select.builder().select(one.column("id"), department.column("name")).from(department) //
-				.join(one).on(one.column("department_id")).equals(department.column("id")) //
-				.build();
-
-		String sql = SqlRenderer.toString(select);
-
-		assertThat(sql).isEqualTo("SELECT one.id, department.name FROM department " //
-				+ "JOIN (SELECT employee.id, employee.department_Id, employee.name FROM employee) one " //
-				+ "ON one.department_id = department.id");
-	}
-
-	@Test // GH-1362
-	void shouldRenderNestedJoins() {
-
-		Table merchantCustomers = Table.create("merchants_customers");
-		Table customerDetails = Table.create("customer_details");
-
-		Select innerSelect = Select.builder().select(customerDetails.column("cd_user_id")).from(customerDetails)
-				.join(merchantCustomers)
-				.on(merchantCustomers.column("mc_user_id").isEqualTo(customerDetails.column("cd_user_id"))).build();
-
-		InlineQuery innerTable = InlineQuery.create(innerSelect, "inner");
-
-		Select select = Select.builder().select(merchantCustomers.asterisk()) //
-				.from(merchantCustomers) //
-				.join(innerTable).on(innerTable.column("i_user_id").isEqualTo(merchantCustomers.column("mc_user_id"))) //
-				.build();
-
-		String sql = SqlRenderer.toString(select);
-
-		assertThat(sql).isEqualTo("SELECT merchants_customers.* FROM merchants_customers " + //
-				"JOIN (" + //
-				"SELECT customer_details.cd_user_id " + //
-				"FROM customer_details " + //
-				"JOIN merchants_customers ON merchants_customers.mc_user_id = customer_details.cd_user_id" + //
-				") inner " + //
-				"ON inner.i_user_id = merchants_customers.mc_user_id");
-	}
-
-	@Test // GH-1003
-	void shouldRenderJoinWithTwoInlineQueries() {
-
-		Table employee = SQL.table("employee");
-		Table department = SQL.table("department");
-
-		Select innerSelectOne = Select.builder()
-				.select(employee.column("id").as("empId"), employee.column("department_Id"), employee.column("name"))
-				.from(employee).build();
-		Select innerSelectTwo = Select.builder().select(department.column("id"), department.column("name")).from(department)
-				.build();
-
-		InlineQuery one = InlineQuery.create(innerSelectOne, "one");
-		InlineQuery two = InlineQuery.create(innerSelectTwo, "two");
-
-		Select select = Select.builder().select(one.column("empId"), two.column("name")).from(one) //
-				.join(two).on(two.column("department_id")).equals(one.column("empId")) //
-				.build();
-
-		String sql = SqlRenderer.toString(select);
-		assertThat(sql).isEqualTo("SELECT one.empId, two.name FROM (" //
-				+ "SELECT employee.id AS empId, employee.department_Id, employee.name FROM employee) one " //
-				+ "JOIN (SELECT department.id, department.name FROM department) two " //
-				+ "ON two.department_id = one.empId");
-	}
-
 	@Test // DATAJDBC-309
 	void shouldRenderOrderByName() {
 
@@ -328,6 +138,17 @@ void shouldRenderOrderByAlias() {
 				.isEqualTo("SELECT emp.name AS my_emp_name FROM employee emp ORDER BY my_emp_name ASC");
 	}
 
+	@Test // GH-574
+	void shouldNotRenderEmptyCondition() {
+
+		Table table = SQL.table("foo");
+		Column bar = table.column("bar");
+
+		Select select = Select.builder().select(bar).from(table).where(Conditions.unrestricted()).build();
+
+		assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT foo.bar FROM foo");
+	}
+
 	@Test // DATAJDBC-309
 	void shouldRenderIsNull() {
 
@@ -424,7 +245,6 @@ void shouldRenderSimpleFunctionWithSubselect() {
 		Table floo = SQL.table("floo");
 		Column bah = floo.column("bah");
 
-
 		Select subselect = Select.builder().select(bah).from(floo).build();
 
 		SimpleFunction func = SimpleFunction.create("func", List.of(SubselectExpression.of(subselect)));
@@ -435,8 +255,8 @@ void shouldRenderSimpleFunctionWithSubselect() {
 				.where(Conditions.isEqual(func, SQL.literalOf(23))) //
 				.build();
 
-		assertThat(SqlRenderer.toString(select))
-				.isEqualTo("SELECT func(SELECT floo.bah FROM floo) AS alias FROM foo WHERE func(SELECT floo.bah FROM floo) = 23");
+		assertThat(SqlRenderer.toString(select)).isEqualTo(
+				"SELECT func(SELECT floo.bah FROM floo) AS alias FROM foo WHERE func(SELECT floo.bah FROM floo) = 23");
 	}
 
 	@Test // DATAJDBC-309
@@ -709,7 +529,7 @@ void asteriskOfAliasedTableUsesAlias() {
 		assertThat(rendered).isEqualTo("SELECT e.*, e.id FROM employee e");
 	}
 
-	@Test
+	@Test // GH-1844
 	void rendersCaseExpression() {
 
 		Table table = SQL.table("table");
@@ -724,7 +544,239 @@ void rendersCaseExpression() {
 				.build();
 
 		String rendered = SqlRenderer.toString(select);
-		assertThat(rendered).isEqualTo("SELECT CASE WHEN table.name IS NULL THEN 1 WHEN table.name IS NOT NULL THEN table.name ELSE 3 END FROM table");
+		assertThat(rendered).isEqualTo(
+				"SELECT CASE WHEN table.name IS NULL THEN 1 WHEN table.name IS NOT NULL THEN table.name ELSE 3 END FROM table");
+	}
+
+	@Test // GH-574
+	void rendersTupleExpression() {
+
+		Table table = SQL.table("table");
+		Column first = table.column("first");
+		Column middle = table.column("middle");
+		Column last = table.column("last").as("anAlias");
+
+		TupleExpression tupleExpression = TupleExpression.create(first, SQL.literalOf(1), middle, last); //
+
+		Select select = StatementBuilder.select(first) //
+				.from(table) //
+				.where(Conditions.in(tupleExpression, Expressions.just("some expression"))).build();
+
+		String rendered = SqlRenderer.toString(select);
+		assertThat(rendered).isEqualTo(
+				"SELECT table.first FROM table WHERE (table.first, 1, table.middle, table.last) IN (some expression)");
+	}
+
+	/**
+	 * Tests for rendering joins.
+	 */
+	@Nested
+	class JoinsTests {
+
+		@Test // DATAJDBC-309
+		void shouldRenderSimpleJoin() {
+
+			Table employee = SQL.table("employee");
+			Table department = SQL.table("department");
+
+			Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) //
+					.join(department).on(employee.column("department_id")).equals(department.column("id")) //
+					.build();
+
+			assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee "
+					+ "JOIN department ON employee.department_id = department.id");
+		}
+
+		@Test // GH-574
+		void shouldRenderSimpleJoinWithUnrestrictedCondition() {
+
+			Table employee = SQL.table("employee");
+			Table department = SQL.table("department");
+
+			Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) //
+					.join(department).on(Conditions.unrestricted()) //
+					.build();
+
+			assertThat(SqlRenderer.toString(select))
+					.isEqualTo("SELECT employee.id, department.name FROM employee " + "JOIN department");
+		}
+
+		@Test // DATAJDBC-340
+		void shouldRenderOuterJoin() {
+
+			Table employee = SQL.table("employee");
+			Table department = SQL.table("department");
+
+			Select select = Select.builder().select(employee.column("id"), department.column("name")) //
+					.from(employee) //
+					.leftOuterJoin(department).on(employee.column("department_id")).equals(department.column("id")) //
+					.build();
+
+			assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee "
+					+ "LEFT OUTER JOIN department ON employee.department_id = department.id");
+		}
+
+		@Test // GH-1421
+		void shouldRenderFullOuterJoin() {
+
+			Table employee = SQL.table("employee");
+			Table department = SQL.table("department");
+
+			Select select = Select.builder().select(employee.column("id"), department.column("name")) //
+					.from(employee) //
+					.join(department, Join.JoinType.FULL_OUTER_JOIN).on(employee.column("department_id"))
+					.equals(department.column("id")) //
+					.build();
+
+			assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee "
+					+ "FULL OUTER JOIN department ON employee.department_id = department.id");
+		}
+
+		@Test // DATAJDBC-309
+		void shouldRenderSimpleJoinWithAnd() {
+
+			Table employee = SQL.table("employee");
+			Table department = SQL.table("department");
+
+			Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) //
+					.join(department).on(employee.column("department_id")).equals(department.column("id")) //
+					.and(employee.column("tenant")).equals(department.column("tenant")) //
+					.build();
+
+			assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " //
+					+ "JOIN department ON employee.department_id = department.id " //
+					+ "AND employee.tenant = department.tenant");
+		}
+
+		@Test // #995
+		void shouldRenderArbitraryJoinCondition() {
+
+			Table employee = SQL.table("employee");
+			Table department = SQL.table("department");
+
+			Select select = Select.builder() //
+					.select(employee.column("id"), department.column("name")) //
+					.from(employee) //
+					.join(department) //
+					.on(Conditions.isEqual(employee.column("department_id"), department.column("id")) //
+							.or(Conditions.isNotEqual(employee.column("tenant"), department.column("tenant")) //
+							)).build();
+
+			assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " //
+					+ "JOIN department ON employee.department_id = department.id " //
+					+ "OR employee.tenant != department.tenant");
+		}
+
+		@Test // #1009
+		void shouldRenderJoinWithJustExpression() {
+
+			Table employee = SQL.table("employee");
+			Table department = SQL.table("department");
+
+			Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) //
+					.join(department).on(Expressions.just("alpha")).equals(Expressions.just("beta")) //
+					.build();
+
+			assertThat(SqlRenderer.toString(select))
+					.isEqualTo("SELECT employee.id, department.name FROM employee " + "JOIN department ON alpha = beta");
+		}
+
+		@Test // DATAJDBC-309
+		void shouldRenderMultipleJoinWithAnd() {
+
+			Table employee = SQL.table("employee");
+			Table department = SQL.table("department");
+			Table tenant = SQL.table("tenant").as("tenant_base");
+
+			Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) //
+					.join(department).on(employee.column("department_id")).equals(department.column("id")) //
+					.and(employee.column("tenant")).equals(department.column("tenant")) //
+					.join(tenant).on(tenant.column("tenant_id")).equals(department.column("tenant")) //
+					.build();
+
+			assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " //
+					+ "JOIN department ON employee.department_id = department.id " //
+					+ "AND employee.tenant = department.tenant " //
+					+ "JOIN tenant tenant_base ON tenant_base.tenant_id = department.tenant");
+		}
+
+		@Test // GH-1003
+		void shouldRenderJoinWithInlineQuery() {
+
+			Table employee = SQL.table("employee");
+			Table department = SQL.table("department");
+
+			Select innerSelect = Select.builder()
+					.select(employee.column("id"), employee.column("department_Id"), employee.column("name")).from(employee)
+					.build();
+
+			InlineQuery one = InlineQuery.create(innerSelect, "one");
+
+			Select select = Select.builder().select(one.column("id"), department.column("name")).from(department) //
+					.join(one).on(one.column("department_id")).equals(department.column("id")) //
+					.build();
+
+			String sql = SqlRenderer.toString(select);
+
+			assertThat(sql).isEqualTo("SELECT one.id, department.name FROM department " //
+					+ "JOIN (SELECT employee.id, employee.department_Id, employee.name FROM employee) one " //
+					+ "ON one.department_id = department.id");
+		}
+
+		@Test // GH-1362
+		void shouldRenderNestedJoins() {
+
+			Table merchantCustomers = Table.create("merchants_customers");
+			Table customerDetails = Table.create("customer_details");
+
+			Select innerSelect = Select.builder().select(customerDetails.column("cd_user_id")).from(customerDetails)
+					.join(merchantCustomers)
+					.on(merchantCustomers.column("mc_user_id").isEqualTo(customerDetails.column("cd_user_id"))).build();
+
+			InlineQuery innerTable = InlineQuery.create(innerSelect, "inner");
+
+			Select select = Select.builder().select(merchantCustomers.asterisk()) //
+					.from(merchantCustomers) //
+					.join(innerTable).on(innerTable.column("i_user_id").isEqualTo(merchantCustomers.column("mc_user_id"))) //
+					.build();
+
+			String sql = SqlRenderer.toString(select);
+
+			assertThat(sql).isEqualTo("SELECT merchants_customers.* FROM merchants_customers " + //
+					"JOIN (" + //
+					"SELECT customer_details.cd_user_id " + //
+					"FROM customer_details " + //
+					"JOIN merchants_customers ON merchants_customers.mc_user_id = customer_details.cd_user_id" + //
+					") inner " + //
+					"ON inner.i_user_id = merchants_customers.mc_user_id");
+		}
+
+		@Test // GH-1003
+		void shouldRenderJoinWithTwoInlineQueries() {
+
+			Table employee = SQL.table("employee");
+			Table department = SQL.table("department");
+
+			Select innerSelectOne = Select.builder()
+					.select(employee.column("id").as("empId"), employee.column("department_Id"), employee.column("name"))
+					.from(employee).build();
+			Select innerSelectTwo = Select.builder().select(department.column("id"), department.column("name"))
+					.from(department).build();
+
+			InlineQuery one = InlineQuery.create(innerSelectOne, "one");
+			InlineQuery two = InlineQuery.create(innerSelectTwo, "two");
+
+			Select select = Select.builder().select(one.column("empId"), two.column("name")).from(one) //
+					.join(two).on(two.column("department_id")).equals(one.column("empId")) //
+					.build();
+
+			String sql = SqlRenderer.toString(select);
+			assertThat(sql).isEqualTo("SELECT one.empId, two.name FROM (" //
+					+ "SELECT employee.id AS empId, employee.department_Id, employee.name FROM employee) one " //
+					+ "JOIN (SELECT department.id, department.name FROM department) two " //
+					+ "ON two.department_id = one.empId");
+		}
+
 	}
 
 	/**
@@ -742,8 +794,8 @@ class AnalyticFunctionsTests {
 		void renderEmptyOver() {
 
 			Select select = StatementBuilder.select( //
-							AnalyticFunction.create("MAX", salary) //
-					) //
+					AnalyticFunction.create("MAX", salary) //
+			) //
 					.from(employee) //
 					.build();
 
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasFactoryUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasFactoryUnitTests.java
index 7ec6678f8b..2ed989e332 100644
--- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasFactoryUnitTests.java
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasFactoryUnitTests.java
@@ -20,11 +20,13 @@
 
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
+import org.springframework.data.annotation.Id;
 import org.springframework.data.relational.core.mapping.Column;
 import org.springframework.data.relational.core.mapping.RelationalMappingContext;
 
 /**
  * Unit tests for the {@link AliasFactory}.
+ * 
  * @author Jens Schauder
  */
 class AliasFactoryUnitTests {
@@ -55,8 +57,8 @@ void aliasSimpleProperty() {
 		@Test // GH-1446
 		void nameGetsSanitized() {
 
-			String alias = aliasFactory.getColumnAlias(
-					context.getAggregatePath( context.getPersistentPropertyPath("evil", DummyEntity.class)));
+			String alias = aliasFactory
+					.getColumnAlias(context.getAggregatePath(context.getPersistentPropertyPath("evil", DummyEntity.class)));
 
 			assertThat(alias).isEqualTo("c_ameannamecontains3illegal_characters_1");
 		}
@@ -64,10 +66,10 @@ void nameGetsSanitized() {
 		@Test // GH-1446
 		void aliasIsStable() {
 
-			String alias1 = aliasFactory.getColumnAlias(
-					context.getAggregatePath( context.getRequiredPersistentEntity(DummyEntity.class)));
-			String alias2 = aliasFactory.getColumnAlias(
-					context.getAggregatePath( context.getRequiredPersistentEntity(DummyEntity.class)));
+			String alias1 = aliasFactory
+					.getColumnAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
+			String alias2 = aliasFactory
+					.getColumnAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
 
 			assertThat(alias1).isEqualTo(alias2);
 		}
@@ -79,10 +81,10 @@ class RnAlias {
 		@Test // GH-1446
 		void aliasIsStable() {
 
-			String alias1 = aliasFactory.getRowNumberAlias(
-					context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
-			String alias2 = aliasFactory.getRowNumberAlias(
-					context.getAggregatePath( context.getRequiredPersistentEntity(DummyEntity.class)));
+			String alias1 = aliasFactory
+					.getRowNumberAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
+			String alias2 = aliasFactory
+					.getRowNumberAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
 
 			assertThat(alias1).isEqualTo(alias2);
 		}
@@ -90,11 +92,11 @@ void aliasIsStable() {
 		@Test // GH-1446
 		void aliasProjectsOnTableReferencingPath() {
 
-			String alias1 = aliasFactory.getRowNumberAlias(
-					context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
+			String alias1 = aliasFactory
+					.getRowNumberAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
 
-			String alias2 = aliasFactory.getRowNumberAlias(
-					context.getAggregatePath(context.getPersistentPropertyPath("evil", DummyEntity.class)));
+			String alias2 = aliasFactory
+					.getRowNumberAlias(context.getAggregatePath(context.getPersistentPropertyPath("evil", DummyEntity.class)));
 
 			assertThat(alias1).isEqualTo(alias2);
 		}
@@ -102,10 +104,10 @@ void aliasProjectsOnTableReferencingPath() {
 		@Test // GH-1446
 		void rnAliasIsIndependentOfTableAlias() {
 
-			String alias1 = aliasFactory.getRowNumberAlias(
-					context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
-			String alias2 = aliasFactory.getColumnAlias(
-					context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
+			String alias1 = aliasFactory
+					.getRowNumberAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
+			String alias2 = aliasFactory
+					.getColumnAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
 
 			assertThat(alias1).isNotEqualTo(alias2);
 		}
@@ -117,8 +119,8 @@ class BackReferenceAlias {
 		@Test // GH-1446
 		void testBackReferenceAlias() {
 
-			String alias = aliasFactory.getBackReferenceAlias(
-					context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class)));
+			String alias = aliasFactory
+					.getBackReferenceAlias(context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class)));
 
 			assertThat(alias).isEqualTo("br_dummy_entity_1");
 		}
@@ -129,8 +131,8 @@ class KeyAlias {
 		@Test // GH-1446
 		void testKeyAlias() {
 
-			String alias = aliasFactory.getKeyAlias(
-					context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class)));
+			String alias = aliasFactory
+					.getKeyAlias(context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class)));
 
 			assertThat(alias).isEqualTo("key_dummy_entity_1");
 		}
@@ -141,11 +143,11 @@ class TableAlias {
 		@Test // GH-1448
 		void tableAliasIsDifferentForDifferentPathsToSameEntity() {
 
-			String alias = aliasFactory.getTableAlias(
-					context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class)));
+			String alias = aliasFactory
+					.getTableAlias(context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class)));
 
-			String alias2 = aliasFactory.getTableAlias(
-					context.getAggregatePath(context.getPersistentPropertyPath("dummy2", Reference.class)));
+			String alias2 = aliasFactory
+					.getTableAlias(context.getAggregatePath(context.getPersistentPropertyPath("dummy2", Reference.class)));
 
 			assertThat(alias).isNotEqualTo(alias2);
 		}
@@ -158,6 +160,7 @@ static class DummyEntity {
 	}
 
 	static class Reference {
+		@Id Long id;
 		DummyEntity dummy;
 		DummyEntity dummy2;
 	}
diff --git a/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc b/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc
index c3bba01ca0..02d4b12cfd 100644
--- a/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc
+++ b/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc
@@ -65,7 +65,8 @@ The table of the referenced entity is expected to have an additional column with
 * `Map<simple type, some entity>` is considered a qualified one-to-many relationship.
 The table of the referenced entity is expected to have two additional columns: One named based on the referencing entity for the foreign key (see <<jdbc.entity-persistence.types.backrefs>>) and one with the same name and an additional `_key` suffix for the map key.
 
-* `List<some entity>` is mapped as a  `Map<Integer, some entity>`. The same additional columns are expected and the names used can be customized in the same way.
+* `List<some entity>` is mapped as a  `Map<Integer, some entity>`.
+The same additional columns are expected and the names used can be customized in the same way.
 +
 For `List`, `Set`, and `Map` naming of the back reference can be controlled by implementing `NamingStrategy.getReverseColumnName(RelationalPersistentEntity<?> owner)` and `NamingStrategy.getKeyColumn(RelationalPersistentProperty property)`, respectively.
 Alternatively you may annotate the attribute with `@MappedCollection(idColumn="your_column_name", keyColumn="your_key_column_name")`.
@@ -106,6 +107,9 @@ Also, the type of that aggregate is encoded in a type parameter.
 All references in an aggregate result in a foreign key relationship in the opposite direction in the database.
 By default, the name of the foreign key column is the table name of the referencing entity.
 
+If the referenced id is an `@Embedded` id, the back reference consists of multiple columns, each named by a concatenation of <table-name> + `_` + <column-name>.
+E.g. the back reference to a `Person` entity, with a composite id with the properties `firstName` and `lastName` will consist of the two columns `PERSON_FIRST_NAME` and `PERSON_LAST_NAME`.
+
 Alternatively you may choose to have them named by the entity name of the referencing entity ignoring `@Table` annotations.
 You activate this behaviour by calling `setForeignKeyNaming(ForeignKeyNaming.IGNORE_RENAMING)` on the `RelationalMappingContext`.
 
diff --git a/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc b/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc
index e98d076c5d..b61072851a 100644
--- a/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc
+++ b/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc
@@ -1,7 +1,17 @@
 The `RelationalConverter` can use metadata to drive the mapping of objects to rows.
 The following annotations are available:
 
+* `@Embedded`: a property with this annotation will be mapped to the table of the parent entity, instead of a separate table.
+Allows to specify if the resulting columns should have a common prefix.
+If all columns resulting from such an entity are `null` either the annotated entity will be `null` or _empty_, i.e. all of its properties will be `null`, depending on the value of `@Embedded.onEmpty()`
+May be combined with `@Id` to form a composite id.
 * `@Id`: Applied at the field level to mark the primary key.
+It may be combined with `@Embedded` to form a composite id.
+* `@InsertOnlyProperty`: Marks a property as only to be written during insert.
+Such a property on an aggregate root will only be written once and never updated.
+Note that on a nested entity, all save operations result in an insert therefore this annotation has no effect on properties of nested entities.
+* `@MappedCollection`: Allows for configuration how a collection, or a single nested entity gets mapped. `idColumn` specifies the column used for referencing the parent entities primary key. `keyColumn` specifies the column used to store the index of a `List` or the key of a `Map`.
+* `@Sequence`: specify a database sequence for generating values for the annotated property.
 * `@Table`: Applied at the class level to indicate this class is a candidate for mapping to the database.
 You can specify the name of the table where the database is stored.
 * `@Transient`: By default, all fields are mapped to the row.
diff --git a/src/main/antora/modules/ROOT/partials/mapping.adoc b/src/main/antora/modules/ROOT/partials/mapping.adoc
index 7e864516e2..e6cacbaada 100644
--- a/src/main/antora/modules/ROOT/partials/mapping.adoc
+++ b/src/main/antora/modules/ROOT/partials/mapping.adoc
@@ -88,7 +88,6 @@ endif::[]
 You may use xref:value-expressions.adoc[Spring Data's SpEL support] to dynamically create column names.
 Once generated the names will be cached, so it is dynamic per mapping context only.
 
-
 ifdef::embedded-entities[]
 
 [[entity-persistence.embedded-entities]]
@@ -149,6 +148,50 @@ Embedded entities containing a `Collection` or a `Map` will always be considered
 Such an entity will therefore never be `null` even when using @Embedded(onEmpty = USE_NULL).
 endif::[]
 
+[[entity-persistence.embedded-ids]]
+=== Embedded Ids
+
+The identifier property can be annotated with `@Embedded` allowing to use composite ids.
+The full embedded entity is considered the id, and therefore the check for determining if an aggregate is considered a new aggregate requiring an insert or an existing one, asking for an update is based on that entity, not its elements.
+Most use cases will require a custom `BeforeConvertCallback` to set the id for new aggregate.
+
+====
+.Simple entity with composite id
+[source,java]
+----
+@Table("PERSON_WITH_COMPOSITE_ID")
+record Person( <1>
+    @Id @Embedded.Nullable Name pk, <2>
+    String nickName,
+    Integer age
+) {
+}
+
+record Name(String first, String last) {
+}
+----
+
+.Matching table for simple entity with composite id
+[source,sql]
+----
+CREATE TABLE PERSON_WITH_COMPOSITE_ID (
+    FIRST VARCHAR(100),
+    LAST VARCHAR(100),
+    NICK_NAME VARCHAR(100),
+    AGE INT,
+    PRIMARY KEY (FIRST, LAST) <3>
+);
+
+
+----
+
+<1> Entities may be represented as records without any special consideration
+<2> `pk` is marked as id and embedded
+<3> the two columns from the embedded `Name` entity make up the primary key in the database.
+
+Details of table creation depends on the used database.
+====
+
 [[entity-persistence.read-only-properties]]
 == Read Only Properties