diff --git a/database/starters/oracle-spring-boot-json-data-tools/pom.xml b/database/starters/oracle-spring-boot-json-data-tools/pom.xml
new file mode 100644
index 00000000..14702fe5
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-data-tools/pom.xml
@@ -0,0 +1,124 @@
+
+
+
+
+ 4.0.0
+
+ oracle-spring-boot-starters
+ com.oracle.database.spring
+ 25.2.0
+ ../pom.xml
+
+
+ oracle-spring-boot-json-data-tools
+ 25.2.0
+
+ Oracle Spring Boot - JSON Data Tools
+ Spring Boot for Oracle Database JSON Data Tools
+ https://github.com/oracle/spring-cloud-oracle/tree/main/database/starters/oracle-spring-boot-json-data-tools
+
+
+ Oracle America, Inc.
+ https://www.oracle.com
+
+
+
+
+ Oracle
+ obaas_ww at oracle.com
+ Oracle America, Inc.
+ https://www.oracle.com
+
+
+
+
+
+ The Universal Permissive License (UPL), Version 1.0
+ https://oss.oracle.com/licenses/upl/
+ repo
+
+
+
+
+ https://github.com/oracle/spring-cloud-oracle
+ scm:git:https://github.com/oracle/spring-cloud-oracle.git
+ scm:git:git@github.com:oracle/spring-cloud-oracle.git
+
+
+
+
+ com.oracle.database.spring
+ oracle-spring-boot-starter-ucp
+ ${project.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+
+ jakarta.json
+ jakarta.json-api
+
+
+
+ org.eclipse.parsson
+ parsson
+
+
+
+ jakarta.json.bind
+ jakarta.json.bind-api
+
+
+
+ org.eclipse
+ yasson
+
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+
+ org.testcontainers
+ testcontainers
+ test
+
+
+
+ org.testcontainers
+ oracle-free
+ test
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+ test
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jdbc
+ test
+
+
+
+ org.projectlombok
+ lombok
+ test
+
+
+
diff --git a/database/starters/oracle-spring-boot-starter-json-collections/src/main/java/com/oracle/spring/json/JsonCollectionsAutoConfiguration.java b/database/starters/oracle-spring-boot-json-data-tools/src/main/java/com/oracle/spring/json/JsonCollectionsAutoConfiguration.java
similarity index 100%
rename from database/starters/oracle-spring-boot-starter-json-collections/src/main/java/com/oracle/spring/json/JsonCollectionsAutoConfiguration.java
rename to database/starters/oracle-spring-boot-json-data-tools/src/main/java/com/oracle/spring/json/JsonCollectionsAutoConfiguration.java
diff --git a/database/starters/oracle-spring-boot-starter-json-collections/src/main/java/com/oracle/spring/json/jsonb/JSONB.java b/database/starters/oracle-spring-boot-json-data-tools/src/main/java/com/oracle/spring/json/jsonb/JSONB.java
similarity index 100%
rename from database/starters/oracle-spring-boot-starter-json-collections/src/main/java/com/oracle/spring/json/jsonb/JSONB.java
rename to database/starters/oracle-spring-boot-json-data-tools/src/main/java/com/oracle/spring/json/jsonb/JSONB.java
diff --git a/database/starters/oracle-spring-boot-starter-json-collections/src/main/java/com/oracle/spring/json/jsonb/JSONBRowMapper.java b/database/starters/oracle-spring-boot-json-data-tools/src/main/java/com/oracle/spring/json/jsonb/JSONBRowMapper.java
similarity index 100%
rename from database/starters/oracle-spring-boot-starter-json-collections/src/main/java/com/oracle/spring/json/jsonb/JSONBRowMapper.java
rename to database/starters/oracle-spring-boot-json-data-tools/src/main/java/com/oracle/spring/json/jsonb/JSONBRowMapper.java
diff --git a/database/starters/oracle-spring-boot-starter-json-collections/src/main/resources/META-INF/spring.factories b/database/starters/oracle-spring-boot-json-data-tools/src/main/resources/META-INF/spring.factories
similarity index 100%
rename from database/starters/oracle-spring-boot-starter-json-collections/src/main/resources/META-INF/spring.factories
rename to database/starters/oracle-spring-boot-json-data-tools/src/main/resources/META-INF/spring.factories
diff --git a/database/starters/oracle-spring-boot-starter-json-collections/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/database/starters/oracle-spring-boot-json-data-tools/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
similarity index 100%
rename from database/starters/oracle-spring-boot-starter-json-collections/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
rename to database/starters/oracle-spring-boot-json-data-tools/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
diff --git a/database/starters/oracle-spring-boot-starter-json-collections/src/test/java/com/oracle/spring/json/JsonCollectionsIT.java b/database/starters/oracle-spring-boot-json-data-tools/src/test/java/com/oracle/spring/json/JsonCollectionsIT.java
similarity index 100%
rename from database/starters/oracle-spring-boot-starter-json-collections/src/test/java/com/oracle/spring/json/JsonCollectionsIT.java
rename to database/starters/oracle-spring-boot-json-data-tools/src/test/java/com/oracle/spring/json/JsonCollectionsIT.java
index 6afacdbc..5a2a9663 100644
--- a/database/starters/oracle-spring-boot-starter-json-collections/src/test/java/com/oracle/spring/json/JsonCollectionsIT.java
+++ b/database/starters/oracle-spring-boot-json-data-tools/src/test/java/com/oracle/spring/json/JsonCollectionsIT.java
@@ -2,7 +2,6 @@
// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
package com.oracle.spring.json;
-import javax.sql.DataSource;
import java.sql.PreparedStatement;
import java.time.Duration;
import java.util.List;
@@ -12,6 +11,7 @@
import com.oracle.spring.json.jsonb.JSONBRowMapper;
import com.oracle.spring.json.test.Student;
import com.oracle.spring.json.test.StudentDetails;
+import javax.sql.DataSource;
import oracle.jdbc.OracleTypes;
import oracle.ucp.jdbc.PoolDataSource;
import oracle.ucp.jdbc.PoolDataSourceFactory;
diff --git a/database/starters/oracle-spring-boot-starter-json-collections/src/test/java/com/oracle/spring/json/jsonb/JSONBTest.java b/database/starters/oracle-spring-boot-json-data-tools/src/test/java/com/oracle/spring/json/jsonb/JSONBTest.java
similarity index 100%
rename from database/starters/oracle-spring-boot-starter-json-collections/src/test/java/com/oracle/spring/json/jsonb/JSONBTest.java
rename to database/starters/oracle-spring-boot-json-data-tools/src/test/java/com/oracle/spring/json/jsonb/JSONBTest.java
diff --git a/database/starters/oracle-spring-boot-starter-json-collections/src/test/java/com/oracle/spring/json/test/Student.java b/database/starters/oracle-spring-boot-json-data-tools/src/test/java/com/oracle/spring/json/test/Student.java
similarity index 100%
rename from database/starters/oracle-spring-boot-starter-json-collections/src/test/java/com/oracle/spring/json/test/Student.java
rename to database/starters/oracle-spring-boot-json-data-tools/src/test/java/com/oracle/spring/json/test/Student.java
diff --git a/database/starters/oracle-spring-boot-starter-json-collections/src/test/java/com/oracle/spring/json/test/StudentDetails.java b/database/starters/oracle-spring-boot-json-data-tools/src/test/java/com/oracle/spring/json/test/StudentDetails.java
similarity index 100%
rename from database/starters/oracle-spring-boot-starter-json-collections/src/test/java/com/oracle/spring/json/test/StudentDetails.java
rename to database/starters/oracle-spring-boot-json-data-tools/src/test/java/com/oracle/spring/json/test/StudentDetails.java
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/pom.xml b/database/starters/oracle-spring-boot-json-relational-duality-views/pom.xml
new file mode 100644
index 00000000..036a1a8d
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/pom.xml
@@ -0,0 +1,98 @@
+
+
+
+
+ 4.0.0
+
+ oracle-spring-boot-starters
+ com.oracle.database.spring
+ 25.2.0
+ ../pom.xml
+
+
+ oracle-spring-boot-json-relational-duality-views
+ 25.2.0
+
+ Oracle Spring Boot - JSON Relational Duality Views
+ Spring Boot for Oracle Database JSON Relational duality Views
+ https://github.com/oracle/spring-cloud-oracle/tree/main/database/starters/oracle-spring-boot-json-relational-duality-views
+
+
+ Oracle America, Inc.
+ https://www.oracle.com
+
+
+
+
+ Oracle
+ obaas_ww at oracle.com
+ Oracle America, Inc.
+ https://www.oracle.com
+
+
+
+
+
+ The Universal Permissive License (UPL), Version 1.0
+ https://oss.oracle.com/licenses/upl/
+ repo
+
+
+
+
+ https://github.com/oracle/spring-cloud-oracle
+ scm:git:https://github.com/oracle/spring-cloud-oracle.git
+ scm:git:git@github.com:oracle/spring-cloud-oracle.git
+
+
+
+
+ com.oracle.database.spring
+ oracle-spring-boot-json-data-tools
+ ${project.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+
+ org.testcontainers
+ testcontainers
+ test
+
+
+
+ org.testcontainers
+ oracle-free
+ test
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+ test
+
+
+
+ org.projectlombok
+ lombok
+ test
+
+
+
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/annotation/AccessMode.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/annotation/AccessMode.java
new file mode 100644
index 00000000..00df54f9
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/annotation/AccessMode.java
@@ -0,0 +1,16 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.annotation;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface AccessMode {
+ boolean insert() default false;
+ boolean update() default false;
+ boolean delete() default false;
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/annotation/JsonRelationalDualityView.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/annotation/JsonRelationalDualityView.java
new file mode 100644
index 00000000..041343b6
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/annotation/JsonRelationalDualityView.java
@@ -0,0 +1,21 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Documented
+@Target({ElementType.TYPE, ElementType.FIELD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface JsonRelationalDualityView {
+ String name() default "";
+
+ boolean selfReferential() default false;
+
+ AccessMode accessMode() default @AccessMode();
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/annotation/JsonRelationalDualityViewScan.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/annotation/JsonRelationalDualityViewScan.java
new file mode 100644
index 00000000..285abba9
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/annotation/JsonRelationalDualityViewScan.java
@@ -0,0 +1,20 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+
+@Documented
+@Target({ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface JsonRelationalDualityViewScan {
+ String[] basePackages() default {};
+
+ Class>[] basePackageClasses() default {};
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/Annotations.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/Annotations.java
new file mode 100644
index 00000000..a3e6e03f
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/Annotations.java
@@ -0,0 +1,120 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.builder;
+
+import java.lang.reflect.Field;
+
+import com.oracle.spring.json.duality.annotation.AccessMode;
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.json.bind.annotation.JsonbTransient;
+import jakarta.persistence.Column;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.JoinTable;
+import jakarta.persistence.ManyToMany;
+import jakarta.persistence.Table;
+import org.springframework.util.StringUtils;
+
+public final class Annotations {
+ public static final String _ID_FIELD = "_id";
+
+ static JoinTable getJoinTableAnnotation( Field f, ManyToMany manyToMany, Class> mappedType) {
+ JoinTable annotation = f.getAnnotation(JoinTable.class);
+ if (annotation != null) {
+ return annotation;
+ }
+
+ String mappedFieldName = manyToMany.mappedBy();
+ if (!StringUtils.hasText(mappedFieldName)) {
+ throw new IllegalArgumentException("Mapped field name is required for inverse join on field " + f.getName());
+ }
+
+ for (Field field : mappedType.getDeclaredFields()) {
+ if (field.getName().equals(mappedFieldName)) {
+ JoinTable mappedJoinTable = field.getAnnotation(JoinTable.class);
+ if (mappedJoinTable == null) {
+ throw new IllegalArgumentException("Mapped field %s does has no JoinTable annotation".formatted(
+ field.getName()
+ ));
+ }
+ return mappedJoinTable;
+ }
+ }
+ throw new IllegalArgumentException("No JoinTable found for field " + f.getName());
+ }
+
+ static String getNestedViewName(Class> javaType,
+ JsonRelationalDualityView dvAnnotation,
+ Table tableAnnotation) {
+ if (dvAnnotation != null && StringUtils.hasText(dvAnnotation.name())) {
+ return dvAnnotation.name();
+ }
+ return getTableName(javaType, tableAnnotation);
+ }
+
+ public static String getViewName(Class> javaType, JsonRelationalDualityView dvAnnotation) {
+ Table tableAnnotation = javaType.getAnnotation(Table.class);
+ final String suffix = "_dv";
+ if (dvAnnotation != null && StringUtils.hasText(dvAnnotation.name())) {
+ return dvAnnotation.name().toLowerCase();
+ }
+ if (tableAnnotation != null && StringUtils.hasText(tableAnnotation.name())) {
+ return tableAnnotation.name().toLowerCase() + suffix;
+ }
+ return javaType.getName().toLowerCase() + suffix;
+ }
+
+ static String getTableName(Class> javaType, Table tableAnnotation) {
+ if (tableAnnotation != null && StringUtils.hasText(tableAnnotation.name())) {
+ return tableAnnotation.name().toLowerCase();
+ }
+ return javaType.getName().toLowerCase();
+ }
+
+ static boolean isFieldIncluded(Field f) {
+ return f.getAnnotation(JsonbTransient.class) == null;
+ }
+
+
+ static String getJsonbPropertyName(Field f) {
+ JsonbProperty jsonbProperty = f.getAnnotation(JsonbProperty.class);
+ if (jsonbProperty == null || !StringUtils.hasText(jsonbProperty.value())) {
+ return f.getName();
+ }
+ return jsonbProperty.value();
+ }
+
+ static String getDatabaseColumnName(Field f) {
+ Column column = f.getAnnotation(Column.class);
+ if (column != null && StringUtils.hasText(column.name())) {
+ return column.name();
+ }
+ return f.getName();
+ }
+
+ static String getAccessModeStr(AccessMode accessMode, ManyToMany manyToMany, JoinColumn joinColumn) {
+ StringBuilder sb = new StringBuilder();
+ if (manyToMany != null) {
+ sb.append("@unnest ");
+ }
+ if (accessMode != null) {
+ if (accessMode.insert()) {
+ sb.append("@insert ");
+ }
+ if (accessMode.update()) {
+ sb.append("@update ");
+ }
+ if (accessMode.delete()) {
+ sb.append("@delete ");
+ }
+ }
+
+ // Add join from if join column present
+ if (joinColumn != null && StringUtils.hasText(joinColumn.name())) {
+ sb.append("@link (from : [%s]) ".formatted(joinColumn.name()));
+ }
+
+ return sb.toString();
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/DualityViewBuilder.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/DualityViewBuilder.java
new file mode 100644
index 00000000..5d4e8fb3
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/DualityViewBuilder.java
@@ -0,0 +1,136 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.builder;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import jakarta.annotation.PostConstruct;
+import javax.sql.DataSource;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties;
+import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
+import org.springframework.stereotype.Component;
+
+import static com.oracle.spring.json.duality.builder.Annotations.getAccessModeStr;
+import static com.oracle.spring.json.duality.builder.Annotations.getViewName;
+
+@Component
+public final class DualityViewBuilder implements DisposableBean {
+ private static final String PREFIX = "JSON Relational Duality Views: ";
+ private static final int TABLE_OR_VIEW_DOES_NOT_EXIST = 942;
+
+ private final DataSource dataSource;
+ private final boolean isShowSql;
+ private final RootSnippet rootSnippet;
+ private final Map dualityViews = new HashMap<>();
+
+ public DualityViewBuilder(DataSource dataSource,
+ JpaProperties jpaProperties,
+ HibernateProperties hibernateProperties) {
+ this.dataSource = dataSource;
+ this.isShowSql = jpaProperties.isShowSql();
+ this.rootSnippet = RootSnippet.fromDdlAuto(
+ hibernateProperties.getDdlAuto()
+ );
+ }
+
+ void apply() {
+ switch (rootSnippet) {
+ case NONE -> {
+ return;
+ }
+ case CREATE_DROP -> {
+ try {
+ createDrop();
+ } catch (SQLException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ for (String ddl : dualityViews.values()) {
+ if (isShowSql) {
+ System.out.println(PREFIX + ddl);
+ }
+ if (rootSnippet.equals(RootSnippet.VALIDATE)) {
+ // TODO: Handle view validation.
+ return;
+ }
+
+ runDDL(ddl);
+ }
+ }
+
+ public String build(Class> javaType) {
+ JsonRelationalDualityView dvAnnotation = javaType.getAnnotation(JsonRelationalDualityView.class);
+ if (dvAnnotation == null) {
+ throw new IllegalArgumentException("%s not found for type %s".formatted(
+ JsonRelationalDualityView.class.getSimpleName(), javaType.getName())
+ );
+ }
+ String viewName = getViewName(javaType, dvAnnotation);
+ String accessMode = getAccessModeStr(dvAnnotation.accessMode(), null, null);
+ ViewEntity ve = new ViewEntity(javaType,
+ new StringBuilder(),
+ rootSnippet,
+ accessMode,
+ viewName,
+ 0,
+ false);
+ String ddl = ve.build().toString();
+ dualityViews.put(viewName, ddl);
+ return ddl;
+ }
+
+ private void runDDL(String ddl) {
+ try (Connection conn = dataSource.getConnection();
+ Statement stmt = conn.createStatement()) {
+ stmt.executeUpdate(ddl);
+ } catch (SQLException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @PostConstruct
+ public void init() throws SQLException {
+ createDrop();
+ }
+
+ @Override
+ public void destroy() throws Exception {
+ createDrop();
+ }
+
+ private void createDrop() throws SQLException {
+ if (rootSnippet.equals(RootSnippet.CREATE_DROP) && !dualityViews.isEmpty()) {
+ try (Connection conn = dataSource.getConnection();
+ Statement stmt = conn.createStatement()) {
+ dropViews(stmt);
+ }
+ }
+ }
+
+ private void dropViews(Statement stmt) {
+ final String dropView = "drop view %s";
+
+ for (String view : dualityViews.keySet()) {
+ String dropStatement = dropView.formatted(view);
+ if (isShowSql) {
+ System.out.println(PREFIX + dropStatement);
+ }
+ try {
+ stmt.execute(dropStatement);
+ } catch (SQLException e) {
+ if (e.getErrorCode() != TABLE_OR_VIEW_DOES_NOT_EXIST) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/DualityViewScanner.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/DualityViewScanner.java
new file mode 100644
index 00000000..2c2ac4cf
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/DualityViewScanner.java
@@ -0,0 +1,73 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.builder;
+
+import java.util.Map;
+import java.util.Set;
+
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityViewScan;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.event.EventListener;
+import org.springframework.data.util.AnnotatedTypeScanner;
+import org.springframework.stereotype.Component;
+import org.springframework.util.ClassUtils;
+
+@Component
+final public class DualityViewScanner {
+ private final DualityViewBuilder dualityViewBuilder;
+ private final ApplicationContext applicationContext;
+ private final AnnotatedTypeScanner scanner;
+
+ public DualityViewScanner(DualityViewBuilder dualityViewBuilder,
+ ApplicationContext applicationContext,
+ @Qualifier("jsonRelationalDualityViewScanner") AnnotatedTypeScanner scanner) {
+ this.dualityViewBuilder = dualityViewBuilder;
+ this.applicationContext = applicationContext;
+ this.scanner = scanner;
+ }
+
+ @EventListener(ApplicationReadyEvent.class)
+ public void scan() {
+ Map springBootApplications = applicationContext.getBeansWithAnnotation(SpringBootApplication.class);
+ for (Map.Entry entry : springBootApplications.entrySet()) {
+ Class> mainClass = ClassUtils.getUserClass(entry.getValue().getClass());
+ JsonRelationalDualityViewScan dvScan = mainClass.getAnnotation(JsonRelationalDualityViewScan.class);
+ if (dvScan == null) {
+ scanPackage(mainClass.getPackageName());
+ } else {
+ applyDvScan(dvScan);
+ }
+ }
+ }
+
+ private void applyDvScan(JsonRelationalDualityViewScan dvScan) {
+ if (dvScan.basePackages() != null) {
+ for (String pkg : dvScan.basePackages()) {
+ scanPackage(pkg);
+ }
+ }
+ for (Class> javaType : dvScan.basePackageClasses()) {
+ addClass(javaType);
+ }
+ dualityViewBuilder.apply();
+ }
+
+ private void scanPackage(String packageName) {
+ Set> types = scanner.findTypes(packageName);
+ for (Class> type : types) {
+ addClass(type);
+ }
+ }
+
+ private void addClass(Class> javaType) {
+ JsonRelationalDualityView dvAnnotation = javaType.getAnnotation(JsonRelationalDualityView.class);
+ if (dvAnnotation != null) {
+ dualityViewBuilder.build(javaType);
+ }
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/RootSnippet.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/RootSnippet.java
new file mode 100644
index 00000000..63f714ea
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/RootSnippet.java
@@ -0,0 +1,36 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.builder;
+
+public enum RootSnippet {
+ NONE(null),
+ VALIDATE(null),
+ CREATE("create force editionable json relational duality view"),
+ CREATE_DROP(CREATE.snippet),
+ UPDATE("create or replace force editionable json relational duality view");
+ private final String snippet;
+
+ RootSnippet(String snippet) {
+ this.snippet = snippet;
+ }
+
+ public String getSnippet() {
+ return snippet;
+ }
+
+ public static RootSnippet fromDdlAuto(String ddlAuto) {
+ if (ddlAuto == null) {
+ return NONE;
+ }
+ // none, validate, update, create, and create-drop
+ return switch (ddlAuto) {
+ case "none" -> NONE;
+ case "validate" -> VALIDATE;
+ case "create" -> CREATE;
+ case "create-drop" -> CREATE_DROP;
+ case "update" -> UPDATE;
+ default -> throw new IllegalStateException("Unexpected value: " + ddlAuto);
+ };
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/ScannerConfiguration.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/ScannerConfiguration.java
new file mode 100644
index 00000000..ab12886a
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/ScannerConfiguration.java
@@ -0,0 +1,25 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.builder;
+
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.Environment;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.data.util.AnnotatedTypeScanner;
+
+@Configuration
+public class ScannerConfiguration {
+ @Bean
+ @Qualifier("jsonRelationalDualityViewScanner")
+ public AnnotatedTypeScanner scanner(ResourceLoader resourceLoader, Environment environment) {
+ AnnotatedTypeScanner dvScanner = new AnnotatedTypeScanner(JsonRelationalDualityView.class);
+
+ dvScanner.setResourceLoader(resourceLoader);
+ dvScanner.setEnvironment(environment);
+ return dvScanner;
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/ViewEntity.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/ViewEntity.java
new file mode 100644
index 00000000..56a10604
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/main/java/com/oracle/spring/json/duality/builder/ViewEntity.java
@@ -0,0 +1,301 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.builder;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.JoinTable;
+import jakarta.persistence.ManyToMany;
+import jakarta.persistence.Table;
+import org.springframework.util.StringUtils;
+
+import static com.oracle.spring.json.duality.builder.Annotations._ID_FIELD;
+import static com.oracle.spring.json.duality.builder.Annotations.getAccessModeStr;
+import static com.oracle.spring.json.duality.builder.Annotations.getDatabaseColumnName;
+import static com.oracle.spring.json.duality.builder.Annotations.getJoinTableAnnotation;
+import static com.oracle.spring.json.duality.builder.Annotations.getJsonbPropertyName;
+import static com.oracle.spring.json.duality.builder.Annotations.getTableName;
+import static com.oracle.spring.json.duality.builder.Annotations.getNestedViewName;
+import static com.oracle.spring.json.duality.builder.Annotations.isFieldIncluded;
+
+final class ViewEntity {
+ // Separates JSON keys from database column names
+ private static final String SEPARATOR = " : ";
+ // Terminal for view entity
+ private static final String OBJECT_TERMINAL = "}";
+ // Terminal for view array entity
+ private static final String ARRAY_TERMINAL = "} ]";
+ // Begin array entity
+ private static final String BEGIN_ARRAY = "[ {\n";
+ // Nesting spacing
+ private static final int TAB_WIDTH = 2;
+
+ private final Class> javaType;
+ private final StringBuilder sb;
+ private final RootSnippet rootSnippet;
+ private final String accessMode;
+ private final String viewName;
+ // Tracks number of spaces for key nesting (pretty print)
+ private int nesting;
+ private final boolean manyToMany;
+
+ // Track views to prevent stacking of nested types
+ private final Set views = new HashSet<>();
+
+ private final List nestedEntities = new ArrayList<>();
+
+ ViewEntity(Class> javaType, StringBuilder sb, String accessMode, String viewName, int nesting, boolean manyToMany) {
+ this(javaType, sb, null, accessMode, viewName, nesting, manyToMany);
+ }
+
+ ViewEntity(Class> javaType, StringBuilder sb, RootSnippet rootSnippet, String accessMode, String viewName, int nesting, boolean manyToMany) {
+ this.javaType = javaType;
+ this.sb = sb;
+ this.rootSnippet = rootSnippet;
+ this.accessMode = accessMode;
+ this.viewName = viewName;
+ this.nesting = nesting;
+ this.manyToMany = manyToMany;
+ views.add(viewName);
+ }
+
+ void addViews(Set views) {
+ this.views.addAll(views);
+ }
+
+ /**
+ * Parse view from javaType.
+ * @return this
+ */
+ ViewEntity build() {
+ Table tableAnnotation = javaType.getAnnotation(Table.class);
+
+ if (rootSnippet != null) {
+ // Add create view snippet
+ sb.append(getStatementPrefix(tableAnnotation));
+ } else {
+ // Process nested entity
+ sb.append(getPadding());
+ sb.append(getNestedEntityPrefix(tableAnnotation));
+ }
+
+ // Increment the nesting (left padding) after processing an entity.
+ incNesting();
+ // Parse each field of the javaType.
+ for (Field f : javaType.getDeclaredFields()) {
+ if (isFieldIncluded(f)) {
+ parseField(f);
+ }
+ }
+ for (ViewEntity ve : nestedEntities) {
+ ve.addViews(views);
+ sb.append(ve.build());
+ }
+ // Close the entity after processing fields.
+ addTrailer(rootSnippet == null);
+ if (manyToMany) {
+ // Add join table trailer if necessary
+ addTrailer(true, ARRAY_TERMINAL);
+ }
+ return this;
+ }
+
+ /**
+ * Parse the javaType and tableAnnotation to generate the view prefix, e.g.,
+ * 'create force editionable json relational duality view my_view as my table @insert @update @delete {}
+ * @param tableAnnotation of the javaType.
+ * @return view prefix String.
+ */
+ private String getStatementPrefix(Table tableAnnotation) {
+ String tableName = getTableName(javaType, tableAnnotation);
+ return "%s %s as %s %s{\n".formatted(
+ rootSnippet.getSnippet(), viewName, tableName, accessMode
+ );
+ }
+
+ private String getNestedEntityPrefix(Table tableAnnotation) {
+ String tableName = getTableName(javaType, tableAnnotation);
+ if (tableName.equals(viewName)) {
+ return "%s %s{\n".formatted(tableName, accessMode);
+ }
+ return "%s%s%s %s{\n".formatted(
+ viewName, SEPARATOR, tableName, accessMode
+ );
+ }
+
+ private void parseField(Field f) {
+ JsonRelationalDualityView dvAnnotation;
+ Id id = f.getAnnotation(Id.class);
+ if (id != null && rootSnippet != null) {
+ // Parse the root entity's _id field.
+ parseId(f);
+ } else if ((dvAnnotation = f.getAnnotation(JsonRelationalDualityView.class)) != null) {
+ // Parse the related sub-entity.
+ parseRelationalEntity(f, dvAnnotation);
+ } else {
+ // Parse the field as a database column.
+ parseColumn(f);
+ }
+ }
+
+ /**
+ * Parse the view's root _id field.
+ * @param f The view's root _id field.
+ */
+ private void parseId(Field f) {
+ String jsonbPropertyName = getJsonbPropertyName(f);
+ if (!jsonbPropertyName.equals(_ID_FIELD)) {
+ throw new IllegalArgumentException("@Id Field %s must be named \"%s\" or annotated with @%s(\"%s\")".formatted(
+ f.getName(),
+ _ID_FIELD,
+ JsonbProperty.class.getSimpleName(),
+ _ID_FIELD
+ ));
+ }
+ // Add the root _id field to the view.
+ addProperty(_ID_FIELD, getDatabaseColumnName(f));
+ }
+
+ private void parseRelationalEntity(Field f, JsonRelationalDualityView dvAnnotation) {
+ Class> entityJavaType = getGenericFieldType(f);
+ if (entityJavaType == null) {
+ throw new IllegalArgumentException("%s %s annotation must include the entity class".formatted(
+ f.getName(), JsonRelationalDualityView.class.getSimpleName()
+ ));
+ }
+
+ // Add join table if present.
+ ManyToMany manyToMany = f.getAnnotation(ManyToMany.class);
+ if (manyToMany != null) {
+ parseManyToMany(manyToMany, dvAnnotation, f, entityJavaType);
+ }
+ // Add nested entity.
+ JoinColumn joinColumn = f.getAnnotation(JoinColumn.class);
+ parseNestedEntity(entityJavaType, dvAnnotation, manyToMany, joinColumn);
+ }
+
+ private boolean visit(String viewName) {
+ boolean visited = views.contains(viewName);
+ views.add(viewName);
+ return visited;
+ }
+
+ /**
+ * Returns the type of f, or parameterized type of f.
+ * @param f to introspect for type information.
+ * @return type of f or parameterized type of f.
+ */
+ private Class> getGenericFieldType(Field f) {
+ Type genericType = f.getGenericType();
+ if (genericType instanceof ParameterizedType p) {
+ Type type = p.getActualTypeArguments()[0];
+ if (type instanceof Class> c) {
+ return c;
+ }
+ throw new IllegalStateException("failed to process type: " + type);
+ }
+ return f.getType();
+ }
+
+ private void parseNestedEntity(Class> entityJavaType,
+ JsonRelationalDualityView dvAnnotation,
+ ManyToMany manyToMany,
+ JoinColumn joinColumn) {
+ Table tableAnnotation = entityJavaType.getAnnotation(Table.class);
+ String viewEntityName = getNestedViewName(entityJavaType, manyToMany == null ? dvAnnotation : null, tableAnnotation);
+ // Prevent infinite recursion
+ if (visit(viewEntityName)) {
+ return;
+ }
+ String accessMode = getAccessModeStr(dvAnnotation.accessMode(), manyToMany, joinColumn);
+ ViewEntity ve = new ViewEntity(entityJavaType,
+ new StringBuilder(),
+ accessMode,
+ viewEntityName,
+ nesting,
+ manyToMany != null
+ );
+ nestedEntities.add(ve);
+ }
+
+ private void parseColumn(Field f) {
+ addProperty(getJsonbPropertyName(f), getDatabaseColumnName(f));
+ }
+
+ private void parseManyToMany(ManyToMany manyToMany, JsonRelationalDualityView dvAnnotation, Field f, Class> entityJavaType) {
+ JoinTable joinTable = getJoinTableAnnotation(f, manyToMany, entityJavaType);
+ String propertyName = dvAnnotation.name();
+ if (!StringUtils.hasText(propertyName)) {
+ propertyName = getJsonbPropertyName(f);
+ }
+ // Don't parse if we've already visited this entity.
+ if (visit(propertyName)) {
+ return;
+ }
+ addProperty(propertyName, joinTable.name(), false);
+ sb.append(" ").append(getAccessModeStr(dvAnnotation.accessMode(), null, null));
+ sb.append(BEGIN_ARRAY);
+ incNesting();
+ }
+
+ private void addProperty(String jsonbPropertyName, String databaseColumnName, boolean addNewLine) {
+ sb.append(getPadding());
+ if (jsonbPropertyName.equals(databaseColumnName)) {
+ sb.append(jsonbPropertyName);
+ } else {
+ sb.append(jsonbPropertyName)
+ .append(SEPARATOR)
+ .append(databaseColumnName);
+ }
+ if (addNewLine) {
+ sb.append("\n");
+ }
+ }
+
+ private void addProperty(String jsonbPropertyName, String databaseColumnName) {
+ addProperty(jsonbPropertyName, databaseColumnName, true);
+ }
+
+ private void addTrailer(boolean addNewLine) {
+ addTrailer(addNewLine, OBJECT_TERMINAL);
+ }
+
+ private void addTrailer(boolean addNewLine, String terminal) {
+ decNesting();
+ if (nesting > 0) {
+ sb.append(getPadding());
+ }
+ sb.append(terminal);
+ if (addNewLine) {
+ sb.append("\n");
+ }
+ }
+
+ private String getPadding() {
+ return " ".repeat(nesting);
+ }
+
+ private void incNesting() {
+ nesting += TAB_WIDTH;
+ }
+
+ private void decNesting() {
+ nesting -= TAB_WIDTH;
+ }
+
+ @Override
+ public String toString() {
+ return sb.toString();
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/Application.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/Application.java
new file mode 100644
index 00000000..e000e3b8
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/Application.java
@@ -0,0 +1,25 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality;
+
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityViewScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+
+@SpringBootApplication
+@EntityScan(basePackages = {
+ "com.oracle.spring.json.duality.model"
+ }
+)
+@JsonRelationalDualityViewScan(
+ basePackages = {
+ "com.oracle.spring.json.duality.model"
+ }
+)
+public class Application {
+ public static void main(String[] args) {
+ SpringApplication.run(Application.class, args);
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/JsonRelationalDualityClient.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/JsonRelationalDualityClient.java
new file mode 100644
index 00000000..f81c1ab1
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/JsonRelationalDualityClient.java
@@ -0,0 +1,52 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality;
+
+import java.util.Optional;
+
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import com.oracle.spring.json.jsonb.JSONB;
+import com.oracle.spring.json.jsonb.JSONBRowMapper;
+import oracle.jdbc.OracleTypes;
+import org.springframework.jdbc.core.simple.JdbcClient;
+import org.springframework.stereotype.Component;
+
+import static com.oracle.spring.json.duality.builder.Annotations.getViewName;
+
+@Component
+public class JsonRelationalDualityClient {
+ private final JdbcClient jdbcClient;
+ private final JSONB jsonb;
+
+ public JsonRelationalDualityClient(JdbcClient jdbcClient, JSONB jsonb) {
+ this.jdbcClient = jdbcClient;
+ this.jsonb = jsonb;
+ }
+
+ public int save(T entity, Class entityJavaType) {
+ String viewName = getViewName(entityJavaType, entityJavaType.getAnnotation(JsonRelationalDualityView.class));
+ final String sql = """
+ insert into %s (data) values (?)
+ """.formatted(viewName);
+
+ byte[] oson = jsonb.toOSON(entity);
+ return jdbcClient.sql(sql)
+ .param(1, oson, OracleTypes.JSON)
+ .update();
+ }
+
+ public Optional findById(Class entityJavaType, ID id) {
+ String viewName = getViewName(entityJavaType, entityJavaType.getAnnotation(JsonRelationalDualityView.class));
+ final String sql = """
+ select * from %s dv
+ where dv.data."_id" = ?
+ """.formatted(viewName);
+
+ JSONBRowMapper rowMapper = new JSONBRowMapper<>(jsonb, entityJavaType);
+ return jdbcClient.sql(sql)
+ .param(1, id)
+ .query(rowMapper)
+ .optional();
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/SpringBootDualityTest.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/SpringBootDualityTest.java
new file mode 100644
index 00000000..41c7265c
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/SpringBootDualityTest.java
@@ -0,0 +1,178 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import com.oracle.spring.json.duality.builder.DualityViewScanner;
+import com.oracle.spring.json.duality.model.book.Book;
+import com.oracle.spring.json.duality.model.book.Loan;
+import com.oracle.spring.json.duality.model.book.Member;
+import com.oracle.spring.json.duality.model.employee.Employee;
+import com.oracle.spring.json.duality.model.movie.Actor;
+import com.oracle.spring.json.duality.model.movie.Director;
+import com.oracle.spring.json.duality.model.movie.DirectorBio;
+import com.oracle.spring.json.duality.model.movie.Movie;
+import com.oracle.spring.json.duality.model.products.Order;
+import com.oracle.spring.json.duality.model.products.Product;
+import com.oracle.spring.json.duality.model.student.Student;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.core.io.ClassPathResource;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.oracle.OracleContainer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+@Testcontainers
+@Disabled
+public class SpringBootDualityTest {
+ public static String readViewFile(String fileName) {
+ try {
+ File file = new ClassPathResource(Path.of("views", fileName).toString()).getFile();
+ return new String(Files.readAllBytes(file.toPath()));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Use a containerized Oracle Database instance for testing.
+ */
+ @Container
+ @ServiceConnection
+ static OracleContainer oracleContainer = new OracleContainer("gvenzl/oracle-free:23.7-slim-faststart")
+ .withStartupTimeout(Duration.ofMinutes(5))
+ .withInitScript("products.sql")
+ .withUsername("testuser")
+ .withPassword("testpwd");
+
+ @Autowired
+ private DualityViewScanner dualityViewScanner;
+
+ @Autowired
+ JsonRelationalDualityClient dvClient;
+
+ @Test
+ void student() {
+ Student s = new Student();
+ s.setId(UUID.randomUUID().toString());
+ s.setFirstName("John");
+ s.setLastName("Doe");
+ s.setCredits(87);
+ s.setEmail("johndoe@example.com");
+ s.setGpa(3.77);
+ s.setMajor("Computer Science");
+
+ int save = dvClient.save(s, Student.class);
+ assertThat(save).isEqualTo(1);
+ Optional byId = dvClient.findById(Student.class, s.getId());
+ assertThat(byId.isPresent()).isTrue();
+ assertThat(byId.get()).isEqualTo(s);
+ }
+
+ @Test
+ void actor() {
+ String actorId = UUID.randomUUID().toString();
+ String directorId = UUID.randomUUID().toString();
+ String movieId = UUID.randomUUID().toString();
+
+ DirectorBio directorBio = new DirectorBio();
+ directorBio.setDirectorId(directorId);
+ directorBio.setBiography("biography");
+
+ Director director = new Director();
+ directorBio.setDirectorId(directorId);
+ director.setDirectorBio(directorBio);
+ director.setFirstName("John");
+ director.setLastName("Doe");
+
+ Movie m = new Movie();
+ m.setMovieId(movieId);
+ m.setTitle("my movie");
+ m.setGenre("action");
+ m.setReleaseYear(1993);
+ dvClient.save(m, Movie.class);
+
+ Actor actor = new Actor();
+ actor.setActorId(actorId);
+ actor.setFirstName("John");
+ actor.setLastName("Doe");
+ actor.setMovies(Set.of(m));
+
+ dvClient.save(actor, Actor.class);
+ Optional actorById = dvClient.findById(Actor.class, actorId);
+ assertThat(actorById.isPresent()).isTrue();
+ }
+
+ @Test
+ void orders() {
+ Product product = new Product();
+ product.setName("my product");
+ product.setPrice(100.00);
+
+ dvClient.save(product, Product.class);
+ Optional productById = dvClient.findById(Product.class, 1);
+ assertThat(productById.isPresent()).isTrue();
+
+ Order order = new Order();
+ order.setProduct(productById.get());
+ order.setQuantity(10);
+
+ dvClient.save(order, Order.class);
+ Optional OrderById = dvClient.findById(Order.class, 1);
+ assertThat(OrderById.isPresent()).isTrue();
+ }
+
+ @Test
+ void books() {
+ Book book = new Book();
+ book.setTitle("my book");
+
+ dvClient.save(book, Book.class);
+
+ Loan loan = new Loan();
+ loan.setBook(book);
+
+ Member member = new Member();
+ member.setFullName("member");
+ member.setLoans(List.of(loan));
+
+ dvClient.save(member, Member.class);
+
+ Optional byId = dvClient.findById(Member.class, 1);
+ assertThat(byId.isPresent()).isTrue();
+ assertThat(byId.get().getFullName()).isEqualTo("member");
+ assertThat(byId.get().getLoans()).hasSize(1);
+ assertThat(byId.get().getLoans().get(0).getBook().getTitle()).isEqualTo("my book");
+ }
+
+ @Test
+ void employees() {
+ Employee manager = new Employee();
+ manager.setName("manager");
+
+ Employee report = new Employee();
+ report.setName("report");
+ manager.setReports(List.of(report));
+ dvClient.save(manager, Employee.class);
+
+ Optional byId = dvClient.findById(Employee.class, 1);
+ assertThat(byId.isPresent()).isTrue();
+ assertThat(byId.get().getReports()).hasSize(1);
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/builder/DualityViewBuilderTest.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/builder/DualityViewBuilderTest.java
new file mode 100644
index 00000000..c477ec21
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/builder/DualityViewBuilderTest.java
@@ -0,0 +1,54 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.builder;
+
+import java.util.stream.Stream;
+
+import com.oracle.spring.json.duality.model.book.Member;
+import com.oracle.spring.json.duality.model.employee.Employee;
+import com.oracle.spring.json.duality.model.movie.Actor;
+import com.oracle.spring.json.duality.model.products.Order;
+import com.oracle.spring.json.duality.model.student.Student;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties;
+import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
+
+import static com.oracle.spring.json.duality.SpringBootDualityTest.readViewFile;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class DualityViewBuilderTest {
+ public static @NotNull Stream entityClasses() {
+ return Stream.of(
+ Arguments.of(Student.class, "student-update.sql", "update"),
+ Arguments.of(Student.class, "student-create.sql", "create"),
+ Arguments.of(Actor.class, "actor-create.sql", "create"),
+ Arguments.of(Order.class, "order-create.sql", "create"),
+ Arguments.of(Member.class, "member-create-drop.sql", "create-drop"),
+ Arguments.of(Employee.class, "employee-create.sql", "create")
+ );
+ }
+
+ @ParameterizedTest(name = "{1} - {0}")
+ @MethodSource("entityClasses")
+ public void buildViews(Class> entity, String viewFile, String ddlAuto) {
+ String expectedView = readViewFile(viewFile);
+ DualityViewBuilder dualityViewBuilder = getDualityViewBuilder(ddlAuto);
+ String actualView = dualityViewBuilder.build(entity);
+ System.out.println(actualView);
+ assertThat(expectedView).isEqualTo(actualView);
+ }
+
+ private DualityViewBuilder getDualityViewBuilder(String ddlAuto) {
+ HibernateProperties hibernateProperties = new HibernateProperties();
+ hibernateProperties.setDdlAuto(ddlAuto);
+ return new DualityViewBuilder(
+ null,
+ new JpaProperties(),
+ hibernateProperties
+ );
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/book/Book.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/book/Book.java
new file mode 100644
index 00000000..1b3213eb
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/book/Book.java
@@ -0,0 +1,53 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+package com.oracle.spring.json.duality.model.book;
+
+import java.util.Objects;
+
+import com.oracle.spring.json.duality.annotation.AccessMode;
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+import static com.oracle.spring.json.duality.builder.Annotations._ID_FIELD;
+
+@Entity
+@Table(name = "books")
+@JsonRelationalDualityView(name = "book_dv", accessMode = @AccessMode(
+ insert = true,
+ update = true
+))
+@Getter
+@Setter
+public class Book {
+
+ @Id
+ @JsonbProperty(_ID_FIELD)
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "book_id")
+ private Long bookId;
+
+ @Column(nullable = false)
+ private String title;
+
+ @Override
+ public final boolean equals(Object o) {
+ if (!(o instanceof Book book)) return false;
+
+ return Objects.equals(bookId, book.bookId) && Objects.equals(title, book.title);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Objects.hashCode(bookId);
+ result = 31 * result + Objects.hashCode(title);
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/book/Loan.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/book/Loan.java
new file mode 100644
index 00000000..8dca9076
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/book/Loan.java
@@ -0,0 +1,65 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+package com.oracle.spring.json.duality.model.book;
+
+import java.util.Objects;
+
+import com.oracle.spring.json.duality.annotation.AccessMode;
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.json.bind.annotation.JsonbTransient;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+import static com.oracle.spring.json.duality.builder.Annotations._ID_FIELD;
+
+@Entity
+@Table(name = "loans")
+@Getter
+@Setter
+@JsonRelationalDualityView(name = "loan_dv", accessMode = @AccessMode(
+ insert = true,
+ update = true
+))
+public class Loan {
+
+ @Id
+ @JsonbProperty(_ID_FIELD)
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "loan_id")
+ private Long loanId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id", nullable = false)
+ @JsonbTransient
+ private Member member;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "book_id", nullable = false)
+ @JsonRelationalDualityView(name = "book", accessMode = @AccessMode(
+ insert = true,
+ update = true
+ ))
+ private Book book;
+
+ @Override
+ public final boolean equals(Object o) {
+ if (!(o instanceof Loan loan)) return false;
+
+ return Objects.equals(getLoanId(), loan.getLoanId());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(getLoanId());
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/book/Member.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/book/Member.java
new file mode 100644
index 00000000..7ae50eed
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/book/Member.java
@@ -0,0 +1,49 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+package com.oracle.spring.json.duality.model.book;
+
+import java.util.List;
+
+import com.oracle.spring.json.duality.annotation.AccessMode;
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+import static com.oracle.spring.json.duality.builder.Annotations._ID_FIELD;
+
+@Entity
+@Table(name = "members")
+@JsonRelationalDualityView(accessMode = @AccessMode(
+ insert = true,
+ update = true,
+ delete = true
+))
+@Getter
+@Setter
+public class Member {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @JsonbProperty(_ID_FIELD)
+ @Column(name = "member_id")
+ private Long memberId;
+
+ @Column(name = "name", nullable = false)
+ private String fullName;
+
+ @JsonRelationalDualityView(name = "loans", accessMode = @AccessMode(
+ insert = true,
+ update = true
+ ))
+ @OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
+ private List loans;
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/Employee.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/Employee.java
new file mode 100644
index 00000000..421b23d8
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/Employee.java
@@ -0,0 +1,70 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+package com.oracle.spring.json.duality.model.employee;
+
+import java.util.List;
+import java.util.Objects;
+
+import com.oracle.spring.json.duality.annotation.AccessMode;
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.json.bind.annotation.JsonbTypeAdapter;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+import static com.oracle.spring.json.duality.builder.Annotations._ID_FIELD;
+
+@Entity
+@Table(name = "employee")
+@JsonRelationalDualityView(name = "employee_dv", accessMode = @AccessMode(
+ insert = true,
+ update = true,
+ delete = true
+))
+@Getter
+@Setter
+public class Employee {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @JsonbProperty(_ID_FIELD)
+ private Long id;
+
+ private String name;
+
+ @ManyToOne
+ @JoinColumn(name = "manager_id", referencedColumnName = "id")
+ @JsonRelationalDualityView(name = "manager", accessMode = @AccessMode(
+ insert = true,
+ update = true
+ ))
+ @JsonbTypeAdapter(ManagerAdapter.class)
+ private Employee manager;
+
+ @OneToMany(mappedBy = "manager")
+ @JsonRelationalDualityView(name = "reports", accessMode = @AccessMode(
+ insert = true,
+ update = true
+ ))
+ @JsonbTypeAdapter(ReportsAdapter.class)
+ private List reports;
+
+ @Override
+ public final boolean equals(Object o) {
+ if (!(o instanceof Employee employee)) return false;
+
+ return Objects.equals(getId(), employee.getId());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(getId());
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/ManagerAdapter.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/ManagerAdapter.java
new file mode 100644
index 00000000..b7996e6d
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/ManagerAdapter.java
@@ -0,0 +1,23 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+package com.oracle.spring.json.duality.model.employee;
+
+import jakarta.json.bind.adapter.JsonbAdapter;
+
+public class ManagerAdapter implements JsonbAdapter {
+ @Override
+ public SimpleEmployee adaptToJson(Employee employee) throws Exception {
+ SimpleEmployee simpleEmployee = new SimpleEmployee();
+ simpleEmployee.set_id(employee.getId());
+ simpleEmployee.setName(employee.getName());
+ return simpleEmployee;
+ }
+
+ @Override
+ public Employee adaptFromJson(SimpleEmployee simpleEmployee) throws Exception {
+ Employee employee = new Employee();
+ employee.setId(simpleEmployee.get_id());
+ employee.setName(simpleEmployee.getName());
+ return employee;
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/ReportsAdapter.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/ReportsAdapter.java
new file mode 100644
index 00000000..508e2243
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/ReportsAdapter.java
@@ -0,0 +1,30 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+package com.oracle.spring.json.duality.model.employee;
+
+import java.util.List;
+
+import jakarta.json.bind.adapter.JsonbAdapter;
+
+public class ReportsAdapter implements JsonbAdapter, List> {
+
+ @Override
+ public List adaptToJson(List employees) throws Exception {
+ return employees.stream().map(e -> {
+ SimpleEmployee simpleEmployee = new SimpleEmployee();
+ simpleEmployee.set_id(e.getId());
+ simpleEmployee.setName(e.getName());
+ return simpleEmployee;
+ }).toList();
+ }
+
+ @Override
+ public List adaptFromJson(List simpleEmployees) throws Exception {
+ return simpleEmployees.stream().map(s -> {
+ Employee employee = new Employee();
+ employee.setId(s.get_id());
+ employee.setName(s.getName());
+ return employee;
+ }).toList();
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/SimpleEmployee.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/SimpleEmployee.java
new file mode 100644
index 00000000..b9324593
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/employee/SimpleEmployee.java
@@ -0,0 +1,13 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+package com.oracle.spring.json.duality.model.employee;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class SimpleEmployee {
+ private Long _id;
+ private String name;
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/Actor.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/Actor.java
new file mode 100644
index 00000000..33ef62ec
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/Actor.java
@@ -0,0 +1,67 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.model.movie;
+
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+import com.oracle.spring.json.duality.annotation.AccessMode;
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.ManyToMany;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+import static com.oracle.spring.json.duality.builder.Annotations._ID_FIELD;
+
+@Entity
+@Table(name = "actor")
+@Getter
+@Setter
+@JsonRelationalDualityView(accessMode = @AccessMode(insert = true))
+public class Actor {
+ @JsonbProperty(_ID_FIELD)
+ @Id
+ @Column(name = "actor_id")
+ private String actorId;
+
+ @Column(name = "first_name", nullable = false, length = 50)
+ private String firstName;
+
+ @Column(name = "last_name", nullable = false, length = 50)
+ private String lastName;
+
+ @ManyToMany(mappedBy = "actors")
+ @JsonRelationalDualityView(name = "movies", accessMode = @AccessMode(insert = true))
+ private Set movies;
+
+ /**
+ * Adds an Actor to a movie, maintaining bidirectional integrity.
+ * @param movie to add the Actor into.
+ */
+ public void addMovie(Movie movie) {
+ if (movies == null) {
+ movies = new HashSet<>();
+ }
+ movies.add(movie);
+ movie.getActors().add(this);
+ }
+
+ @Override
+ public final boolean equals(Object o) {
+ if (!(o instanceof Actor actor)) return false;
+
+ return Objects.equals(getActorId(), actor.getActorId());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(getActorId());
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/Director.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/Director.java
new file mode 100644
index 00000000..9f00169b
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/Director.java
@@ -0,0 +1,73 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.model.movie;
+
+import java.util.Objects;
+import java.util.Set;
+
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.json.bind.annotation.JsonbTransient;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.PrimaryKeyJoinColumn;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+import static com.oracle.spring.json.duality.builder.Annotations._ID_FIELD;
+
+@Entity
+@Table(name = "director")
+@Getter
+@Setter
+public class Director {
+ @JsonbProperty(_ID_FIELD)
+ @Id
+ @Column(name = "director_id")
+ private String directorId;
+
+ @Column(name = "first_name", nullable = false, length = 50)
+ private String firstName;
+
+ @Column(name = "last_name", nullable = false, length = 50)
+ private String lastName;
+
+ @JsonbTransient
+ @OneToMany(mappedBy = "director") // Reference related entity's associated field
+ private Set movies;
+
+ @OneToOne(
+ mappedBy = "director", // Reference related entity's associated field
+ cascade = CascadeType.ALL, // Cascade persistence to the mapped entity
+ orphanRemoval = true // Remove director bio from director if deleted
+ )
+ // The primary key of the Director entity is used as the foreign key of the DirectorBio entity.
+ @PrimaryKeyJoinColumn
+ @JsonbTransient
+ //@JsonRelationalDualityView(name = "directorBio", accessMode = @AccessMode(insert = true))
+ private DirectorBio directorBio;
+
+ public void setDirectorBio(DirectorBio directorBio) {
+ this.directorBio = directorBio;
+ if (directorBio != null) {
+ directorBio.setDirector(this);
+ }
+ }
+
+ @Override
+ public final boolean equals(Object o) {
+ if (!(o instanceof Director director)) return false;
+
+ return Objects.equals(getDirectorId(), director.getDirectorId());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(getDirectorId());
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/DirectorBio.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/DirectorBio.java
new file mode 100644
index 00000000..38ac1757
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/DirectorBio.java
@@ -0,0 +1,53 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.model.movie;
+
+import java.util.Objects;
+
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.json.bind.annotation.JsonbTransient;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.MapsId;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+import static com.oracle.spring.json.duality.builder.Annotations._ID_FIELD;
+
+@Entity
+@Table(name = "director_bio")
+@Getter
+@Setter
+public class DirectorBio {
+ @JsonbProperty(_ID_FIELD)
+ @Id
+ @Column(name = "director_id")
+ private String directorId;
+
+ @OneToOne(fetch = FetchType.LAZY)
+ // The primary key will be copied from the director entity
+ @MapsId
+ @JoinColumn(name = "director_id")
+ @JsonbTransient
+ private Director director;
+
+ @Column(name = "biography", columnDefinition = "CLOB")
+ private String biography;
+
+ @Override
+ public final boolean equals(Object o) {
+ if (!(o instanceof DirectorBio directorBio)) return false;
+ return Objects.equals(getDirectorId(), directorBio.getDirectorId());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(getDirectorId());
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/Movie.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/Movie.java
new file mode 100644
index 00000000..9148ec1a
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/movie/Movie.java
@@ -0,0 +1,72 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.model.movie;
+
+import java.util.Objects;
+import java.util.Set;
+
+import com.oracle.spring.json.duality.annotation.AccessMode;
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.json.bind.annotation.JsonbTransient;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.JoinTable;
+import jakarta.persistence.ManyToMany;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+@Entity
+@Table(name = "movie")
+@Getter
+@Setter
+@JsonRelationalDualityView(name = "movie_dv", accessMode = @AccessMode(
+ insert = true
+))
+public class Movie {
+ @Id
+ @Column(name = "movie_id")
+ @JsonbProperty("_id")
+ private String movieId;
+
+ @Column(name = "title", nullable = false, length = 100)
+ private String title;
+
+ @Column(name = "release_year")
+ private Integer releaseYear;
+
+ @Column(name = "genre", length = 50)
+ private String genre;
+
+ @ManyToOne
+ @JoinColumn(name = "director_id")
+ //@JsonRelationalDualityView(accessMode = @AccessMode(insert = true))
+ @JsonbTransient
+ private Director director;
+
+ @ManyToMany
+ @JsonbTransient
+ @JoinTable(
+ name = "movie_actor",
+ joinColumns = @JoinColumn(name = "movie_id"),
+ inverseJoinColumns = @JoinColumn(name = "actor_id")
+ )
+ private Set actors;
+
+ @Override
+ public final boolean equals(Object o) {
+ if (!(o instanceof Movie movie)) return false;
+
+ return Objects.equals(movieId, movie.movieId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(movieId);
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/products/Order.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/products/Order.java
new file mode 100644
index 00000000..950082c2
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/products/Order.java
@@ -0,0 +1,48 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.model.products;
+
+import com.oracle.spring.json.duality.annotation.AccessMode;
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.persistence.Column;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Date;
+import java.util.Objects;
+
+import static com.oracle.spring.json.duality.builder.Annotations._ID_FIELD;
+
+@Getter
+@Setter
+@JsonRelationalDualityView(accessMode = @AccessMode(insert = true))
+@Table(name = "orders")
+public class Order {
+ @JsonbProperty(_ID_FIELD)
+ @Column(name = "order_id")
+ private Long id;
+ @JsonRelationalDualityView(name = "product")
+ @Column(name = "products")
+ private Product product;
+ private Integer quantity;
+ @Column(name = "order_date")
+ private Date orderDate;
+
+ @Override
+ public final boolean equals(Object o) {
+ if (!(o instanceof Order order)) return false;
+
+ return Objects.equals(getId(), order.getId()) && Objects.equals(getQuantity(), order.getQuantity()) && Objects.equals(getOrderDate(), order.getOrderDate());
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Objects.hashCode(getId());
+ result = 31 * result + Objects.hashCode(getQuantity());
+ result = 31 * result + Objects.hashCode(getOrderDate());
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/products/Product.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/products/Product.java
new file mode 100644
index 00000000..51d3b6e4
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/products/Product.java
@@ -0,0 +1,46 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.model.products;
+
+import java.util.Objects;
+
+import com.oracle.spring.json.duality.annotation.AccessMode;
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.persistence.Column;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+import static com.oracle.spring.json.duality.builder.Annotations._ID_FIELD;
+
+@Getter
+@Setter
+@Table(name = "products")
+@JsonRelationalDualityView(
+ name = "product_dv",
+ accessMode = @AccessMode(insert = true)
+)
+public class Product {
+ @JsonbProperty(_ID_FIELD)
+ @Column(name = "product_id")
+ private Long id;
+ private String name;
+ private Double price;
+
+ @Override
+ public final boolean equals(Object o) {
+ if (!(o instanceof Product product)) return false;
+
+ return Objects.equals(getId(), product.getId()) && Objects.equals(getName(), product.getName()) && Objects.equals(getPrice(), product.getPrice());
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Objects.hashCode(getId());
+ result = 31 * result + Objects.hashCode(getName());
+ result = 31 * result + Objects.hashCode(getPrice());
+ return result;
+ }
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/student/Student.java b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/student/Student.java
new file mode 100644
index 00000000..20f235f6
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/java/com/oracle/spring/json/duality/model/student/Student.java
@@ -0,0 +1,48 @@
+// Copyright (c) 2025, Oracle and/or its affiliates.
+// Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
+
+package com.oracle.spring.json.duality.model.student;
+
+import com.oracle.spring.json.duality.annotation.JsonRelationalDualityView;
+import com.oracle.spring.json.duality.annotation.AccessMode;
+import jakarta.json.bind.annotation.JsonbProperty;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+
+import static com.oracle.spring.json.duality.builder.Annotations._ID_FIELD;
+
+@Entity
+@Table(name = "STUDENT")
+@JsonRelationalDualityView(
+ accessMode = @AccessMode(
+ insert = true,
+ update = true,
+ delete = true
+ )
+)
+@EqualsAndHashCode
+@Getter
+@Setter
+public class Student {
+ @JsonbProperty(_ID_FIELD)
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private String id;
+ @Column(name = "first_name")
+ private String firstName;
+ @Column(name = "last_name")
+ private String lastName;
+ private String email;
+ private String major;
+ private double credits;
+ private double gpa;
+
+ public Student() {}
+}
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/application.yaml b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/application.yaml
new file mode 100644
index 00000000..64c69a27
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/application.yaml
@@ -0,0 +1,17 @@
+spring:
+ datasource:
+ url: ${DB_URL}
+ username: ${DB_USERNAME}
+ password: ${DB_PASSWORD}
+ driver-class-name: oracle.jdbc.OracleDriver
+ type: oracle.ucp.jdbc.PoolDataSource
+ oracleucp:
+ connection-factory-class-name: oracle.jdbc.pool.OracleDataSource
+ connection-pool-name: ConsumerConnectionPool
+ initial-pool-size: 15
+ min-pool-size: 10
+ max-pool-size: 30
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+ show-sql: true
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/products.sql b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/products.sql
new file mode 100644
index 00000000..ced55ae8
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/products.sql
@@ -0,0 +1,15 @@
+create table products (
+ product_id number generated always as identity primary key,
+ name varchar2(255) not null,
+ price number(10,2) not null
+);
+
+create table orders (
+ order_id number generated always as identity primary key,
+ product_id number not null,
+ quantity number not null,
+ order_date date default sysdate,
+ constraint fk_orders_product foreign key (product_id) references products(product_id) on delete cascade
+);
+
+
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/actor-create.sql b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/actor-create.sql
new file mode 100644
index 00000000..34241c5d
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/actor-create.sql
@@ -0,0 +1,13 @@
+create force editionable json relational duality view actor_dv as actor @insert {
+ _id : actor_id
+ firstName : first_name
+ lastName : last_name
+ movies : movie_actor @insert [ {
+ movie @unnest @insert {
+ _id : movie_id
+ title
+ releaseYear : release_year
+ genre
+ }
+ } ]
+ }
\ No newline at end of file
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/employee-create.sql b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/employee-create.sql
new file mode 100644
index 00000000..230e9240
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/employee-create.sql
@@ -0,0 +1,12 @@
+create force editionable json relational duality view employee_dv as employee @insert @update @delete {
+ _id : id
+ name
+ manager : employee @insert @update @link (from : [manager_id]) {
+ _id : id
+ name
+ }
+ reports : employee @insert @update {
+ _id : id
+ name
+ }
+}
\ No newline at end of file
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/member-create-drop.sql b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/member-create-drop.sql
new file mode 100644
index 00000000..59de6297
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/member-create-drop.sql
@@ -0,0 +1,11 @@
+create force editionable json relational duality view members_dv as members @insert @update @delete {
+ _id : member_id
+ fullName : name
+ loans @insert @update {
+ _id : loan_id
+ book : books @insert @update @link (from : [book_id]) {
+ _id : book_id
+ title
+ }
+ }
+}
\ No newline at end of file
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/order-create.sql b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/order-create.sql
new file mode 100644
index 00000000..95d0397f
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/order-create.sql
@@ -0,0 +1,10 @@
+create force editionable json relational duality view orders_dv as orders @insert {
+ _id : order_id
+ quantity
+ orderDate : order_date
+ product : products {
+ _id : product_id
+ name
+ price
+ }
+}
\ No newline at end of file
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/student-create.sql b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/student-create.sql
new file mode 100644
index 00000000..4a683759
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/student-create.sql
@@ -0,0 +1,9 @@
+create force editionable json relational duality view student_dv as student @insert @update @delete {
+ _id : id
+ firstName : first_name
+ lastName : last_name
+ email
+ major
+ credits
+ gpa
+}
\ No newline at end of file
diff --git a/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/student-update.sql b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/student-update.sql
new file mode 100644
index 00000000..6a004f44
--- /dev/null
+++ b/database/starters/oracle-spring-boot-json-relational-duality-views/src/test/resources/views/student-update.sql
@@ -0,0 +1,9 @@
+create or replace force editionable json relational duality view student_dv as student @insert @update @delete {
+ _id : id
+ firstName : first_name
+ lastName : last_name
+ email
+ major
+ credits
+ gpa
+}
\ No newline at end of file
diff --git a/database/starters/oracle-spring-boot-starter-json-collections/pom.xml b/database/starters/oracle-spring-boot-starter-json-collections/pom.xml
index 4cb6043e..23d0a480 100644
--- a/database/starters/oracle-spring-boot-starter-json-collections/pom.xml
+++ b/database/starters/oracle-spring-boot-starter-json-collections/pom.xml
@@ -50,69 +50,8 @@
com.oracle.database.spring
- oracle-spring-boot-starter-ucp
+ oracle-spring-boot-json-data-tools
${project.version}
-
-
- org.springframework.boot
- spring-boot-starter-jdbc
-
-
-
- jakarta.json
- jakarta.json-api
-
-
-
- org.eclipse.parsson
- parsson
-
-
-
- jakarta.json.bind
- jakarta.json.bind-api
-
-
-
- org.eclipse
- yasson
-
-
-
- org.testcontainers
- junit-jupiter
- test
-
-
-
- org.testcontainers
- testcontainers
- test
-
-
-
- org.testcontainers
- oracle-free
- test
-
-
-
- org.springframework.boot
- spring-boot-starter-test
- test
-
-
-
- org.springframework.boot
- spring-boot-starter-data-jdbc
- test
-
-
-
- org.projectlombok
- lombok
- test
-
diff --git a/database/starters/pom.xml b/database/starters/pom.xml
index 88d28d82..5022df9c 100644
--- a/database/starters/pom.xml
+++ b/database/starters/pom.xml
@@ -53,6 +53,8 @@
+ oracle-spring-boot-json-relational-duality-views
+ oracle-spring-boot-json-data-tools
oracle-spring-boot-starter-ucp
oracle-spring-boot-starter-wallet
oracle-spring-boot-starter-aqjms