From b7c0249682fb08f61b8224da49801b67ecbcbe4e Mon Sep 17 00:00:00 2001 From: JoseLion Date: Sun, 18 Feb 2024 13:39:34 -0500 Subject: [PATCH] feat(core): Add one-to-one relationships --- build.gradle | 28 +- buildscript-gradle.lockfile | 23 ++ config/checkstyle/checkstyle.xml | 4 + config/checkstyle/suppressions.xml | 5 + config/checkstyle/xpath-suppressions.xml | 24 ++ gradle.lockfile | 130 +++++++-- settings.gradle | 14 + .../springr2dbcrelationships/Library.java | 16 -- .../RelationalCallbacks.java | 252 ++++++++++++++++ .../annotations/OneToOne.java | 63 ++++ .../annotations/ProjectionOf.java | 33 +++ .../exceptions/ReflectException.java | 38 +++ .../exceptions/RelationshipException.java | 35 +++ .../helpers/Commons.java | 87 ++++++ .../helpers/Reflect.java | 272 ++++++++++++++++++ .../springr2dbcrelationships/LibraryTest.java | 14 - .../RelationalCallbacksTest.java | 166 +++++++++++ .../helpers/CommonTest.java | 93 ++++++ .../helpers/ReflectTest.java | 243 ++++++++++++++++ .../testing/annotations/IntegrationTest.java | 21 ++ .../testing/annotations/UnitTest.java | 17 ++ .../FixtureApplication.java | 14 + .../models/phone/Phone.java | 22 ++ .../models/phone/PhoneRepository.java | 9 + .../models/phone/details/PhoneDetails.java | 30 ++ .../phone/details/PhoneDetailsRepository.java | 9 + src/testFixtures/resources/application.yml | 5 + src/testFixtures/resources/schema.sql | 12 + 28 files changed, 1625 insertions(+), 54 deletions(-) create mode 100644 config/checkstyle/xpath-suppressions.xml delete mode 100644 src/main/java/io/github/joselion/springr2dbcrelationships/Library.java create mode 100644 src/main/java/io/github/joselion/springr2dbcrelationships/RelationalCallbacks.java create mode 100644 src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToOne.java create mode 100644 src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ProjectionOf.java create mode 100644 src/main/java/io/github/joselion/springr2dbcrelationships/exceptions/ReflectException.java create mode 100644 src/main/java/io/github/joselion/springr2dbcrelationships/exceptions/RelationshipException.java create mode 100644 src/main/java/io/github/joselion/springr2dbcrelationships/helpers/Commons.java create mode 100644 src/main/java/io/github/joselion/springr2dbcrelationships/helpers/Reflect.java delete mode 100644 src/test/java/io/github/joselion/springr2dbcrelationships/LibraryTest.java create mode 100644 src/test/java/io/github/joselion/springr2dbcrelationships/RelationalCallbacksTest.java create mode 100644 src/test/java/io/github/joselion/springr2dbcrelationships/helpers/CommonTest.java create mode 100644 src/test/java/io/github/joselion/springr2dbcrelationships/helpers/ReflectTest.java create mode 100644 src/test/java/io/github/joselion/testing/annotations/IntegrationTest.java create mode 100644 src/test/java/io/github/joselion/testing/annotations/UnitTest.java create mode 100644 src/testFixtures/java/io/github/joselion/springr2dbcrelationships/FixtureApplication.java create mode 100644 src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/Phone.java create mode 100644 src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/PhoneRepository.java create mode 100644 src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/details/PhoneDetails.java create mode 100644 src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/details/PhoneDetailsRepository.java create mode 100644 src/testFixtures/resources/application.yml create mode 100644 src/testFixtures/resources/schema.sql diff --git a/build.gradle b/build.gradle index be21f52..eda9240 100644 --- a/build.gradle +++ b/build.gradle @@ -7,9 +7,12 @@ buildscript { plugins { id('checkstyle') id('java-library') + id('java-test-fixtures') alias(libs.plugins.prettyJupiter) alias(libs.plugins.sonarlint) + alias(libs.plugins.spring.boot) + alias(libs.plugins.spring.management) alias(libs.plugins.strictNullCheck) } @@ -49,6 +52,10 @@ jar { } } +bootJar { + enabled = false +} + checkstyle { setToolVersion(libs.versions.checkstyle.get()) setMaxWarnings(0) @@ -72,7 +79,7 @@ sonarLint { } strictNullCheck { - addEclipse() + addEclipse('2.2.800') packageInfo { useEclipse() javadoc = '@author Jose Luis Leon' @@ -97,7 +104,18 @@ repositories { dependencies { annotationProcessor(libs.lombok) compileOnly(libs.lombok) + compileOnly(libs.spring.data.r2dbc) + compileOnly(libs.spring.webflux) sonarlintCorePlugins(libs.sonarlint.java) + + implementation(libs.maybe) + + testFixturesAnnotationProcessor(libs.lombok) + testFixturesCompileOnly(libs.lombok) + testFixturesImplementation(libs.spring.boot.r2dbc) + testFixturesImplementation(libs.spring.boot.webflux) + testFixturesRuntimeOnly(libs.h2) + testFixturesRuntimeOnly(libs.h2.r2dbc) } testing { @@ -108,8 +126,16 @@ testing { dependencies { annotationProcessor(libs.lombok) compileOnly(libs.lombok) + runtimeOnly(libs.h2) + runtimeOnly(libs.h2.r2dbc) implementation(libs.assertj.core) + implementation(libs.mockito.core) + implementation(libs.reactor.extra) + implementation(libs.reactor.test) + implementation(libs.spring.boot.r2dbc) + implementation(libs.spring.boot.test) + implementation(libs.spring.boot.webflux) } } } diff --git a/buildscript-gradle.lockfile b/buildscript-gradle.lockfile index b5de549..bdcfc94 100644 --- a/buildscript-gradle.lockfile +++ b/buildscript-gradle.lockfile @@ -1,11 +1,34 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. +com.fasterxml.jackson.core:jackson-annotations:2.14.2=classpath +com.fasterxml.jackson.core:jackson-core:2.14.2=classpath +com.fasterxml.jackson.core:jackson-databind:2.14.2=classpath +com.fasterxml.jackson.module:jackson-module-parameter-names:2.14.2=classpath +com.fasterxml.jackson:jackson-bom:2.14.2=classpath +com.google.code.findbugs:jsr305:3.0.2=classpath io.github.joselion.pretty-jupiter:io.github.joselion.pretty-jupiter.gradle.plugin:3.2.0=classpath io.github.joselion.strict-null-check:io.github.joselion.strict-null-check.gradle.plugin:3.3.0=classpath io.github.joselion:maybe:3.5.0=classpath io.github.joselion:pretty-jupiter:3.2.0=classpath io.github.joselion:strict-null-check:3.3.0=classpath +io.spring.dependency-management:io.spring.dependency-management.gradle.plugin:1.1.4=classpath +io.spring.gradle:dependency-management-plugin:1.1.4=classpath name.remal.gradle-plugins.sonarlint:sonarlint:3.4.6=classpath name.remal.sonarlint:name.remal.sonarlint.gradle.plugin:3.4.6=classpath +net.java.dev.jna:jna-platform:5.13.0=classpath +net.java.dev.jna:jna:5.13.0=classpath +org.antlr:antlr4-runtime:4.7.2=classpath +org.apache.commons:commons-compress:1.23.0=classpath +org.apache.httpcomponents.client5:httpclient5:5.2.3=classpath +org.apache.httpcomponents.core5:httpcore5-h2:5.2.4=classpath +org.apache.httpcomponents.core5:httpcore5:5.2.4=classpath +org.slf4j:slf4j-api:1.7.36=classpath +org.springframework.boot:org.springframework.boot.gradle.plugin:3.2.2=classpath +org.springframework.boot:spring-boot-buildpack-platform:3.2.2=classpath +org.springframework.boot:spring-boot-gradle-plugin:3.2.2=classpath +org.springframework.boot:spring-boot-loader-tools:3.2.2=classpath +org.springframework:spring-core:6.0.10=classpath +org.springframework:spring-jcl:6.0.10=classpath +org.tomlj:tomlj:1.0.0=classpath empty= diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index d2a86f5..674d991 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -113,6 +113,10 @@ + + + + diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml index d337e48..ffb79e1 100644 --- a/config/checkstyle/suppressions.xml +++ b/config/checkstyle/suppressions.xml @@ -15,4 +15,9 @@ + + + + + diff --git a/config/checkstyle/xpath-suppressions.xml b/config/checkstyle/xpath-suppressions.xml new file mode 100644 index 0000000..2949ff0 --- /dev/null +++ b/config/checkstyle/xpath-suppressions.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/gradle.lockfile b/gradle.lockfile index 9a5e893..7384c55 100644 --- a/gradle.lockfile +++ b/gradle.lockfile @@ -1,65 +1,123 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. +ch.qos.logback:logback-classic:1.4.14=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.4.14=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.15.3=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.15.3=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.15.3=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.15.3=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.3=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.module:jackson-module-parameter-names:2.15.3=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.15.3=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath com.fasterxml.staxmate:staxmate:2.4.0=sonarlintCoreClasspath com.fasterxml.woodstox:woodstox-core:6.4.0=sonarlintCoreClasspath +com.github.jsqlparser:jsqlparser:4.6=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath com.google.code.findbugs:jsr305:3.0.2=checkstyle,sonarlintCoreClasspath -com.google.code.gson:gson:2.8.9=sonarlintCoreClasspath +com.google.code.gson:gson:2.10.1=sonarlintCoreClasspath com.google.errorprone:error_prone_annotations:2.23.0=checkstyle com.google.guava:failureaccess:1.0.2=checkstyle com.google.guava:guava:33.0.0-jre=checkstyle com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=checkstyle +com.h2database:h2:2.2.224=testFixturesRuntimeClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.8.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:10.13.0=checkstyle +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.9.4=checkstyle -commons-codec:commons-codec:1.15=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.11.0=sonarlintCoreClasspath info.picocli:picocli:4.7.5=checkstyle +io.github.joselion:maybe:4.2.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.12.2=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.12.2=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-dns:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http2:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-socks:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-common:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-handler-proxy:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-handler:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns-classes-macos:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns-native-macos:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-epoll:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.projectreactor.addons:reactor-extra:3.5.1=testCompileClasspath,testRuntimeClasspath +io.projectreactor.addons:reactor-pool:1.0.5=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.projectreactor.netty:reactor-netty-core:1.1.15=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.projectreactor.netty:reactor-netty-http:1.1.15=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.6.2=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.projectreactor:reactor-test:3.6.2=testCompileClasspath,testRuntimeClasspath +io.r2dbc:r2dbc-h2:1.0.0.RELEASE=testFixturesRuntimeClasspath,testRuntimeClasspath +io.r2dbc:r2dbc-pool:1.0.1.RELEASE=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.r2dbc:r2dbc-spi:1.0.0.RELEASE=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.2=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:2.1.1=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath jakarta.el:jakarta.el-api:4.0.0=sonarlintCoreClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.1=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.14.11=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.14.11=testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.5.0=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.5.0=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.4=checkstyle org.antlr:antlr4-runtime:4.13.1=checkstyle org.apache.commons:commons-compress:1.21=sonarlintCoreClasspath -org.apache.commons:commons-lang3:3.12.0=sonarlintCoreClasspath -org.apache.commons:commons-lang3:3.8.1=checkstyle +org.apache.commons:commons-lang3:3.13.0=checkstyle,sonarlintCoreClasspath org.apache.commons:commons-text:1.3=checkstyle -org.apache.httpcomponents.client5:httpclient5:5.1.3=checkstyle -org.apache.httpcomponents.core5:httpcore5-h2:5.1.3=checkstyle -org.apache.httpcomponents.core5:httpcore5:5.1.3=checkstyle +org.apache.httpcomponents.client5:httpclient5:5.2.3=checkstyle +org.apache.httpcomponents.core5:httpcore5-h2:5.2.4=checkstyle +org.apache.httpcomponents.core5:httpcore5:5.2.4=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle -org.apache.httpcomponents:httpcore:4.4.14=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle +org.apache.logging.log4j:log4j-api:2.21.1=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.21.1=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:9.0.84=sonarlintCoreClasspath -org.apache.tomcat.embed:tomcat-embed-el:9.0.84=sonarlintCoreClasspath -org.apache.tomcat.embed:tomcat-embed-jasper:9.0.84=sonarlintCoreClasspath -org.apache.tomcat:tomcat-annotations-api:9.0.84=sonarlintCoreClasspath +org.apache.tomcat.embed:tomcat-embed-core:10.1.18=sonarlintCoreClasspath +org.apache.tomcat.embed:tomcat-embed-el:10.1.18=sonarlintCoreClasspath +org.apache.tomcat.embed:tomcat-embed-jasper:10.1.18=sonarlintCoreClasspath +org.apache.tomcat:tomcat-annotations-api:10.1.18=sonarlintCoreClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.assertj:assertj-core:3.25.3=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.2.0=testCompileClasspath,testRuntimeClasspath org.checkerframework:checker-qual:3.42.0=checkstyle org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.codehaus.woodstox:stax2-api:4.2.1=sonarlintCoreClasspath -org.eclipse.jdt:org.eclipse.jdt.annotation:2.2.700=compileClasspath,testCompileClasspath +org.eclipse.jdt:org.eclipse.jdt.annotation:2.2.800=compileClasspath,testCompileClasspath,testFixturesCompileClasspath org.glassfish:jakarta.el:4.0.2=sonarlintCoreClasspath +org.hamcrest:hamcrest:2.2=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.junit.jupiter:junit-jupiter-api:5.10.2=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:5.10.2=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:5.10.2=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.10.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.10.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.10.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter:5.10.2=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:1.10.2=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:1.10.2=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:1.10.2=testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.10.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.10.1=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.10.1=testRuntimeClasspath org.junit:junit-bom:5.10.2=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.10.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.7.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm:9.0=sonarlintCoreClasspath -org.projectlombok:lombok:1.18.30=annotationProcessor,compileClasspath,testAnnotationProcessor,testCompileClasspath +org.ow2.asm:asm:9.3=testCompileClasspath,testRuntimeClasspath +org.projectlombok:lombok:1.18.30=annotationProcessor,compileClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle +org.skyscreamer:jsonassert:1.5.1=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.11=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.11=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.sonarsource.analyzer-commons:sonar-analyzer-commons:2.7.0.1482=sonarlintCoreClasspath org.sonarsource.analyzer-commons:sonar-analyzer-recognizers:2.7.0.1482=sonarlintCoreClasspath org.sonarsource.analyzer-commons:sonar-performance-measure:2.7.0.1482=sonarlintCoreClasspath @@ -85,10 +143,36 @@ org.sonarsource.slang:sonar-scala-plugin:1.15.0.4655=sonarlintCoreClasspath org.sonarsource.sonarlint.core:sonarlint-core:9.8.0.76914=sonarlintCore,sonarlintCoreClasspath org.sonarsource.sslr:sslr-core:1.24.0.633=sonarlintCoreClasspath org.sonarsource.xml:sonar-xml-plugin:2.10.0.4108=sonarlintCoreClasspath -org.springframework:spring-core:5.3.31=sonarlintCoreClasspath -org.springframework:spring-expression:5.3.31=sonarlintCoreClasspath +org.springframework.boot:spring-boot-autoconfigure:3.2.2=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-r2dbc:3.2.2=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:3.2.2=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:3.2.2=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-reactor-netty:3.2.2=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:3.2.2=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webflux:3.2.2=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:3.2.2=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:3.2.2=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:3.2.2=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:3.2.2=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:3.2.2=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.data:spring-data-r2dbc:3.2.2=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.data:spring-data-relational:3.2.2=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-aop:6.1.3=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-beans:6.1.3=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-context:6.1.3=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-core:6.1.3=compileClasspath,sonarlintCoreClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-expression:6.1.3=compileClasspath,sonarlintCoreClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-jcl:6.1.3=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:6.1.3=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-r2dbc:6.1.3=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-test:6.1.3=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:6.1.3=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-web:6.1.3=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-webflux:6.1.3=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.tukaani:xz:1.9=sonarlintCoreClasspath org.xmlresolver:xmlresolver:5.2.2=checkstyle +org.xmlunit:xmlunit-core:2.9.1=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.2=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath xerces:xercesImpl:2.12.2=sonarlintCoreClasspath xml-apis:xml-apis:1.4.01=sonarlintCoreClasspath -empty=runtimeClasspath +empty=developmentOnly,testAndDevelopmentOnly diff --git a/settings.gradle b/settings.gradle index 2234b8b..a42d6a3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -13,11 +13,25 @@ dependencyResolutionManagement { plugin('prettyJupiter', 'io.github.joselion.pretty-jupiter').version('3.2.0') plugin('sonarlint', 'name.remal.sonarlint').version('3.4.6') + plugin('spring-boot', 'org.springframework.boot').version('3.2.2') + plugin('spring-management', 'io.spring.dependency-management').version('1.1.4') plugin('strictNullCheck', 'io.github.joselion.strict-null-check').version('3.3.0') library('assertj-core', 'org.assertj', 'assertj-core').version('3.25.3') library('lombok', 'org.projectlombok', 'lombok').version('1.18.30') + library('maybe', 'io.github.joselion', 'maybe').version('4.2.1') + library('mockito-core', 'org.mockito', 'mockito-core').version('5.10.0') library('sonarlint-java', 'org.sonarsource.java', 'sonar-java-plugin').version('7.30.1.34514') + library('spring-data-r2dbc', 'org.springframework.data', 'spring-data-r2dbc').withoutVersion() + library('spring-webflux', 'org.springframework', 'spring-webflux').withoutVersion() + + library('h2', 'com.h2database', 'h2').withoutVersion() + library('h2-r2dbc', 'io.r2dbc', 'r2dbc-h2').withoutVersion() + library('reactor-extra', 'io.projectreactor.addons', 'reactor-extra').withoutVersion() + library('reactor-test', 'io.projectreactor', 'reactor-test').withoutVersion() + library('spring-boot-r2dbc', 'org.springframework.boot', 'spring-boot-starter-data-r2dbc').withoutVersion() + library('spring-boot-webflux', 'org.springframework.boot', 'spring-boot-starter-webflux').withoutVersion() + library('spring-boot-test', 'org.springframework.boot', 'spring-boot-starter-test').withoutVersion() } } } diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/Library.java b/src/main/java/io/github/joselion/springr2dbcrelationships/Library.java deleted file mode 100644 index 754819b..0000000 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/Library.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.github.joselion.springr2dbcrelationships; - -/** - * Auto-generetaed test class. - */ -public record Library() { - - /** - * Auto-generetaed test method. - * - * @return always true - */ - public boolean someLibraryMethod() { - return true; // NOSONAR - } -} diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/RelationalCallbacks.java b/src/main/java/io/github/joselion/springr2dbcrelationships/RelationalCallbacks.java new file mode 100644 index 0000000..20c84ac --- /dev/null +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/RelationalCallbacks.java @@ -0,0 +1,252 @@ +package io.github.joselion.springr2dbcrelationships; + +import static java.util.function.Predicate.not; +import static org.springframework.data.relational.core.query.Criteria.where; +import static org.springframework.data.relational.core.query.Query.query; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.reactivestreams.Publisher; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Lazy; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.r2dbc.mapping.OutboundRow; +import org.springframework.data.r2dbc.mapping.event.AfterConvertCallback; +import org.springframework.data.r2dbc.mapping.event.AfterSaveCallback; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.stereotype.Component; + +import io.github.joselion.springr2dbcrelationships.annotations.OneToOne; +import io.github.joselion.springr2dbcrelationships.annotations.ProjectionOf; +import io.github.joselion.springr2dbcrelationships.exceptions.RelationshipException; +import io.github.joselion.springr2dbcrelationships.helpers.Commons; +import io.github.joselion.springr2dbcrelationships.helpers.Reflect; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Spring component which registers callbacks for all entities to process + * relationship annotations. + * + * @param the entity marked as relational + * @param context the Spring application context + * @param template the R2DBC entity template + */ +@Component +public record RelationalCallbacks( + ApplicationContext context, + @Lazy R2dbcEntityTemplate template +) implements AfterConvertCallback, AfterSaveCallback { + + private static final String STACK_KEY = "relationship_access_stack"; + + @Override + public Publisher onAfterConvert(final T entity, final SqlIdentifier table) { + return Mono.just(entity) + .map(T::getClass) + .map(Class::getDeclaredFields) + .flatMapMany(Flux::fromArray) + .parallel() + .runOn(Schedulers.parallel()) + .flatMap(entityField -> + Mono.just(entityField) + .filter(field -> field.isAnnotationPresent(OneToOne.class)) + .zipWhen(field -> this.populateOneToOne(entity, field, table)) + ) + .sequential() + .reduce(entity, (acc, tuple) -> { + final var field = tuple.getT1(); + final var value = tuple.getT2(); + + return Reflect.update(acc, field, value); + }) + .defaultIfEmpty(entity) + .contextWrite(ctx -> { + final var typeName = entity.getClass().getName(); + final var next = ctx.>>getOrEmpty(STACK_KEY) + .map(prev -> Stream.concat(prev.stream(), Stream.of(typeName)).toList()) + .orElse(List.of(typeName)); + return ctx.put(STACK_KEY, next); + }); + } + + @Override + public Publisher onAfterSave(final T entity, final OutboundRow outboundRow, final SqlIdentifier table) { + return Mono.just(entity) + .map(T::getClass) + .map(Class::getDeclaredFields) + .flatMapIterable(List::of) + .parallel() + .runOn(Schedulers.parallel()) + .flatMap(entityField -> + Mono.just(entityField) + .filter(field -> field.isAnnotationPresent(OneToOne.class)) + .filter(field -> + Optional.of(OneToOne.class) + .map(field::getAnnotation) + .filter(not(OneToOne::readonly)) + .filter(not(OneToOne::backReference)) + .isPresent() + ) + .zipWhen(field -> this.persistOneToOne(entity, field, table)) + ) + .sequential() + .reduce(entity, (acc, tuple) -> { + final var field = tuple.getT1(); + final var value = tuple.getT2(); + + return Reflect.update(acc, field, value); + }) + .defaultIfEmpty(entity); + } + + private Mono populateOneToOne(final T entity, final Field field, final SqlIdentifier table) { + final var fieldType = field.getType(); + final var isBackReference = Optional.of(OneToOne.class) + .map(field::getAnnotation) + .filter(OneToOne::backReference) + .isPresent(); + final var mappedBy = Optional.of(OneToOne.class) + .map(field::getAnnotation) + .map(OneToOne::mappedBy) + .filter(not(String::isBlank)) + .orElseGet(() -> { + final var prefix = isBackReference + ? this.tableNameOf(fieldType) + : table.getReference(); + + return prefix.concat("_id"); + }); + + if (isBackReference) { + final var parentId = this.findIdColumn(fieldType); + final var mappedField = Commons.toCamelCase(mappedBy); + final var fkValue = Optional.of(entity) + .map(Reflect.getter(mappedField)) + .orElseThrow(() -> { + final var message = "Entity <%s> is missing foreign key in field: %s".formatted( + entity.getClass().getName(), + mappedField + ); + + return RelationshipException.of(message); + }); + + return this.checkCycles() + .flatMap(x -> + this.template + .select(this.domainFor(fieldType)) + .as(fieldType) + .matching(query(where(parentId).is(fkValue))) + .one() + ) + .map(Commons::cast); + } + + return this.idOrEmpty(entity) + .map(entityId -> + this.template + .select(this.domainFor(fieldType)) + .as(fieldType) + .matching(query(where(mappedBy).is(entityId))) + .one() + .map(Commons::cast) + ) + .orElseGet(Mono::empty); + } + + private Mono persistOneToOne(final T entity, final Field field, final SqlIdentifier table) { + return this.idOrEmpty(entity) + .map(entityId -> { + final var fieldType = field.getType(); + final var entityName = entity.getClass().getSimpleName(); + final var mappedBy = Optional.of(OneToOne.class) + .map(field::getAnnotation) + .map(OneToOne::mappedBy) + .filter(not(String::isBlank)) + .orElseGet(() -> table.getReference().concat("_id")); + final var fkFieldName = Commons.uncapitalize(entityName).concat("Id"); + final var initial = Reflect.getter(entity, field); + final var value = Optional.ofNullable(initial) + .map(Reflect.update(fkFieldName, entityId)) + .orElse(initial); + + return Mono.justOrEmpty(value) + .flatMap(this::upsert) + .map(Commons::cast) + .switchIfEmpty( + this.template + .delete(this.domainFor(fieldType)) + .matching(query(where(mappedBy).is(entityId))) + .all() + .then(Mono.empty()) + ); + }) + .orElseGet(Mono::empty); + } + + private Mono upsert(final S entity) { + final var type = entity.getClass(); + final var isNew = this.template + .getConverter() + .getMappingContext() + .getRequiredPersistentEntity(type) + .isNew(entity); + + return isNew + ? this.template.insert(entity) + : this.template.update(entity); + } + + private Class domainFor(final Class type) { + return Optional.of(ProjectionOf.class) + .map(type::getAnnotation) + .map(ProjectionOf::value) + .map(Commons::>cast) + .orElse(type); + } + + private String findIdColumn(final Class type) { + return this.template + .getConverter() + .getMappingContext() + .getRequiredPersistentEntity(type) + .getIdColumn() + .getReference(); + } + + private Optional idOrEmpty(final Object target) { + final var mapper = this.template.getConverter().getMappingContext(); + + return Optional.of(target) + .map(Object::getClass) + .map(mapper::getRequiredPersistentEntity) + .map(PersistentEntity::getIdProperty) + .map(RelationalPersistentProperty::getField) + .map(field -> Reflect.getter(target, field)); + } + + private String tableNameOf(final Class type) { + return this.template + .getConverter() + .getMappingContext() + .getRequiredPersistentEntity(type) + .getTableName() + .getReference(); + } + + private Mono checkCycles() { + return Mono.deferContextual(ctx -> + Mono.just(STACK_KEY) + .map(ctx::>get) + .filter(stack -> stack.size() == stack.stream().distinct().count()) + .map(List::size) + ); + } +} diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToOne.java b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToOne.java new file mode 100644 index 0000000..a2a97af --- /dev/null +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToOne.java @@ -0,0 +1,63 @@ +package io.github.joselion.springr2dbcrelationships.annotations; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.annotation.Transient; + +/** + * Marks a field to have a one-to-one relationship. + * + *

This annotation also adds the {@link Transient @Transient} and + * {@link Value @Value("null")} annotations to the field. + */ +@Transient +@Documented +@Value("null") // NOSONAR +@Retention(RUNTIME) +@Target({FIELD, PARAMETER, ANNOTATION_TYPE}) +public @interface OneToOne { + + /** + * Set to {@code true} if the annotated field is only a back reference in the + * child entity of the one-to-one relationship. This is {@code false} by + * default as the annotaton is mostly used o the parent side of the + * relationship. + * + *

Using the {@code @OneToOne} annotation in the child side instead of the + * parent is ussually just with the intention of having a back references to + * the parent. Therefore, when this property is set to {@code true} the + * {@link #readonly()} property is true {@code true} as well. + * + * @return whether he annotation is used as a back reference or not. + */ + boolean backReference() default false; + + /** + * Used to specify the name of the "foreing key" column of the child table. + * This is usually not necessary if the name of the column matches the name + * of the parent table followed by an {@code _id} suffix. + * + *

For example, given the parent table is {@code phone} and the child + * table is {@code phone_details}. By default, the annotation will look for + * the "foreign key" column {@code phone_id} in the {@code phone_details} table. + * + * @return the name of the "foreing key" column + */ + String mappedBy() default ""; + + /** + * Should the entity on the annotated field be readonly. I.e., the entity is + * never persisted. Defaults to {@code false}. + * + * @return whether the annotated entoty is readonly or not + */ + boolean readonly() default false; +} diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ProjectionOf.java b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ProjectionOf.java new file mode 100644 index 0000000..1379d15 --- /dev/null +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ProjectionOf.java @@ -0,0 +1,33 @@ +package io.github.joselion.springr2dbcrelationships.annotations; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Marks a class or record to be a projection of another entity. This features + * usually works out-of-the-box on Spring Boot, but this annotation is required + * to work with the relational annotations provided by this package. + * + * @see + * Projections - Spring R2DBC Reference + * + */ +@Inherited +@Documented +@Retention(RUNTIME) +@Target({TYPE, ANNOTATION_TYPE}) +public @interface ProjectionOf { + + /** + * The class of the entity to be projected. + * + * @return the type of the projection + */ + Class value(); +} diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/exceptions/ReflectException.java b/src/main/java/io/github/joselion/springr2dbcrelationships/exceptions/ReflectException.java new file mode 100644 index 0000000..9c6b604 --- /dev/null +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/exceptions/ReflectException.java @@ -0,0 +1,38 @@ +package io.github.joselion.springr2dbcrelationships.exceptions; + +import io.github.joselion.springr2dbcrelationships.helpers.Reflect; + +/** + * Runtime exception thrown when something goes wrong with a {@link Reflect} + * helper process. + */ +public final class ReflectException extends RuntimeException { + + private ReflectException(final String message) { + super(message); + } + + private ReflectException(final Throwable cause) { + super(cause); + } + + /** + * Creates a reflect exception with a detail message. + * + * @param message the detail message + * @return a reflect exception instance + */ + public static ReflectException of(final String message) { + return new ReflectException(message); + } + + /** + * Creates a reflect exception with the specified cause. + * + * @param cause the cause to this exception + * @return a reflect exception instance + */ + public static ReflectException of(final Throwable cause) { + return new ReflectException(cause); + } +} diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/exceptions/RelationshipException.java b/src/main/java/io/github/joselion/springr2dbcrelationships/exceptions/RelationshipException.java new file mode 100644 index 0000000..d0360af --- /dev/null +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/exceptions/RelationshipException.java @@ -0,0 +1,35 @@ +package io.github.joselion.springr2dbcrelationships.exceptions; + +/** + * Runtime exception thrown when something goes wrong on a relationship process. + */ +public final class RelationshipException extends RuntimeException { + + private RelationshipException(final String message) { + super(message); + } + + private RelationshipException(final Throwable cause) { + super(cause); + } + + /** + * Creates a new relationship exception with a detail message. + * + * @param message the detail message + * @return a relationship exception instance + */ + public static RelationshipException of(final String message) { + return new RelationshipException(message); + } + + /** + * Creates a new relationship exception with the specified cause. + * + * @param cause the cause to this exception + * @return a relationship exception instance + */ + public static RelationshipException of(final Throwable cause) { + return new RelationshipException(cause); + } +} diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/helpers/Commons.java b/src/main/java/io/github/joselion/springr2dbcrelationships/helpers/Commons.java new file mode 100644 index 0000000..7db0b6c --- /dev/null +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/helpers/Commons.java @@ -0,0 +1,87 @@ +package io.github.joselion.springr2dbcrelationships.helpers; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.joining; + +/** + * Common helpers. + */ +public final class Commons { + + private Commons() { + throw new UnsupportedOperationException("Common is a helper class"); + } + + /** + * Convenience method to cast with a generic type. + * + * @param the type to cast the target to + * @param target the target to cast + * @return a value cast to the generic type + */ + @SuppressWarnings("unchecked") + public static T cast(final Object target) { + return (T) target; + } + + /** + * Capitalizes the first character of the provided string. + * + * @param value the string to capitalize + * @return the capitalized string + */ + public static String capitalize(final String value) { + if (!value.isEmpty()) { + final var first = value.substring(0, 1); + final var rest = value.substring(1); + + return first.toUpperCase().concat(rest); + } + + return value; + } + + /** + * Uncapitalizes the first character of the provided string. + * + * @param value the string to uncapitalize + * @return the uncapitalized string + */ + public static String uncapitalize(final String value) { + if (!value.isEmpty()) { + final var first = value.substring(0, 1); + final var rest = value.substring(1); + + return first.toLowerCase().concat(rest); + } + + return value; + } + + /** + * Transforms a string to snake-case format. + * + * @param value the string to transform + * @return the text in snake-case format + */ + public static String toSnakeCase(final String value) { + return value + .replaceAll("([a-z])([A-Z]+)", "$1_$2") + .toLowerCase(); + } + + /** + * Transforms a string to camel-case format. + * + * @param value the string to transform + * @return the text in camel-case format + */ + public static String toCamelCase(final String value) { + final var words = value.split("[\\W_]+"); + final var joined = stream(words) + .map(Commons::capitalize) + .collect(joining()); + + return Commons.uncapitalize(joined); + } +} diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/helpers/Reflect.java b/src/main/java/io/github/joselion/springr2dbcrelationships/helpers/Reflect.java new file mode 100644 index 0000000..b97e42c --- /dev/null +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/helpers/Reflect.java @@ -0,0 +1,272 @@ +package io.github.joselion.springr2dbcrelationships.helpers; + +import static java.lang.invoke.LambdaMetafactory.metafactory; +import static java.lang.invoke.MethodType.methodType; + +import java.lang.invoke.CallSite; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.Nullable; + +import io.github.joselion.maybe.Maybe; +import io.github.joselion.springr2dbcrelationships.exceptions.ReflectException; + +/** + * Reflection helper class. + */ +public final class Reflect { + + private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); + + private Reflect() { + throw new UnsupportedOperationException("Reflect is a helper class"); + } + + /** + * Finds and invokes the getter of a field in the provided target. + * + * @param the return type of the getter + * @param target the target instance were the getter is invoked + * @param field the field to invoke the getter + * @return the result of invoking the getter + * @throws ReflectException if the getter method cannot be found or fails to + * be invoked + */ + @Nullable + public static T getter(final Object target, final Field field) { + final var targetType = field.getDeclaringClass(); + final var fieldType = field.getType(); + final var getterMethod = Reflect.findGetterMethod(field); + final var getterFn = Maybe + .from(() -> metafactory( + LOOKUP, + "apply", + methodType(Function.class), + methodType(Object.class, Object.class), + getterMethod, + methodType(fieldType, targetType) + )) + .map(CallSite::getTarget) + .solve(method -> method.invoke()) // NOSONAR + .solve(Commons::>cast) + .orThrow(ReflectException::of); + + return getterFn.apply(target); + } + + /** + * Curried version of {@link Reflect#getter(Object, Field)} overload. + * + * @param the return type of the getter + * @param field the field to invoke the getter + * @return a function that takes the target as parameter and returns the + * result of invoking the getter of the field + */ + public static Function getter(final Field field) { + return target -> Reflect.getter(target, field); + } + + /** + * Finds and invokes the getter of a field in the provided target. + * + * @param the return type of the getter + * @param target the target instance were the getter is invoked + * @param fieldName the name of the field to invoke the getter + * @return the result of invoking the getter + * @throws ReflectException if the getter method cannot be found or fails to + * be invoked + */ + @Nullable + public static T getter(final Object target, final String fieldName) { + final var field = Maybe.of(fieldName) + .solve(target.getClass()::getDeclaredField) + .orThrow(ReflectException::of); + + return getter(target, field); + } + + /** + * Curried version of {@link Reflect#getter(Object, String)} overload. + * + * @param the return type of the getter + * @param fieldName the name of the field to invoke the getter + * @return a function that takes the target as parameter and returns the + * result of invoking the getter of the field + */ + public static Function getter(final String fieldName) { + return target -> Reflect.getter(target, fieldName); + } + + /** + * Finds and invokes the getter of a field in the provided target. + * + * @param the return type of the getter + * @param target the target instance were the getter is invoked + * @param method the method of the getter to invoke + * @return the result of invoking the getter + * @throws ReflectException if the getter method cannot be found or fails to + * be invoked + */ + @Nullable + public static T getter(final Object target, final Method method) { + final var unprefixed = method.getName().replace("get", ""); + final var fieldName = Commons.uncapitalize(unprefixed); + + return Reflect.getter(target, fieldName); + } + + /** + * Updates a field using either a wither or a setter method. Then returns the + * target with the updated field. + * + * @param the target type + * @param target the target instance where the field is updated + * @param field the field to update + * @param value the value to update the field to + * @return the updated target + * @throws ReflectException if the wither/setter method cannot be found or + * fails to be invoked + */ + @Nullable + public static T update(final T target, final Field field, final Object value) { + final var targetType = field.getDeclaringClass(); + final var fieldType = field.getType(); + + return Reflect + .findWitherMethod(field) + .map(witherMethod -> { + final var witherFn = Maybe + .from(() -> metafactory( + LOOKUP, + "apply", + methodType(BiFunction.class), + methodType(Object.class, Object.class, Object.class), + witherMethod, + methodType(targetType, targetType, fieldType) + )) + .map(CallSite::getTarget) + .solve(method -> method.invoke()) // NOSONAR + .solve(Commons::>cast) + .orThrow(ReflectException::of); + + return witherFn.apply(target, value); + }) + .orElseGet(() -> { + final var setterMethod = Reflect + .findSetterMethod(field) + .orElseThrow(() -> ReflectException.of("Unable to find wither/setter for field: ".concat(field.getName()))); + final var setterFn = Maybe + .from(() -> metafactory( + LOOKUP, + "accept", + methodType(BiConsumer.class), + methodType(void.class, Object.class, Object.class), + setterMethod, + methodType(void.class, targetType, fieldType) + )) + .map(CallSite::getTarget) + .solve(method -> method.invoke()) // NOSONAR + .solve(Commons::>cast) + .orThrow(ReflectException::of); + + setterFn.accept(target, value); + return target; + }); + } + + /** + * Updates a field using either a wither or a setter method. Then returns the + * target with the updated field. + * + * @param the target type + * @param target the target instance where the field is updated + * @param fieldName the field name to update + * @param value the value to update the field to + * @return the updated target + * @throws ReflectException if the wither/setter method cannot be found or + * fails to be invoked + */ + @Nullable + public static T update(final T target, final String fieldName, final Object value) { + final var field = Maybe.of(fieldName) + .solve(target.getClass()::getDeclaredField) + .orThrow(ReflectException::of); + + return Reflect.update(target, field, value); + } + + /** + * Curried version of {@link Reflect#update(Object, String, Object)} overload. + * + * @param the target type + * @param fieldName the field name to update + * @param value the value to update the field to + * @return a function that takes the target as argument and returns the + * updated target + */ + public static Function update(final String fieldName, final Object value) { + return target -> Reflect.update(target, fieldName, value); + } + + /** + * Returns the first inner type of a field's type. + * + * @param the type of the generic + * @param field the field to find the inner type + * @return the inner type of a field's type + * @throws ReflectException if the field's type does not have an inner + * generic type + */ + public static Class innerTypeOf(final Field field) { + return Maybe.of(field) + .solve(Field::getGenericType) + .cast(ParameterizedType.class, (x, e) -> { + final var message = "The type of '%s' field has no parameterized types".formatted(field.getName()); + return ReflectException.of(message); + }) + .solve(ParameterizedType::getActualTypeArguments) + .solve(type -> type[0]) + .solve(Commons::>cast) + .orThrow(); + } + + private static MethodHandle findGetterMethod(final Field field) { + final var targetType = field.getDeclaringClass(); + final var fieldName = field.getName(); + final var getterName = "get".concat(Commons.capitalize(fieldName)); + final var getterType = methodType(field.getType()); + + return Maybe + .from(() -> LOOKUP.findVirtual(targetType, fieldName, getterType)) + .onErrorSolve(NoSuchMethodException.class, e -> LOOKUP.findVirtual(targetType, getterName, getterType)) + .orThrow(e -> ReflectException.of("Unable to find getter for field: ".concat(fieldName))); + } + + private static Optional findWitherMethod(final Field field) { + final var targetType = field.getDeclaringClass(); + final var witherName = "with".concat(Commons.capitalize(field.getName())); + final var witherType = methodType(targetType, field.getType()); + + return Maybe + .from(() -> LOOKUP.findVirtual(targetType, witherName, witherType)) + .toOptional(); + } + + private static Optional findSetterMethod(final Field field) { + final var targetType = field.getDeclaringClass(); + final var setterName = "set".concat(Commons.capitalize(field.getName())); + final var setterType = methodType(void.class, field.getType()); + + return Maybe + .from(() -> LOOKUP.findVirtual(targetType, setterName, setterType)) + .toOptional(); + } +} diff --git a/src/test/java/io/github/joselion/springr2dbcrelationships/LibraryTest.java b/src/test/java/io/github/joselion/springr2dbcrelationships/LibraryTest.java deleted file mode 100644 index 54dd2f2..0000000 --- a/src/test/java/io/github/joselion/springr2dbcrelationships/LibraryTest.java +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.joselion.springr2dbcrelationships; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -class LibraryTest { - - @Test void someLibraryMethodReturnsTrue() { - final var lib = new Library(); - - assertThat(lib.someLibraryMethod()).isTrue(); - } -} diff --git a/src/test/java/io/github/joselion/springr2dbcrelationships/RelationalCallbacksTest.java b/src/test/java/io/github/joselion/springr2dbcrelationships/RelationalCallbacksTest.java new file mode 100644 index 0000000..33abccd --- /dev/null +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/RelationalCallbacksTest.java @@ -0,0 +1,166 @@ +package io.github.joselion.springr2dbcrelationships; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import io.github.joselion.springr2dbcrelationships.models.phone.Phone; +import io.github.joselion.springr2dbcrelationships.models.phone.PhoneRepository; +import io.github.joselion.springr2dbcrelationships.models.phone.details.PhoneDetails; +import io.github.joselion.springr2dbcrelationships.models.phone.details.PhoneDetailsRepository; +import io.github.joselion.testing.annotations.IntegrationTest; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@IntegrationTest class RelationalCallbacksTest { + + @Autowired + private PhoneRepository phoneRepo; + + @Autowired + private PhoneDetailsRepository phoneDetailsRepo; + + private final PhoneDetails details = PhoneDetails.empty().withProvider("Movistar").withTechnology("5G"); + + private final Phone phone = Phone.empty().withNumber("+593998591484"); + + @Nested class afterConvert { + @Nested class when_a_field_has_a_OneToOne_annotation { + @Nested class and_the_annotation_is_in_the_parent { + @Test void populates_the_field_with_the_child_entity() { + phoneRepo.save(phone) + .map(Phone::id) + .delayUntil(id -> + Mono.just(id) + .map(details::withPhoneId) + .flatMap(phoneDetailsRepo::save) + ) + .flatMap(phoneRepo::findById) + .as(StepVerifier::create) + .assertNext(found -> { + final var phoneDetails = found.phoneDetails(); + + assertThat(found.id()).isNotNull(); + assertThat(phoneDetails).isNotNull(); + assertThat(phoneDetails.id()).isNotNull(); + assertThat(phoneDetails.phoneId()).isEqualTo(found.id()); + assertThat(phoneDetails.provider()).isEqualTo(details.provider()); + assertThat(phoneDetails.technology()).isEqualTo(details.technology()); + }) + .verifyComplete(); + } + } + + @Nested class and_the_annotation_is_in_the_child { + @Test void populates_the_field_with_the_parent_entity() { + phoneRepo.save(phone) + .map(Phone::id) + .map(details::withPhoneId) + .flatMap(phoneDetailsRepo::save) + .map(PhoneDetails::id) + .flatMap(phoneDetailsRepo::findById) + .as(StepVerifier::create) + .assertNext(found -> { + assertThat(found.phone()).isNotNull(); + assertThat(found.phone().id()).isNotNull(); + assertThat(found.phone().number()).isEqualTo(phone.number()); + assertThat(found.phone().phoneDetails().phone()).isNull(); + assertThat(found.phone().phoneDetails()) + .usingRecursiveComparison() + .ignoringFields("phone") + .isEqualTo(found); + }) + .verifyComplete(); + } + } + } + } + + @Nested class onAfterSave { + @Nested class when_a_field_has_a_OneToOne_annotation { + @Nested class and_the_annotation_is_in_the_parent { + @Nested class and_the_child_does_not_exist { + @Test void creates_a_new_child_entity() { + Mono.just(details) + .map(phone::withPhoneDetails) + .flatMap(phoneRepo::save) + .map(Phone::phoneDetails) + .mapNotNull(PhoneDetails::id) + .flatMap(phoneDetailsRepo::findById) + .as(StepVerifier::create) + .assertNext(found -> { + assertThat(found.id()).isNotNull(); + assertThat(found.provider()).isEqualTo(details.provider()); + assertThat(found.technology()).isEqualTo(details.technology()); + }) + .verifyComplete(); + } + } + + @Nested class and_the_child_already_exists { + @Test void updates_the_child_entity() { + phoneRepo.save(phone) + .map(Phone::id) + .map(details::withPhoneId) + .flatMap(phoneDetailsRepo::save) + .map(saved -> saved.withTechnology("5G")) + .map(phone::withPhoneDetails) + .flatMap(phoneRepo::save) + .map(Phone::phoneDetails) + .map(PhoneDetails::id) + .flatMap(phoneDetailsRepo::findById) + .as(StepVerifier::create) + .assertNext(found -> { + assertThat(found.id()).isNotNull(); + assertThat(found.provider()).isEqualTo(details.provider()); + assertThat(found.technology()).isEqualTo("5G"); + }) + .verifyComplete(); + } + } + + @Nested class and_the_child_field_is_null { + @Test void deletes_the_orphan_child() { + Mono.just(details) + .map(phone::withPhoneDetails) + .flatMap(phoneRepo::save) + .delayUntil(saved -> { + final var updated = saved.withPhoneDetails(null); + return phoneRepo.save(updated); + }) + .map(Phone::phoneDetails) + .map(PhoneDetails::id) + .flatMap(phoneDetailsRepo::findById) + .as(StepVerifier::create) + .expectNextCount(0) + .verifyComplete(); + } + } + } + + @Nested class and_the_annotation_is_in_the_child { + @Test void never_persists_the_parent() { + phoneRepo.save(phone) + .flatMap(saved -> + Mono.just(saved) + .map(Phone::id) + .map(details::withPhoneId) + .flatMap(phoneDetailsRepo::save) + .map(pd -> pd.withPhone(saved.withNumber("N/A"))) + ) + .flatMap(phoneDetailsRepo::save) + .map(PhoneDetails::phone) + .map(Phone::id) + .flatMap(phoneRepo::findById) + .as(StepVerifier::create) + .assertNext(found -> { + assertThat(found.number()).isEqualTo(phone.number()); + }) + .verifyComplete(); + } + } + } + } +} diff --git a/src/test/java/io/github/joselion/springr2dbcrelationships/helpers/CommonTest.java b/src/test/java/io/github/joselion/springr2dbcrelationships/helpers/CommonTest.java new file mode 100644 index 0000000..091149f --- /dev/null +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/helpers/CommonTest.java @@ -0,0 +1,93 @@ +package io.github.joselion.springr2dbcrelationships.helpers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import io.github.joselion.testing.annotations.UnitTest; + +@UnitTest class CommonTest { + + @Nested class cast { + @Nested class when_the_value_can_be_cast { + @Test void returns_the_value_as_the_parameter_type() { + final Number value = 3; + + assertThat(Commons.cast(value)).isInstanceOf(Integer.class); + } + } + + @Nested class when_the_value_cannot_be_cast { + @Test void throws_a_ClassCastException() { + assertThatCode(() -> Commons.cast("3").intValue()) // NOSONAR + .isInstanceOf(ClassCastException.class); + } + } + } + + @Nested class capitalize { + @CsvSource({ + "hello, Hello", + "world, World", + "foo, Foo", + "ah, Ah", + "x, X", + "'', ''" + }) + @ParameterizedTest void capitalizes_the_provided_string(final String text, final String expected) { + final var result = Commons.capitalize(text); + + assertThat(result).isEqualTo(expected); + } + } + + @Nested class uncapitalize { + @CsvSource({ + "Hello, hello", + "World, world", + "Foo, foo", + "Ah, ah", + "X, x", + "'', ''" + }) + @ParameterizedTest void uncapitalizes_the_provided_string(final String text, final String expected) { + final var result = Commons.uncapitalize(text); + + assertThat(result).isEqualTo(expected); + } + } + + @Nested class toSnakeCase { + @CsvSource({ + "someLongText, some_long_text", + "helloWorld, hello_world", + "getABC, get_abc", + "HELLO, hello", + "'', ''" + }) + @ParameterizedTest void transforms_the_string_to_snake_case(final String text, final String expected) { + final var result = Commons.toSnakeCase(text); + + assertThat(result).isEqualTo(expected); + } + } + + @Nested class toCamelCase { + @CsvSource({ + "some_long_text, someLongText", + "hello_world, helloWorld", + "get_abc, getAbc", + "hello, hello", + "'', ''" + }) + @ParameterizedTest void transforms_the_string_to_camel_case(final String text, final String expected) { + final var result = Commons.toCamelCase(text); + + assertThat(result).isEqualTo(expected); + } + } +} diff --git a/src/test/java/io/github/joselion/springr2dbcrelationships/helpers/ReflectTest.java b/src/test/java/io/github/joselion/springr2dbcrelationships/helpers/ReflectTest.java new file mode 100644 index 0000000..847a5d5 --- /dev/null +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/helpers/ReflectTest.java @@ -0,0 +1,243 @@ +package io.github.joselion.springr2dbcrelationships.helpers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +import io.github.joselion.springr2dbcrelationships.exceptions.ReflectException; +import io.github.joselion.testing.annotations.UnitTest; +import lombok.AllArgsConstructor; +import lombok.With; + +@UnitTest class ReflectTest { + + @Nested class getter { + @Nested class when_the_field_is_provided { + @Nested class and_the_field_getter_exists { + @TestFactory Stream invokes_the_getter_and_returns_the_value() { + final var targetRecord = TestRecord.of("Hello world!"); + final var targetClass = TestPojo.of("Hello world!"); + + return Stream + .of(targetRecord, targetClass) + .map(target -> dynamicTest(target.getClass().getSimpleName(), () -> { + final var field = target.getClass().getDeclaredField("foo"); + final var result = Reflect.getter(target, field); + + assertThat(result) + .isInstanceOf(String.class) + .isEqualTo("Hello world!"); + })); + } + } + + @Nested class and_the_field_getter_does_not_exist { + @Test void throws_a_ReflectException() throws NoSuchFieldException, SecurityException { + final var field = TestPojo.class.getDeclaredField("bar"); + final var target = TestPojo.of("hello"); + + assertThatCode(() -> Reflect.getter(target, field)) + .isInstanceOf(ReflectException.class) + .hasMessage("Unable to find getter for field: bar"); + } + } + } + + @Nested class when_the_field_name_is_provided { + @Nested class and_the_field_name_exists { + @Test void calls_the_field_based_overload() throws NoSuchFieldException, SecurityException { + try (var mocked = mockStatic(Reflect.class, CALLS_REAL_METHODS)) { + final var field = TestRecord.class.getDeclaredField("foo"); + final var target = TestRecord.of("Hello world!"); + final var foo = Reflect.getter(target, "foo"); + + assertThat(foo).isEqualTo("Hello world!"); + + mocked.verify(() -> Reflect.getter(target, field)); + } + } + } + + @Nested class and_the_field_name_does_not_exist { + @Test void throws_a_ReflectException() { + final var target = TestRecord.of("Hello world!"); + + assertThatCode(() -> Reflect.getter(target, "other")) + .isInstanceOf(ReflectException.class) + .hasMessage("java.lang.NoSuchFieldException: other"); + } + } + } + + @Nested class when_the_method_is_provided { + @Nested class and_the_field_name_exists { + @Test void calls_the_field_name_based_overload() throws ReflectiveOperationException, SecurityException { + try (var mocked = mockStatic(Reflect.class, CALLS_REAL_METHODS)) { + final var method = TestPojo.class.getDeclaredMethod("getFoo"); + final var target = TestPojo.of("Hello world!"); + final var foo = Reflect.getter(target, method); + + assertThat(foo).isEqualTo("Hello world!"); + + mocked.verify(() -> Reflect.getter(target, "foo")); + } + } + } + + @Nested class and_the_method_does_not_exist { + @Test void throws_a_ReflectException() throws NoSuchMethodException, SecurityException { + final var target = TestPojo.of("Hello world!"); + final var method = TestPojo.class.getDeclaredMethod("retrieveBar"); + + assertThatCode(() -> Reflect.getter(target, method)) + .isInstanceOf(ReflectException.class) + .hasMessage("java.lang.NoSuchFieldException: retrieveBar"); + } + } + } + } + + @Nested class update { + @Nested class when_the_field_is_provided { + @Nested class and_the_field_updater_exists { + @TestFactory Stream updates_the_field_in_the_target() { + final var targetRecord = TestRecord.of("Hello!"); + final var targetClass = TestPojo.of("Hello!"); + + return Stream + .of(targetRecord, targetClass) + .map(target -> dynamicTest(target.getClass().getSimpleName(), () -> { + final var field = target.getClass().getDeclaredField("foo"); + final var updated = Reflect.update(target, field, "Good bye!"); + + assertThat(updated) + .extracting("foo") + .isEqualTo("Good bye!"); + })); + } + } + + @Nested class and_the_field_updater_does_not_exist { + @Test void throws_a_ReflectException() throws NoSuchFieldException, SecurityException { + final var field = TestPojo.class.getDeclaredField("bar"); + final var target = TestPojo.of("Hello!"); + + assertThatCode(() -> Reflect.update(target, field, true)) + .isInstanceOf(ReflectException.class) + .hasMessage("Unable to find wither/setter for field: bar"); + } + } + } + + @Nested class when_the_field_name_is_provided { + @Nested class and_the_field_name_exists { + @Test void calls_the_field_based_overload() throws NoSuchFieldException, SecurityException { + try (var mocked = mockStatic(Reflect.class, CALLS_REAL_METHODS)) { + final var field = TestRecord.class.getDeclaredField("foo"); + final var target = TestRecord.of("Hello world!"); + final var updated = Reflect.update(target, "foo", "Good bye!"); + + assertThat(updated.foo()).isEqualTo("Good bye!"); + + mocked.verify(() -> Reflect.update(target, field, "Good bye!")); + } + } + } + + @Nested class and_the_field_name_does_not_exist { + @Test void throws_a_ReflectException() { + final var target = TestRecord.of("Hello!"); + + assertThatCode(() -> Reflect.update(target, "other", "Bye!")) + .isInstanceOf(ReflectException.class) + .hasMessage("java.lang.NoSuchFieldException: other"); + } + } + } + } + + @Nested class innerTypeOf { + @Nested class when_the_field_type_has_only_one_generic_inner_type { + @Test void returns_the_inner_type_of_the_field_type() throws NoSuchFieldException, SecurityException { + final var field = TestPojo.class.getDeclaredField("textList"); + final var innerType = Reflect.innerTypeOf(field); + + assertThat(innerType).isEqualTo(String.class); + } + } + + @Nested class when_the_field_type_has_more_than_one_generic_inner_type { + @Test void returns_the_first_inner_type_of_the_field_type() throws NoSuchFieldException, SecurityException { + final var field = TestPojo.class.getDeclaredField("props"); + final var innerType = Reflect.innerTypeOf(field); + + assertThat(innerType).isEqualTo(String.class); + } + } + + @Nested class when_the_field_type_has_no_generic_inner_types { + @Test void throws_a_ReflectException() throws NoSuchFieldException, SecurityException { + final var field = TestPojo.class.getDeclaredField("foo"); + + assertThatCode(() -> Reflect.innerTypeOf(field)) + .isInstanceOf(ReflectException.class) + .hasMessage("The type of 'foo' field has no parameterized types"); + } + } + } + + @With + record TestRecord(String foo) { + + public static TestRecord of(final String foo) { + return new TestRecord(foo); + } + } + + @AllArgsConstructor + static final class TestPojo { + + private String foo; + + private final boolean bar = false; + + private final List textList = List.of(); + + private final Map props = Map.of(); + + public static TestPojo of(final String foo) { + return new TestPojo(foo); + } + + public String getFoo() { + return this.foo; + } + + public void setFoo(final String foo) { + this.foo = foo; + } + + public boolean retrieveBar() { + return this.bar; + } + + public List getTextList() { + return this.textList; + } + + public Map getProps() { + return this.props; + } + } +} diff --git a/src/test/java/io/github/joselion/testing/annotations/IntegrationTest.java b/src/test/java/io/github/joselion/testing/annotations/IntegrationTest.java new file mode 100644 index 0000000..32ac1fd --- /dev/null +++ b/src/test/java/io/github/joselion/testing/annotations/IntegrationTest.java @@ -0,0 +1,21 @@ +package io.github.joselion.testing.annotations; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import org.springframework.boot.test.context.SpringBootTest; + +import io.github.joselion.springr2dbcrelationships.FixtureApplication; + +@UnitTest +@Inherited +@Target(TYPE) +@Retention(RUNTIME) +@SpringBootTest(classes = FixtureApplication.class) +public @interface IntegrationTest { + +} diff --git a/src/test/java/io/github/joselion/testing/annotations/UnitTest.java b/src/test/java/io/github/joselion/testing/annotations/UnitTest.java new file mode 100644 index 0000000..444c181 --- /dev/null +++ b/src/test/java/io/github/joselion/testing/annotations/UnitTest.java @@ -0,0 +1,17 @@ +package io.github.joselion.testing.annotations; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; + +@Target(TYPE) +@Retention(RUNTIME) +@DisplayNameGeneration(ReplaceUnderscores.class) +public @interface UnitTest { + +} diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/FixtureApplication.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/FixtureApplication.java new file mode 100644 index 0000000..3510a83 --- /dev/null +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/FixtureApplication.java @@ -0,0 +1,14 @@ +package io.github.joselion.springr2dbcrelationships; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; + +@SpringBootApplication +@EnableR2dbcRepositories +public class FixtureApplication { + + public static void main(final String[] args) { + SpringApplication.run(FixtureApplication.class, args); + } +} diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/Phone.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/Phone.java new file mode 100644 index 0000000..8e46a9e --- /dev/null +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/Phone.java @@ -0,0 +1,22 @@ +package io.github.joselion.springr2dbcrelationships.models.phone; + +import java.util.UUID; + +import org.eclipse.jdt.annotation.Nullable; +import org.springframework.data.annotation.Id; + +import io.github.joselion.springr2dbcrelationships.annotations.OneToOne; +import io.github.joselion.springr2dbcrelationships.models.phone.details.PhoneDetails; +import lombok.With; + +@With +public record Phone( + @Id @Nullable UUID id, + String number, + @Nullable @OneToOne PhoneDetails phoneDetails +) { + + public static Phone empty() { + return new Phone(null, "", null); + } +} diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/PhoneRepository.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/PhoneRepository.java new file mode 100644 index 0000000..c75c392 --- /dev/null +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/PhoneRepository.java @@ -0,0 +1,9 @@ +package io.github.joselion.springr2dbcrelationships.models.phone; + +import java.util.UUID; + +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +public interface PhoneRepository extends ReactiveCrudRepository { + +} diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/details/PhoneDetails.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/details/PhoneDetails.java new file mode 100644 index 0000000..72d1852 --- /dev/null +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/details/PhoneDetails.java @@ -0,0 +1,30 @@ +package io.github.joselion.springr2dbcrelationships.models.phone.details; + +import java.util.UUID; + +import org.eclipse.jdt.annotation.Nullable; +import org.springframework.data.annotation.Id; + +import io.github.joselion.springr2dbcrelationships.annotations.OneToOne; +import io.github.joselion.springr2dbcrelationships.models.phone.Phone; +import lombok.With; + +@With +public record PhoneDetails( + @Id @Nullable UUID id, + UUID phoneId, + @OneToOne(backReference = true) Phone phone, + String provider, + String technology +) { + + public static PhoneDetails empty() { + return new PhoneDetails( + null, + UUID.randomUUID(), + Phone.empty(), + "", + "" + ); + } +} diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/details/PhoneDetailsRepository.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/details/PhoneDetailsRepository.java new file mode 100644 index 0000000..39f1b55 --- /dev/null +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/phone/details/PhoneDetailsRepository.java @@ -0,0 +1,9 @@ +package io.github.joselion.springr2dbcrelationships.models.phone.details; + +import java.util.UUID; + +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +public interface PhoneDetailsRepository extends ReactiveCrudRepository { + +} diff --git a/src/testFixtures/resources/application.yml b/src/testFixtures/resources/application.yml new file mode 100644 index 0000000..0162d87 --- /dev/null +++ b/src/testFixtures/resources/application.yml @@ -0,0 +1,5 @@ +spring: + r2dbc: + url: r2dbc:h2:mem:///~/db/testdb?options=DB_CLOSE_DELAY=-1; + username: sa + password: "" diff --git a/src/testFixtures/resources/schema.sql b/src/testFixtures/resources/schema.sql new file mode 100644 index 0000000..c6ab8ec --- /dev/null +++ b/src/testFixtures/resources/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE phone( + id uuid NOT NULL DEFAULT random_uuid() PRIMARY KEY, + number varchar(255) NOT NULL +); + +CREATE TABLE phone_details( + id uuid NOT NULL DEFAULT random_uuid() PRIMARY KEY, + phone_id uuid NOT NULL, + provider varchar(255) NOT NULL, + technology varchar(255) NOT NULL, + FOREIGN KEY (phone_id) REFERENCES phone +);