Oracle native BOOLEAN with Micronaut Data JDBC

Learn how to map Java boolean properties to Oracle Database 23ai native BOOLEAN columns and query them with Micronaut Data JDBC.

Authors: Radovan Radic

Micronaut Version: 5.2.0

1. Getting Started

In this guide, we will create a Micronaut application written in Java.

In this guide, you will build a small task list with Micronaut Data JDBC and Oracle Database. Each task has a completed flag.

You will map that flag to a native Oracle BOOLEAN column and query it with repository methods that Micronaut Data translates to Oracle boolean SQL such as IS TRUE and IS FALSE.

Oracle Database 23.1 and later (including Oracle AI Database 26ai) support the SQL BOOLEAN data type. Earlier releases such as Oracle Database 19c and 21c do not, so Micronaut Data keeps its legacy mapping by default: boolean properties are stored in NUMBER(1) columns and compared with 1 and 0. You opt in to native BOOLEAN by telling Micronaut Data which Oracle version your SQL targets.

2. What you will need

To complete this guide, you will need the following:

3. Solution

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

4. Writing the Application

Create an application using the Micronaut Command Line Interface or with Micronaut Launch.

mn create-app example.micronaut.micronautguide \
    --features=data-jdbc,oracle,serialization-jackson \
    --build=gradle \
    --lang=java \
    --test=junit
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool.
If you don’t specify the --lang argument, Java is used as the language.
If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.

The previous command creates a Micronaut application with the default package example.micronaut in a directory named micronautguide.

If you use Micronaut Launch, select Micronaut Application as application type and add data-jdbc, oracle, and serialization-jackson features.

If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application.

4.1. Oracle Driver

Add also the Oracle Driver

build.gradle
runtimeOnly("com.oracle.database.jdbc:ojdbc11")

4.2. Database Configuration

And the database configuration:

src/main/resources/application.properties
datasources.default.schema-generate=CREATE_DROP
(1)
datasources.default.driver-class-name=oracle.jdbc.OracleDriver
(2)
datasources.default.db-type=oracle
(3)
datasources.default.dialect=ORACLE
1 Use Oracle driver.
2 In order for the database to be properly detected by Micronaut Test Resources.
3 Configure the Oracle dialect.

5. Target Oracle Database 23.1

Micronaut Data uses the target Oracle version in two places, and each has its own setting:

  • Repository SQL is generated when your code is compiled. The target version comes from the build or from the repository annotation.

  • Schema generation runs when the application starts. The target version comes from the datasource configuration.

Configure both. The datasource setting does not change SQL that was already generated for repositories at compilation time, and the compile-time setting does not change the generated DDL.

The target version tells Micronaut Data which SQL features it can use. It does not detect the database version. Use numeric major.minor notation: a major-only value such as 23 means 23.0 and does not enable features that require 23.1.

5.1. Repository SQL

There are two ways to set the target version for repository SQL:

  • For all Oracle repositories of the application, set the micronaut.data.sql.dialect-options.oracle.version option in the build, as shown below. Use this when the whole application runs against Oracle Database 23.1 or later, so the version is configured in one place.

  • For a single repository, set the version member of @JdbcRepository. A repository version takes precedence over the build setting, so you can target an individual repository differently, for example while you migrate a schema.

This guide sets the version on the repository, as you will see in the Repository section, so the generated project works without changes to the build. To target all Oracle repositories instead, configure the build as follows and remove version from the annotation.

The Java compiler does not accept the option as an -A annotation processor argument, because the option name contains hyphens. Pass it as a system property to a forked compiler instead:

build.gradle
tasks.withType(JavaCompile).configureEach {
    options.fork = true
    options.forkOptions.jvmArgs.add("-Dmicronaut.data.sql.dialect-options.oracle.version=23.1")
}

5.2. Schema Generation

Schema generation reads the target version from the datasource configuration:

src/main/resources/application.properties
(1)
datasources.default.dialect-options.version=23.1
1 Generate Oracle 23.1-compatible DDL, so boolean properties are created as BOOLEAN columns instead of NUMBER(1).

5.3. Test Resources

Micronaut Test Resources starts Oracle Database Free, which supports native BOOLEAN, for tests and development:

src/main/resources/application.properties
(1)
test-resources.containers.oracle.image-name=gvenzl/oracle-free
test-resources.containers.oracle.image-tag=latest
test-resources.containers.oracle.startup-timeout=600s
1 Use the full Oracle Database Free image and allow the container enough time to start.

6. Task

Create an entity for a task:

src/main/java/example/micronaut/Task.java
package example.micronaut;

import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import io.micronaut.serde.annotation.Serdeable;

@Serdeable
@MappedEntity("TASK")
public record Task(
    @Id @GeneratedValue @Nullable Long id,
    String title,
    boolean completed) { (1)
}
1 A boolean property. Micronaut Data maps it to a BOOLEAN column because the datasource targets Oracle 23.1.

Schema generation creates the TASK table, and a TASK_SEQ sequence for the generated IDs:

CREATE TABLE "TASK" (
    "ID" NUMBER(19) NOT NULL PRIMARY KEY,
    "TITLE" VARCHAR(255) NOT NULL,
    "COMPLETED" BOOLEAN NOT NULL
)

Without the datasource target version, the COMPLETED column would be created as NUMBER(1).

7. Repository

Create a repository for tasks:

src/main/java/example/micronaut/TaskRepository.java
package example.micronaut;

import io.micronaut.data.annotation.Id;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;

import java.util.List;

@JdbcRepository(dialect = Dialect.ORACLE, version = "23.1") (1)
public interface TaskRepository extends CrudRepository<Task, Long> {

    List<Task> findByCompletedTrue(); (2)

    List<Task> findByCompletedFalse(); (3)

    List<Task> findByCompleted(boolean completed); (4)

    long updateCompleted(@Id Long id, boolean completed); (5)
}
1 Micronaut Data generates SQL for Oracle Database 23.1 at compilation time. Without a target version, the repository uses the legacy mapping even when the database supports BOOLEAN. See Repository SQL.
2 The True suffix generates WHERE (task_."COMPLETED" IS TRUE). With the legacy mapping, the condition is task_."COMPLETED" = 1.
3 The False suffix generates WHERE (task_."COMPLETED" IS FALSE).
4 A boolean parameter generates WHERE (task_."COMPLETED" = ?). Micronaut Data binds the value as a JDBC BOOLEAN, instead of the BIT type it uses for the legacy mapping.
5 A partial update that sets the completed flag of one task and returns the number of updated rows.

When a repository has a target version, Micronaut Data reads the database version on first use. If the database is older than the target version, it logs a warning, because Oracle Database 19c or 21c rejects the generated BOOLEAN SQL.

8. Controller

Expose the tasks as HTTP endpoints:

src/main/java/example/micronaut/TaskController.java
package example.micronaut;

import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.annotation.Put;
import io.micronaut.http.annotation.Status;
import io.micronaut.http.exceptions.HttpStatusException;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;

import java.util.List;

@ExecuteOn(TaskExecutors.BLOCKING) (1)
@Controller("/tasks")
public class TaskController {

    private final TaskRepository taskRepository;

    TaskController(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    @Post
    @Status(HttpStatus.CREATED)
    public Task create(@Body("title") String title) { (2)
        return taskRepository.save(new Task(null, title, false));
    }

    @Get("/open")
    public List<Task> open() {
        return taskRepository.findByCompletedFalse();
    }

    @Get("/completed")
    public List<Task> completed() {
        return taskRepository.findByCompletedTrue();
    }

    @Put("/{id}/complete")
    public Task complete(Long id) {
        if (taskRepository.updateCompleted(id, true) == 0) { (3)
            throw new HttpStatusException(HttpStatus.NOT_FOUND, "Task not found");
        }
        return taskRepository.findById(id).orElseThrow();
    }
}
1 Repository calls block while they wait for the database, so run them on the blocking executor instead of the event loop.
2 Bind the title property of the JSON request body. New tasks are not completed.
3 updateCompleted returns 0 when there is no task with the given ID, so respond with 404 Not Found.

9. Test

Add a test for the column type, the repository queries and the HTTP endpoints:

src/test/java/example/micronaut/OracleBooleanTest.java
package example.micronaut;

import io.micronaut.core.type.Argument;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.transaction.TransactionOperations;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

@MicronautTest(transactional = false) (1)
class OracleBooleanTest {

    @Inject
    TaskRepository taskRepository;

    @Inject
    TransactionOperations<Connection> transactionOperations;

    @Inject
    @Client("/")
    HttpClient httpClient;

    @BeforeEach
    void deleteTasks() {
        taskRepository.deleteAll();
    }

    @Test
    void completedColumnIsNativeBoolean() {
        assertEquals("BOOLEAN", columnDataType("TASK", "COMPLETED")); (2)
    }

    @Test
    void queriesBooleanValues() {
        Task write = taskRepository.save(new Task(null, "Write the guide", false));
        Task review = taskRepository.save(new Task(null, "Review the guide", false));

        assertEquals(1, taskRepository.updateCompleted(write.id(), true)); (3)

        assertEquals(List.of("Write the guide"), titles(taskRepository.findByCompletedTrue())); (4)
        assertEquals(List.of("Review the guide"), titles(taskRepository.findByCompletedFalse()));
        assertEquals(List.of("Write the guide"), titles(taskRepository.findByCompleted(true))); (5)

        assertTrue(taskRepository.findById(write.id()).orElseThrow().completed());
        assertFalse(taskRepository.findById(review.id()).orElseThrow().completed());
    }

    @Test
    void completesTaskOverHttp() {
        BlockingHttpClient client = httpClient.toBlocking();
        Task task = client.retrieve(HttpRequest.POST("/tasks", Map.of("title", "Publish the guide")), Task.class);
        assertFalse(task.completed());

        Task completed = client.retrieve(HttpRequest.PUT("/tasks/" + task.id() + "/complete", ""), Task.class); (6)
        assertTrue(completed.completed());

        assertEquals(List.of("Publish the guide"),
            titles(client.retrieve(HttpRequest.GET("/tasks/completed"), Argument.listOf(Task.class))));
        assertTrue(client.retrieve(HttpRequest.GET("/tasks/open"), Argument.listOf(Task.class)).isEmpty());
    }

    private static List<String> titles(List<Task> tasks) {
        return tasks.stream().map(Task::title).toList();
    }

    private String columnDataType(String table, String column) {
        return transactionOperations.executeRead(status -> {
            String sql = "SELECT data_type FROM user_tab_columns WHERE table_name = ? AND column_name = ?";
            try (PreparedStatement statement = status.getConnection().prepareStatement(sql)) {
                statement.setString(1, table);
                statement.setString(2, column);
                try (ResultSet resultSet = statement.executeQuery()) {
                    return resultSet.next() ? resultSet.getString(1) : null;
                }
            } catch (SQLException e) {
                throw new IllegalStateException(e);
            }
        });
    }
}
1 Disable the test transaction, so the repository and HTTP requests commit their own transactions.
2 Oracle reports the BOOLEAN data type for the COMPLETED column in the USER_TAB_COLUMNS data dictionary view.
3 Complete one of the tasks.
4 IS TRUE and IS FALSE conditions find the completed and the open task.
5 A boolean parameter is bound as a JDBC BOOLEAN.
6 Complete a task over HTTP.

10. Testing the Application

To run the tests:

./gradlew test

Then open build/reports/tests/test/index.html in a browser to see the results.

When you run the tests, Micronaut Test Resources starts an Oracle Database Free container before the application connects.

11. Run the Application

Start the application with ./gradlew run or ./mvnw mn:run, and create two tasks:

curl -X POST http://localhost:8080/tasks \
     -H 'Content-Type: application/json' \
     -d '{"title":"Write the guide"}'
curl -X POST http://localhost:8080/tasks \
     -H 'Content-Type: application/json' \
     -d '{"title":"Review the guide"}'

Complete the first task:

curl -X PUT http://localhost:8080/tasks/1/complete

List the completed and the open tasks:

curl http://localhost:8080/tasks/completed
curl http://localhost:8080/tasks/open

The first request returns Write the guide, and the second returns Review the guide.

To see the SQL that Micronaut Data runs, set the logger.levels.io.micronaut.data.query configuration property to DEBUG.

11.1. Micronaut Test Resources Goals

  • zero-configuration: without adding any configuration, test resources should be spawned and the application configured to use them. Configuration is only required for advanced use cases.

  • classpath isolation: use of test resources shouldn’t leak into your application classpath, nor your test classpath

  • compatible with GraalVM native: if you build a native binary, or run tests in native mode, test resources should be available

  • easy to use: the Micronaut build plugins for Gradle and Maven should handle the complexity of figuring out the dependencies for you

  • extensible: you can implement your own test resources, in case the built-in ones do not cover your use case

  • technology agnostic: while lots of test resources use Testcontainers under the hood, you can use any other technology to create resources

12. Next Steps

Read more about Oracle BOOLEAN Support in Micronaut Data and the Oracle SQL data types.

13. License

All guides are released with an Apache License 2.0 for the code and a Creative Commons Attribution 4.0 license for the writing and media (images).