MicronautでActiveObjectsを利用した軽量ORM実装

# ActiveObjectsとは？

ActiveObjectsは、Atlassian社が提供するJava向けの軽量なオブジェクト関係マッピング（ORM）ライブラリです。主にJiraやConfluenceなどのプラグイン開発向けに設計されており、次のような特徴があります：

- **Entityはインターフェースで定義**し、コードが簡潔
- **DDLの自動生成（migrate）**により、テーブル作成が容易
- **クラス定義ベースでリレーションが構築**でき、@OneToMany や @ManyToMany もサポート
- **シンプルなAPI**でデータアクセスやトランザクションが可能

## 利点

- JPAやHibernateに比べて**構成が軽量**で、学習コストも低い
- **JAR単体で動作可能**なため、外部依存を極力抑えたいアプリに向いている
- MicronautやSpringと併用することで、モダンなDIとも親和性を持たせられる

---

# 概要

Atlassian製品(JiraやConfluence)のプラグイン開発で知られる軽量ORM「ActiveObjects」を、Micronautと連携させた例はほぼ例を見ません。ここでは、MicronautのDI、サービス検索機能に、ActiveObjectsのEntityManagerを利用した構成を簡単に紹介します。

# 環境

- Java 17
- Micronaut 4.8.0
- MariaDB
- ActiveObjects 6.2.0-1ca9fca38
- HikariCP

# Maven設定（pom.xml 抜粋）

```xml
<repositories>
    <repository>
        <id>atlassian-public</id>
        <url>https://packages.atlassian.com/mvn/maven-atlassian-external/</url>
    </repository>
</repositories>

<dependencies>
    <!-- Micronaut + Servlet環境 -->
    <dependency>
        <groupId>io.micronaut</groupId>
        <artifactId>micronaut-http-server</artifactId>
    </dependency>
    <dependency>
        <groupId>io.micronaut.servlet</groupId>
        <artifactId>micronaut-http-server-tomcat</artifactId>
    </dependency>
    <dependency>
        <groupId>io.micronaut.sql</groupId>
        <artifactId>micronaut-jdbc-hikari</artifactId>
    </dependency>
    <dependency>
        <groupId>org.mariadb.jdbc</groupId>
        <artifactId>mariadb-java-client</artifactId>
    </dependency>

    <!-- ActiveObjects本体 -->
    <dependency>
        <groupId>com.atlassian.activeobjects</groupId>
        <artifactId>activeobjects-core</artifactId>
        <version>6.2.0-1ca9fca38</version>
    </dependency>

    <!-- 依存する補助ライブラリ -->
    <dependency>
        <groupId>com.atlassian.plugins</groupId>
        <artifactId>atlassian-plugins-core</artifactId>
        <version>9.0.0-jakarta-m002</version>
    </dependency>
    <dependency>
        <groupId>com.atlassian.sal</groupId>
        <artifactId>sal-api</artifactId>
        <version>7.0.0-jakarta-001</version>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>com.atlassian.profiling</groupId>
        <artifactId>atlassian-profiling</artifactId>
        <version>4.8.5-f8c7987</version>
    </dependency>
</dependencies>
```

# 構成概要

## 1. EntityManagerをラップした ActiveObjects の実装

```java
package jp.livlog.kotonoba.ao;

import java.sql.SQLException;
import java.util.List;
import java.util.Map;

import com.atlassian.activeobjects.external.ActiveObjects;
import com.atlassian.activeobjects.external.ActiveObjectsModuleMetaData;
import com.atlassian.activeobjects.external.FailedFastCountException;
import com.atlassian.sal.api.transaction.TransactionCallback;

import net.java.ao.DBParam;
import net.java.ao.EntityManager;
import net.java.ao.EntityStreamCallback;
import net.java.ao.Query;
import net.java.ao.RawEntity;

public class SimpleActiveObjects implements ActiveObjects {

    private final EntityManager em;

    public SimpleActiveObjects(final EntityManager em) {

        this.em = em;
    }


    public EntityManager getEntityManager() {

        return this.em;
    }


    @SuppressWarnings ("unchecked")
    @Override
    public void migrate(final Class <? extends RawEntity <?>>... entities) {

        try {
            this.em.migrate(entities);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @SuppressWarnings ("unchecked")
    @Override
    public void migrateDestructively(final Class <? extends RawEntity <?>>... entities) {

        try {
            this.em.migrateDestructively(entities);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @SuppressWarnings ("deprecation")
    @Override
    public void flushAll() {

        this.em.flushAll();
    }


    @SuppressWarnings ("deprecation")
    @Override
    public void flush(final RawEntity <?>... entities) {

        this.em.flush(entities);
    }


    @SuppressWarnings ("unchecked")
    @Override
    public <T extends RawEntity <K>, K> T[] get(final Class <T> type, final K... keys) {

        try {
            return this.em.get(type, keys);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T extends RawEntity <K>, K> T get(final Class <T> type, final K key) {

        try {
            return this.em.get(type, key);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T extends RawEntity <K>, K> T create(final Class <T> type, final DBParam... params) {

        try {
            return this.em.create(type, params);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T extends RawEntity <K>, K> T create(final Class <T> type, final Map <String, Object> params) {

        try {
            return this.em.create(type, params);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T extends RawEntity <K>, K> void create(final Class <T> type, final List <Map <String, Object>> rows) {

        try {
            this.em.create(type, rows);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public void delete(final RawEntity <?>... entities) {

        try {
            this.em.delete(entities);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <K> int deleteWithSQL(final Class <? extends RawEntity <K>> type, final String criteria, final Object... parameters) {

        try {
            return this.em.deleteWithSQL(type, criteria, parameters);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T extends RawEntity <K>, K> T[] find(final Class <T> type) {

        try {
            return this.em.find(type);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T extends RawEntity <K>, K> T[] find(final Class <T> type, final String criteria, final Object... parameters) {

        try {
            return this.em.find(type, criteria, parameters);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T extends RawEntity <K>, K> T[] find(final Class <T> type, final Query query) {

        try {
            return this.em.find(type, query);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T extends RawEntity <K>, K> T[] find(final Class <T> type, final String field, final Query query) {

        try {
            return this.em.find(type, field, query);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T extends RawEntity <K>, K> T[] findWithSQL(final Class <T> type, final String keyField, final String sql, final Object... parameters) {

        try {
            return this.em.findWithSQL(type, keyField, sql, parameters);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T extends RawEntity <K>, K> void stream(final Class <T> type, final EntityStreamCallback <T, K> streamCallback) {

        try {
            this.em.stream(type, streamCallback);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T extends RawEntity <K>, K> void stream(final Class <T> type, final Query query, final EntityStreamCallback <T, K> streamCallback) {

        try {
            this.em.stream(type, query, streamCallback);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <K> int count(final Class <? extends RawEntity <K>> type) {

        try {
            return this.em.count(type);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <K> int count(final Class <? extends RawEntity <K>> type, final String criteria, final Object... parameters) {

        try {
            return this.em.count(type, criteria, parameters);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <K> int count(final Class <? extends RawEntity <K>> type, final Query query) {

        try {
            return this.em.count(type, query);
        } catch (final SQLException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <K> int getFastCountEstimate(final Class <? extends RawEntity <K>> type) throws SQLException, FailedFastCountException {

        try {
            return this.em.getFastCountEstimate(type);
        } catch (final SQLException | net.java.ao.FailedFastCountException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public <T> T executeInTransaction(final TransactionCallback <T> callback) {

        throw new UnsupportedOperationException("executeInTransaction(TransactionCallback<T> callback) is not supported in SimpleActiveObjects");
    }


    public <T> T runInTransaction(final CustomTransactionCallback <T> callback) {

        try (var conn = this.em.getProvider().getConnection()) {
            try {
                conn.setAutoCommit(false);
                final var result = callback.doInTransaction();
                conn.commit();
                return result;
            } catch (final Exception ex) {
                conn.rollback();
                throw new RuntimeException("Transaction failed", ex);
            } finally {
                conn.setAutoCommit(true);
            }
        } catch (final SQLException e) {
            throw new RuntimeException("Failed to begin transaction", e);
        }
    }


    @Override
    public ActiveObjectsModuleMetaData moduleMetaData() {

        throw new UnsupportedOperationException("moduleMetaData() is not supported in SimpleActiveObjects");
    }
}


```

```java
package jp.livlog.kotonoba.ao;

/**
 * トランザクション内で実行される処理を定義する関数型インターフェース。
 *
 * @param <T> 戻り値の型
 */
@FunctionalInterface
public interface CustomTransactionCallback<T> {

    /**
     * トランザクション内で実行する処理。
     *
     * @return 処理結果
     * @throws Exception 例外が発生した場合は呼び出し元でロールバックされる
     */
    T doInTransaction() throws Exception;
}

```

## 2. MicronautのファクトリーでDI用Bean生成

```java
package jp.livlog.kotonoba.config;

import javax.sql.DataSource;

import jakarta.inject.Singleton;

import com.zaxxer.hikari.HikariDataSource;

import io.micronaut.context.annotation.Factory;
import jp.livlog.kotonoba.ao.SimpleActiveObjects;
import net.java.ao.builder.EntityManagerBuilderWithUrlAndUsernameAndPassword;

@Factory
public class  ActiveObjectsFactory {

    @Singleton
    public SimpleActiveObjects activeObjects(final DataSource dataSource) {

        final var hikariDataSource = (HikariDataSource) dataSource;
        final var entityManager = new EntityManagerBuilderWithUrlAndUsernameAndPassword(
                hikariDataSource.getJdbcUrl(),
                hikariDataSource.getUsername(),
                hikariDataSource.getPassword())
                        .auto() // 利用可能なConnectionPoolを自動選択
                        .build(); // EntityManagerを構築

        return new SimpleActiveObjects(entityManager);
    }
}
```

## 3. Entityの定義

```java
package jp.livlog.kotonoba.ao.entity;

import net.java.ao.Accessor;
import net.java.ao.Entity;
import net.java.ao.Mutator;
import net.java.ao.Preload;
import net.java.ao.schema.Table;

@Preload
@Table("custom_address_view")
public interface CustomAddressViewEntity extends Entity {

    @Accessor("NAME")
    String getName();
    @Mutator("NAME")
    void setName(String name);

    @Accessor("KANA")
    String getKana();
    @Mutator("KANA")
    void setKana(String kana);

    @Accessor("ROMAN")
    String getRoman();
    @Mutator("ROMAN")
    void setRoman(String roman);

    @Accessor("LONGITUDE")
    double getLongitude();
    @Mutator("LONGITUDE")
    void setLongitude(double longitude);

    @Accessor("LATITUDE")
    double getLatitude();
    @Mutator("LATITUDE")
    void setLatitude(double latitude);

    @Accessor("NEW_OLD_FLG")
    String getNewOldFlg();
    @Mutator("NEW_OLD_FLG")
    void setNewOldFlg(String newOldFlg);

    @Accessor("PREF_CD")
    String getPrefCd();
    @Mutator("PREF_CD")
    void setPrefCd(String prefCd);

    @Accessor("PREF_NAME")
    String getPrefName();
    @Mutator("PREF_NAME")
    void setPrefName(String prefName);

    @Accessor("PREF_KANA")
    String getPrefKana();
    @Mutator("PREF_KANA")
    void setPrefKana(String prefKana);

    @Accessor("CITY_CD")
    String getCityCd();
    @Mutator("CITY_CD")
    void setCityCd(String cityCd);

    @Accessor("CITY_NAME")
    String getCityName();
    @Mutator("CITY_NAME")
    void setCityName(String cityName);

    @Accessor("CITY_KANA")
    String getCityKana();
    @Mutator("CITY_KANA")
    void setCityKana(String cityKana);

    @Accessor("TOWN_CD")
    String getTownCd();
    @Mutator("TOWN_CD")
    void setTownCd(String townCd);

    @Accessor("TOWN_NAME")
    String getTownName();
    @Mutator("TOWN_NAME")
    void setTownName(String townName);

    @Accessor("TOWN_KANA")
    String getTownKana();
    @Mutator("TOWN_KANA")
    void setTownKana(String townKana);

    @Accessor("STREET_CD")
    String getStreetCd();
    @Mutator("STREET_CD")
    void setStreetCd(String streetCd);

    @Accessor("STREET_NAME")
    String getStreetName();
    @Mutator("STREET_NAME")
    void setStreetName(String streetName);

    @Accessor("STREET_KANA")
    String getStreetKana();
    @Mutator("STREET_KANA")
    void setStreetKana(String streetKana);

    @Accessor("LATITUDE2")
    double getLatitude2();
    @Mutator("LATITUDE2")
    void setLatitude2(double latitude2);

    @Accessor("LONGITUDE2")
    double getLongitude2();
    @Mutator("LONGITUDE2")
    void setLongitude2(double longitude2);
}
```

## 4. 検索用サービス

```java
package jp.livlog.kotonoba.ao.service;

import java.util.List;

import jakarta.inject.Inject;
import jakarta.inject.Singleton;

import com.atlassian.activeobjects.external.ActiveObjects;

import jp.livlog.kotonoba.ao.entity.CustomAddressViewEntity;
import net.java.ao.Query;

@Singleton
public class CustomAddressService {

    private final ActiveObjects ao;

    @Inject
    public CustomAddressService(final ActiveObjects ao) {

        this.ao = ao;
    }


    public List <CustomAddressViewEntity> findByWord(final String prefCd, String word, final int search) {

        String condition;
        word = switch (search) {
            case 1: // 前方一致
            {
                condition = "NAME LIKE ? COLLATE utf8mb4_unicode_ci";
                yield word + "%";
            }
            case 2: // 後方一致
            {
                condition = "NAME LIKE ? COLLATE utf8mb4_unicode_ci";
                yield "%" + word;
            }
            default: // 部分一致
            {
                condition = "NAME LIKE ? COLLATE utf8mb4_unicode_ci";
                yield "%" + word + "%";
            }
        };

        if (prefCd != null && !prefCd.trim().isEmpty()) {
            condition += " AND PREF_CD = ?";
            return List.of(this.ao.find(CustomAddressViewEntity.class, Query.select().where(condition, word, prefCd)));
        }
        return List.of(this.ao.find(CustomAddressViewEntity.class, Query.select().where(condition, word)));
    }


    public List <CustomAddressViewEntity> findByYomi(final String prefCd, String yomi, final int search) {

        String condition;
        yomi = switch (search) {
            case 1 -> {
                condition = "KANA LIKE ? COLLATE utf8mb4_unicode_ci";
                yield yomi + "%";
            }
            case 2 -> {
                condition = "KANA LIKE ? COLLATE utf8mb4_unicode_ci";
                yield "%" + yomi;
            }
            default -> {
                condition = "KANA LIKE ? COLLATE utf8mb4_unicode_ci";
                yield "%" + yomi + "%";
            }
        };

        if (prefCd != null && !prefCd.trim().isEmpty()) {
            condition += " AND PREF_CD = ?";
            return List.of(this.ao.find(CustomAddressViewEntity.class, Query.select().where(condition, yomi, prefCd)));
        }
        return List.of(this.ao.find(CustomAddressViewEntity.class, Query.select().where(condition, yomi)));
    }


    public List <CustomAddressViewEntity> findByWordZenKoku(final String word) {

        final var condition = "MATCH(NAME) AGAINST(? IN BOOLEAN MODE)";
        return List.of(this.ao.find(CustomAddressViewEntity.class, Query.select().where(condition, "+" + word)));
    }


    public List <CustomAddressViewEntity> findByYomiZenKoku(final String yomi) {

        final var condition = "MATCH(KANA) AGAINST(? IN BOOLEAN MODE)";
        return List.of(this.ao.find(CustomAddressViewEntity.class, Query.select().where(condition, "+" + yomi)));
    }
}

```

## 5. トランザクションの利用例

`SimpleActiveObjects` では Atlassian の `TransactionCallback<T>` を使わず、独自定義の `CustomTransactionCallback<T>` を使って、ラムダ形式で明快なトランザクション処理を実現できます。

```java
// 例：データの追加と削除をまとめてトランザクション処理
ao.runInTransaction(() -> {
    final var newEntity = ao.create(MyEntity.class);
    newEntity.setName("新しいデータ");
    newEntity.save();

    final var oldEntity = ao.get(MyEntity.class, 1);
    ao.delete(oldEntity);

    return null; // 戻り値不要な場合
});
```

このように、複数の DB 操作を一つのトランザクションとして安全に実行できます。例外が発生した場合は自動的にロールバックされます。

# 特徴

- 非Atlassian環境でActiveObjectsを利用
- Entity定義はインターフェース形式
- MariaDBの全文検索を直接利用
- Micronaut DI/ファクトリーと自然に連携

---

ActiveObjectsは本来 Atlassian製品向けのニッチなORMですが、このように現代的なJavaフレームワークでも十分利用できる例が確立しています。