From e0bb92708cb5316016a223b3056746030836d2e2 Mon Sep 17 00:00:00 2001 From: jjh75607 Date: Sun, 16 Aug 2026 16:09:40 +0900 Subject: [PATCH] Fix duplicate query counting when a DataSource bean delegates to another one --- ...ngBoot1DataSourceDelegationJunit4Test.java | 64 +++++++++++++ ...QueryCountingWithDelegatingDataSource.java | 84 +++++++++++++++++ ...eryCountingWithIndependentDataSources.java | 90 +++++++++++++++++++ ...ngBoot2DataSourceDelegationJunit4Test.java | 64 +++++++++++++ ...QueryCountingWithDelegatingDataSource.java | 84 +++++++++++++++++ ...eryCountingWithIndependentDataSources.java | 90 +++++++++++++++++++ ...ngBoot3DataSourceDelegationJunit5Test.java | 67 ++++++++++++++ ...QueryCountingWithDelegatingDataSource.java | 80 +++++++++++++++++ ...eryCountingWithIndependentDataSources.java | 89 ++++++++++++++++++ .../sql/QuickPerfProxiedDataSource.java | 17 ++++ .../sql/QuickPerfProxyBeanPostProcessor.java | 41 ++++++++- .../sql/QuickPerfProxiedDataSource.java | 17 ++++ .../sql/QuickPerfProxyBeanPostProcessor.java | 41 ++++++++- 13 files changed, 826 insertions(+), 2 deletions(-) create mode 100644 spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot1DataSourceDelegationJunit4Test.java create mode 100644 spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java create mode 100644 spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java create mode 100644 spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot2DataSourceDelegationJunit4Test.java create mode 100644 spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java create mode 100644 spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java create mode 100644 spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot3DataSourceDelegationJunit5Test.java create mode 100644 spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java create mode 100644 spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java create mode 100644 spring/sql-spring4/src/main/java/org/quickperf/spring/sql/QuickPerfProxiedDataSource.java create mode 100644 spring/sql-spring5/src/main/java/org/quickperf/spring/sql/QuickPerfProxiedDataSource.java diff --git a/spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot1DataSourceDelegationJunit4Test.java b/spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot1DataSourceDelegationJunit4Test.java new file mode 100644 index 00000000..0fa8b0e6 --- /dev/null +++ b/spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot1DataSourceDelegationJunit4Test.java @@ -0,0 +1,64 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * Copyright 2019-2022 the original author or authors. + */ +package org.quickperf.spring.springboottest; + +import org.junit.Test; +import org.junit.experimental.results.PrintableResult; +import org.quickperf.spring.springboottest.datasourcedelegation.QueryCountingWithDelegatingDataSource; +import org.quickperf.spring.springboottest.datasourcedelegation.QueryCountingWithIndependentDataSources; + +import static org.assertj.core.api.Assertions.assertThat; + +public class SpringBoot1DataSourceDelegationJunit4Test { + + // Each test class holds one method expecting the right number of selects and one expecting a + // wrong number. A single failure carrying the recorded number means both ran as intended. + @Test + public void should_count_a_select_once_when_a_datasource_bean_delegates_to_another_datasource_bean() { + + // GIVEN + Class testClass = QueryCountingWithDelegatingDataSource.class; + + // WHEN + PrintableResult printableResult = PrintableResult.testResult(testClass); + + // THEN + assertThat(printableResult.failureCount()).isOne(); + + String testReport = printableResult.toString(); + assertThat(testReport) + .contains("You may think that <2> select statements were sent to the database") + .contains("But there is in fact <1>"); + + } + + @Test + public void should_count_the_selects_of_each_datasource_bean_when_they_do_not_delegate() { + + // GIVEN + Class testClass = QueryCountingWithIndependentDataSources.class; + + // WHEN + PrintableResult printableResult = PrintableResult.testResult(testClass); + + // THEN + assertThat(printableResult.failureCount()).isOne(); + + String testReport = printableResult.toString(); + assertThat(testReport) + .contains("You may think that <3> select statements were sent to the database") + .contains("But there are in fact <2>"); + + } + +} diff --git a/spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java b/spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java new file mode 100644 index 00000000..bf7fb264 --- /dev/null +++ b/spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java @@ -0,0 +1,84 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * Copyright 2019-2022 the original author or authors. + */ +package org.quickperf.spring.springboottest.datasourcedelegation; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.quickperf.spring.junit4.QuickPerfSpringRunner; +import org.quickperf.spring.springboottest.FootballApplication; +import org.quickperf.spring.springboottest.jpa.entity.Player; +import org.quickperf.spring.springboottest.jpa.repository.PlayerRepository; +import org.quickperf.sql.annotation.ExpectSelect; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +import javax.sql.DataSource; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that a select is counted once when a DataSource bean delegates to another + * DataSource bean. + */ +@RunWith(QuickPerfSpringRunner.class) +// DataSourceAutoConfiguration is excluded because it asks for the primary DataSource while the +// delegate is being initialized, which the two beans below turn into a circular reference. +@SpringBootTest(classes = {FootballApplication.class}, + properties = "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration") +@Import(QueryCountingWithDelegatingDataSource.Config.class) +public class QueryCountingWithDelegatingDataSource { + + @Autowired + private PlayerRepository playerRepository; + + @ExpectSelect(1) + @Test + public void should_count_a_select_once_when_a_datasource_bean_delegates_to_another_one() { + List players = playerRepository.findAll(); + assertThat(players).hasSize(2); + } + + // Wrong on purpose. The failure message states how many selects were really recorded, + // which pins the count instead of only checking that nothing failed. + @ExpectSelect(2) + @Test + public void should_report_the_number_of_recorded_selects() { + playerRepository.findAll(); + } + + static class Config { + + @Bean + DataSource targetDataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.HSQL) + .generateUniqueName(true) + .build(); + } + + // LazyConnectionDataSourceProxy is a common way to end up with this setup. + @Bean + @Primary + DataSource delegatingDataSource(@Qualifier("targetDataSource") DataSource targetDataSource) { + return new LazyConnectionDataSourceProxy(targetDataSource); + } + } +} diff --git a/spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java b/spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java new file mode 100644 index 00000000..fb8a46d5 --- /dev/null +++ b/spring/junit4-spring-boot-1-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java @@ -0,0 +1,90 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * Copyright 2019-2022 the original author or authors. + */ +package org.quickperf.spring.springboottest.datasourcedelegation; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.quickperf.spring.junit4.QuickPerfSpringRunner; +import org.quickperf.spring.springboottest.FootballApplication; +import org.quickperf.sql.annotation.ExpectSelect; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +import javax.sql.DataSource; + + +/** + * Verifies that each DataSource bean is still counted when the beans do not delegate to + * each other. + */ +@RunWith(QuickPerfSpringRunner.class) +@SpringBootTest(classes = {FootballApplication.class}) +@Import(QueryCountingWithIndependentDataSources.Config.class) +public class QueryCountingWithIndependentDataSources { + + @Autowired + @Qualifier("firstDataSource") + private DataSource firstDataSource; + + @Autowired + @Qualifier("secondDataSource") + private DataSource secondDataSource; + + @ExpectSelect(2) + @Test + public void should_count_the_selects_of_each_datasource_bean_when_they_do_not_delegate() { + executeOneSelectOnEachDataSource(); + } + + // Wrong on purpose. The failure message states how many selects were really recorded, + // which pins the count instead of only checking that nothing failed. + @ExpectSelect(3) + @Test + public void should_report_the_number_of_recorded_selects() { + executeOneSelectOnEachDataSource(); + } + + private void executeOneSelectOnEachDataSource() { + String select = "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES"; + + new JdbcTemplate(firstDataSource).queryForObject(select, Integer.class); + new JdbcTemplate(secondDataSource).queryForObject(select, Integer.class); + } + + static class Config { + + @Bean + @Primary + DataSource firstDataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.HSQL) + .generateUniqueName(true) + .build(); + } + + @Bean + DataSource secondDataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.HSQL) + .generateUniqueName(true) + .build(); + } + } +} diff --git a/spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot2DataSourceDelegationJunit4Test.java b/spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot2DataSourceDelegationJunit4Test.java new file mode 100644 index 00000000..33068062 --- /dev/null +++ b/spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot2DataSourceDelegationJunit4Test.java @@ -0,0 +1,64 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * Copyright 2019-2022 the original author or authors. + */ +package org.quickperf.spring.springboottest; + +import org.junit.Test; +import org.junit.experimental.results.PrintableResult; +import org.quickperf.spring.springboottest.datasourcedelegation.QueryCountingWithDelegatingDataSource; +import org.quickperf.spring.springboottest.datasourcedelegation.QueryCountingWithIndependentDataSources; + +import static org.assertj.core.api.Assertions.assertThat; + +public class SpringBoot2DataSourceDelegationJunit4Test { + + // Each test class holds one method expecting the right number of selects and one expecting a + // wrong number. A single failure carrying the recorded number means both ran as intended. + @Test + public void should_count_a_select_once_when_a_datasource_bean_delegates_to_another_datasource_bean() { + + // GIVEN + Class testClass = QueryCountingWithDelegatingDataSource.class; + + // WHEN + PrintableResult printableResult = PrintableResult.testResult(testClass); + + // THEN + assertThat(printableResult.failureCount()).isOne(); + + String testReport = printableResult.toString(); + assertThat(testReport) + .contains("You may think that <2> select statements were sent to the database") + .contains("But there is in fact <1>"); + + } + + @Test + public void should_count_the_selects_of_each_datasource_bean_when_they_do_not_delegate() { + + // GIVEN + Class testClass = QueryCountingWithIndependentDataSources.class; + + // WHEN + PrintableResult printableResult = PrintableResult.testResult(testClass); + + // THEN + assertThat(printableResult.failureCount()).isOne(); + + String testReport = printableResult.toString(); + assertThat(testReport) + .contains("You may think that <3> select statements were sent to the database") + .contains("But there are in fact <2>"); + + } + +} diff --git a/spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java b/spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java new file mode 100644 index 00000000..bf7fb264 --- /dev/null +++ b/spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java @@ -0,0 +1,84 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * Copyright 2019-2022 the original author or authors. + */ +package org.quickperf.spring.springboottest.datasourcedelegation; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.quickperf.spring.junit4.QuickPerfSpringRunner; +import org.quickperf.spring.springboottest.FootballApplication; +import org.quickperf.spring.springboottest.jpa.entity.Player; +import org.quickperf.spring.springboottest.jpa.repository.PlayerRepository; +import org.quickperf.sql.annotation.ExpectSelect; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +import javax.sql.DataSource; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that a select is counted once when a DataSource bean delegates to another + * DataSource bean. + */ +@RunWith(QuickPerfSpringRunner.class) +// DataSourceAutoConfiguration is excluded because it asks for the primary DataSource while the +// delegate is being initialized, which the two beans below turn into a circular reference. +@SpringBootTest(classes = {FootballApplication.class}, + properties = "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration") +@Import(QueryCountingWithDelegatingDataSource.Config.class) +public class QueryCountingWithDelegatingDataSource { + + @Autowired + private PlayerRepository playerRepository; + + @ExpectSelect(1) + @Test + public void should_count_a_select_once_when_a_datasource_bean_delegates_to_another_one() { + List players = playerRepository.findAll(); + assertThat(players).hasSize(2); + } + + // Wrong on purpose. The failure message states how many selects were really recorded, + // which pins the count instead of only checking that nothing failed. + @ExpectSelect(2) + @Test + public void should_report_the_number_of_recorded_selects() { + playerRepository.findAll(); + } + + static class Config { + + @Bean + DataSource targetDataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.HSQL) + .generateUniqueName(true) + .build(); + } + + // LazyConnectionDataSourceProxy is a common way to end up with this setup. + @Bean + @Primary + DataSource delegatingDataSource(@Qualifier("targetDataSource") DataSource targetDataSource) { + return new LazyConnectionDataSourceProxy(targetDataSource); + } + } +} diff --git a/spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java b/spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java new file mode 100644 index 00000000..fb8a46d5 --- /dev/null +++ b/spring/junit4-spring-boot-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java @@ -0,0 +1,90 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * Copyright 2019-2022 the original author or authors. + */ +package org.quickperf.spring.springboottest.datasourcedelegation; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.quickperf.spring.junit4.QuickPerfSpringRunner; +import org.quickperf.spring.springboottest.FootballApplication; +import org.quickperf.sql.annotation.ExpectSelect; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +import javax.sql.DataSource; + + +/** + * Verifies that each DataSource bean is still counted when the beans do not delegate to + * each other. + */ +@RunWith(QuickPerfSpringRunner.class) +@SpringBootTest(classes = {FootballApplication.class}) +@Import(QueryCountingWithIndependentDataSources.Config.class) +public class QueryCountingWithIndependentDataSources { + + @Autowired + @Qualifier("firstDataSource") + private DataSource firstDataSource; + + @Autowired + @Qualifier("secondDataSource") + private DataSource secondDataSource; + + @ExpectSelect(2) + @Test + public void should_count_the_selects_of_each_datasource_bean_when_they_do_not_delegate() { + executeOneSelectOnEachDataSource(); + } + + // Wrong on purpose. The failure message states how many selects were really recorded, + // which pins the count instead of only checking that nothing failed. + @ExpectSelect(3) + @Test + public void should_report_the_number_of_recorded_selects() { + executeOneSelectOnEachDataSource(); + } + + private void executeOneSelectOnEachDataSource() { + String select = "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES"; + + new JdbcTemplate(firstDataSource).queryForObject(select, Integer.class); + new JdbcTemplate(secondDataSource).queryForObject(select, Integer.class); + } + + static class Config { + + @Bean + @Primary + DataSource firstDataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.HSQL) + .generateUniqueName(true) + .build(); + } + + @Bean + DataSource secondDataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.HSQL) + .generateUniqueName(true) + .build(); + } + } +} diff --git a/spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot3DataSourceDelegationJunit5Test.java b/spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot3DataSourceDelegationJunit5Test.java new file mode 100644 index 00000000..c5c4354f --- /dev/null +++ b/spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/SpringBoot3DataSourceDelegationJunit5Test.java @@ -0,0 +1,67 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * Copyright 2019-2022 the original author or authors. + */ +package org.quickperf.spring.springboottest; + +import org.junit.jupiter.api.Test; +import org.quickperf.junit5.JUnit5Tests; +import org.quickperf.junit5.JUnit5Tests.JUnit5TestsResult; +import org.quickperf.spring.springboottest.datasourcedelegation.QueryCountingWithDelegatingDataSource; +import org.quickperf.spring.springboottest.datasourcedelegation.QueryCountingWithIndependentDataSources; + +import static org.assertj.core.api.Assertions.assertThat; + +class SpringBoot3DataSourceDelegationJunit5Test { + + // Each test class holds one method expecting the right number of selects and one expecting a + // wrong number. A single failure carrying the recorded number means both ran as intended. + @Test + void should_count_a_select_once_when_a_datasource_bean_delegates_to_another_datasource_bean() { + + // GIVEN + Class testClass = QueryCountingWithDelegatingDataSource.class; + JUnit5Tests jUnit5Tests = JUnit5Tests.createInstance(testClass); + + // WHEN + JUnit5TestsResult jUnit5TestsResult = jUnit5Tests.run(); + + // THEN + assertThat(jUnit5TestsResult.getNumberOfFailures()).isOne(); + + String errorReport = jUnit5TestsResult.getErrorReport(); + assertThat(errorReport) + .contains("You may think that <2> select statements were sent to the database") + .contains("But there is in fact <1>"); + + } + + @Test + void should_count_the_selects_of_each_datasource_bean_when_they_do_not_delegate() { + + // GIVEN + Class testClass = QueryCountingWithIndependentDataSources.class; + JUnit5Tests jUnit5Tests = JUnit5Tests.createInstance(testClass); + + // WHEN + JUnit5TestsResult jUnit5TestsResult = jUnit5Tests.run(); + + // THEN + assertThat(jUnit5TestsResult.getNumberOfFailures()).isOne(); + + String errorReport = jUnit5TestsResult.getErrorReport(); + assertThat(errorReport) + .contains("You may think that <3> select statements were sent to the database") + .contains("But there are in fact <2>"); + + } + +} diff --git a/spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java b/spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java new file mode 100644 index 00000000..dd93bb91 --- /dev/null +++ b/spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithDelegatingDataSource.java @@ -0,0 +1,80 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * Copyright 2019-2022 the original author or authors. + */ +package org.quickperf.spring.springboottest.datasourcedelegation; + +import org.junit.jupiter.api.Test; +import org.quickperf.junit5.QuickPerfTest; +import org.quickperf.spring.springboottest.FootballApplication; +import org.quickperf.spring.springboottest.jpa.entity.Player; +import org.quickperf.spring.springboottest.jpa.repository.PlayerRepository; +import org.quickperf.sql.annotation.ExpectSelect; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +import javax.sql.DataSource; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that a select is counted once when a DataSource bean delegates to another + * DataSource bean. + */ +@QuickPerfTest +@SpringBootTest(classes = {FootballApplication.class}) +@Import(QueryCountingWithDelegatingDataSource.Config.class) +public class QueryCountingWithDelegatingDataSource { + + @Autowired + private PlayerRepository playerRepository; + + @ExpectSelect(1) + @Test + void should_count_a_select_once_when_a_datasource_bean_delegates_to_another_one() { + List players = playerRepository.findAll(); + assertThat(players).hasSize(2); + } + + // Wrong on purpose. The failure message states how many selects were really recorded, + // which pins the count instead of only checking that nothing failed. + @ExpectSelect(2) + @Test + void should_report_the_number_of_recorded_selects() { + playerRepository.findAll(); + } + + static class Config { + + @Bean + DataSource targetDataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .generateUniqueName(true) + .build(); + } + + // LazyConnectionDataSourceProxy is a common way to end up with this setup. + @Bean + @Primary + DataSource delegatingDataSource(@Qualifier("targetDataSource") DataSource targetDataSource) { + return new LazyConnectionDataSourceProxy(targetDataSource); + } + } +} diff --git a/spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java b/spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java new file mode 100644 index 00000000..9a1a2928 --- /dev/null +++ b/spring/junit5-spring-boot-3-test/src/test/java/org/quickperf/spring/springboottest/datasourcedelegation/QueryCountingWithIndependentDataSources.java @@ -0,0 +1,89 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * Copyright 2019-2022 the original author or authors. + */ +package org.quickperf.spring.springboottest.datasourcedelegation; + +import org.junit.jupiter.api.Test; +import org.quickperf.junit5.QuickPerfTest; +import org.quickperf.spring.springboottest.FootballApplication; +import org.quickperf.sql.annotation.ExpectSelect; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +import javax.sql.DataSource; + + +/** + * Verifies that each DataSource bean is still counted when the beans do not delegate to + * each other. + */ +@QuickPerfTest +@SpringBootTest(classes = {FootballApplication.class}) +@Import(QueryCountingWithIndependentDataSources.Config.class) +public class QueryCountingWithIndependentDataSources { + + @Autowired + @Qualifier("firstDataSource") + private DataSource firstDataSource; + + @Autowired + @Qualifier("secondDataSource") + private DataSource secondDataSource; + + @ExpectSelect(2) + @Test + void should_count_the_selects_of_each_datasource_bean_when_they_do_not_delegate() { + executeOneSelectOnEachDataSource(); + } + + // Wrong on purpose. The failure message states how many selects were really recorded, + // which pins the count instead of only checking that nothing failed. + @ExpectSelect(3) + @Test + void should_report_the_number_of_recorded_selects() { + executeOneSelectOnEachDataSource(); + } + + private void executeOneSelectOnEachDataSource() { + String select = "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES"; + + new JdbcTemplate(firstDataSource).queryForObject(select, Integer.class); + new JdbcTemplate(secondDataSource).queryForObject(select, Integer.class); + } + + static class Config { + + @Bean + @Primary + DataSource firstDataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .generateUniqueName(true) + .build(); + } + + @Bean + DataSource secondDataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .generateUniqueName(true) + .build(); + } + } +} diff --git a/spring/sql-spring4/src/main/java/org/quickperf/spring/sql/QuickPerfProxiedDataSource.java b/spring/sql-spring4/src/main/java/org/quickperf/spring/sql/QuickPerfProxiedDataSource.java new file mode 100644 index 00000000..ba9073c3 --- /dev/null +++ b/spring/sql-spring4/src/main/java/org/quickperf/spring/sql/QuickPerfProxiedDataSource.java @@ -0,0 +1,17 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * Copyright 2019-2022 the original author or authors. + */ +package org.quickperf.spring.sql; + +// Marker interface carried by every DataSource proxy built by QuickPerfProxyBeanPostProcessor. +public interface QuickPerfProxiedDataSource { +} diff --git a/spring/sql-spring4/src/main/java/org/quickperf/spring/sql/QuickPerfProxyBeanPostProcessor.java b/spring/sql-spring4/src/main/java/org/quickperf/spring/sql/QuickPerfProxyBeanPostProcessor.java index 190f853f..c3f80889 100644 --- a/spring/sql-spring4/src/main/java/org/quickperf/spring/sql/QuickPerfProxyBeanPostProcessor.java +++ b/spring/sql-spring4/src/main/java/org/quickperf/spring/sql/QuickPerfProxyBeanPostProcessor.java @@ -33,6 +33,9 @@ // bean post-processors first. public class QuickPerfProxyBeanPostProcessor implements BeanPostProcessor, PriorityOrdered { + // Bounds the walk so that DataSource beans pointing at each other cannot loop forever. + private static final int MAX_DELEGATION_DEPTH = 16; + @Override public Object postProcessBeforeInitialization(Object bean, String beanName) { return bean; @@ -40,15 +43,51 @@ public Object postProcessBeforeInitialization(Object bean, String beanName) { @Override public Object postProcessAfterInitialization(Object bean, String beanName) { - if (bean instanceof DataSource && !ScopedProxyUtils.isScopedTarget(beanName)) { + if (bean instanceof DataSource + && !ScopedProxyUtils.isScopedTarget(beanName) + && !isAlreadyProxied((DataSource) bean)) { final ProxyFactory factory = new ProxyFactory(bean); factory.setProxyTargetClass(true); + factory.addInterface(QuickPerfProxiedDataSource.class); factory.addAdvice(new ProxyDataSourceInterceptor((DataSource) bean)); return factory.getProxy(); } return bean; } + // A delegate is proxied before the bean delegating to it is created, so proxying both would + // record every query twice. Skipping the delegating bean still records the queries: they go + // through the proxied delegate. + private boolean isAlreadyProxied(DataSource dataSource) { + DataSource currentDataSource = dataSource; + for (int depth = 0; currentDataSource != null && depth < MAX_DELEGATION_DEPTH; depth++) { + if (currentDataSource instanceof QuickPerfProxiedDataSource) { + return true; + } + currentDataSource = targetDataSourceOf(currentDataSource); + } + return false; + } + + // The accessor is looked up rather than reached through DelegatingDataSource because + // spring-jdbc is not a dependency of this module. Calling it must never break the + // application context, so a failure falls back to proxying the bean. + private DataSource targetDataSourceOf(DataSource dataSource) { + Method targetDataSourceMethod = ReflectionUtils.findMethod(dataSource.getClass(), "getTargetDataSource"); + if (targetDataSourceMethod == null + || !DataSource.class.isAssignableFrom(targetDataSourceMethod.getReturnType())) { + return null; + } + try { + ReflectionUtils.makeAccessible(targetDataSourceMethod); + Object targetDataSource = ReflectionUtils.invokeMethod(targetDataSourceMethod, dataSource); + return targetDataSource instanceof DataSource ? (DataSource) targetDataSource : null; + } + catch (RuntimeException e) { + return null; + } + } + @Override public int getOrder() { return PriorityOrdered.LOWEST_PRECEDENCE - 1; diff --git a/spring/sql-spring5/src/main/java/org/quickperf/spring/sql/QuickPerfProxiedDataSource.java b/spring/sql-spring5/src/main/java/org/quickperf/spring/sql/QuickPerfProxiedDataSource.java new file mode 100644 index 00000000..ba9073c3 --- /dev/null +++ b/spring/sql-spring5/src/main/java/org/quickperf/spring/sql/QuickPerfProxiedDataSource.java @@ -0,0 +1,17 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * Copyright 2019-2022 the original author or authors. + */ +package org.quickperf.spring.sql; + +// Marker interface carried by every DataSource proxy built by QuickPerfProxyBeanPostProcessor. +public interface QuickPerfProxiedDataSource { +} diff --git a/spring/sql-spring5/src/main/java/org/quickperf/spring/sql/QuickPerfProxyBeanPostProcessor.java b/spring/sql-spring5/src/main/java/org/quickperf/spring/sql/QuickPerfProxyBeanPostProcessor.java index 9d9dfd97..d0516d4a 100644 --- a/spring/sql-spring5/src/main/java/org/quickperf/spring/sql/QuickPerfProxyBeanPostProcessor.java +++ b/spring/sql-spring5/src/main/java/org/quickperf/spring/sql/QuickPerfProxyBeanPostProcessor.java @@ -32,6 +32,9 @@ // bean post-processors first. public class QuickPerfProxyBeanPostProcessor implements BeanPostProcessor, PriorityOrdered { + // Bounds the walk so that DataSource beans pointing at each other cannot loop forever. + private static final int MAX_DELEGATION_DEPTH = 16; + @Override public Object postProcessBeforeInitialization(Object bean, String beanName) { return bean; @@ -39,15 +42,51 @@ public Object postProcessBeforeInitialization(Object bean, String beanName) { @Override public Object postProcessAfterInitialization(Object bean, String beanName) { - if (bean instanceof DataSource && !ScopedProxyUtils.isScopedTarget(beanName)) { + if (bean instanceof DataSource + && !ScopedProxyUtils.isScopedTarget(beanName) + && !isAlreadyProxied((DataSource) bean)) { final ProxyFactory factory = new ProxyFactory(bean); factory.setProxyTargetClass(true); + factory.addInterface(QuickPerfProxiedDataSource.class); factory.addAdvice(new ProxyDataSourceInterceptor((DataSource) bean)); return factory.getProxy(); } return bean; } + // A delegate is proxied before the bean delegating to it is created, so proxying both would + // record every query twice. Skipping the delegating bean still records the queries: they go + // through the proxied delegate. + private boolean isAlreadyProxied(DataSource dataSource) { + DataSource currentDataSource = dataSource; + for (int depth = 0; currentDataSource != null && depth < MAX_DELEGATION_DEPTH; depth++) { + if (currentDataSource instanceof QuickPerfProxiedDataSource) { + return true; + } + currentDataSource = targetDataSourceOf(currentDataSource); + } + return false; + } + + // The accessor is looked up rather than reached through DelegatingDataSource because + // spring-jdbc is not a dependency of this module. Calling it must never break the + // application context, so a failure falls back to proxying the bean. + private DataSource targetDataSourceOf(DataSource dataSource) { + Method targetDataSourceMethod = ReflectionUtils.findMethod(dataSource.getClass(), "getTargetDataSource"); + if (targetDataSourceMethod == null + || !DataSource.class.isAssignableFrom(targetDataSourceMethod.getReturnType())) { + return null; + } + try { + ReflectionUtils.makeAccessible(targetDataSourceMethod); + Object targetDataSource = ReflectionUtils.invokeMethod(targetDataSourceMethod, dataSource); + return targetDataSource instanceof DataSource ? (DataSource) targetDataSource : null; + } + catch (RuntimeException e) { + return null; + } + } + @Override public int getOrder() { return PriorityOrdered.LOWEST_PRECEDENCE - 1;