Java (.javaファイル) のみで利用可能なDB用ライブラリを作ってみた

以前Wicketを使っていた際は、データベースの操作にHibernate(JPA)を利用していたのですが、今一つ気に入らない部分がありました。下記のような部分です。

  • 設定をXMLファイルで書かないといけない。
  • テーブル定義とマッピング定義を分離できない。
  • カラム名等をテキストリテラルで指定する必要がある。

別のO/R Mappingライブラリも探してみたのですが、プリプロセッサでの処理が必要となったり、機能的にいろいろと制限があったり、今一つしっくりとくるものがありませんでした。

JDBCを直接使ったりDbUtils等を利用する方法も考えられるのですが、SQLを書かなければいけない、DBごとの差違の吸収が大変になる等の別の問題が出てきてしまいます。

そこで、これらの不満点を解消するために、データベース操作用のライブラリを自作してみました。ライブラリ開発の方針は下記のようなものです。

  • Java (.javaファイル)のみで利用可能とする。
  • テーブルの作成・更新もJavaから操作可能とする。(プリプロセッサによるDDLの生成、事前のテーブル作成を不要にする)
  • 文字リテラルをなるべく使わなくても良くする。
  • SQLで表現できることはなるべく出来るようにする。

開発力のなさにより、今のペースだと永遠に完成しそうにないので、ひとまず動く状態になったものをProof of conceptとして公開してみました。構成としては、DbUtils + Criteria + Javaクラスでのスキーマ定義という感じになっています。

plaintable - Simple database definition and manipulation library for Java - Google Project Hosting

簡単な使い方を以下に説明します。

テーブル定義

テーブル定義はJavaクラスにより行います。テーブル定義用クラスはSerializableを実装する必要があります。また@Tableアノテーションを付与する必要があります。@Tableのname属性はデータベース上に作成される実際のテーブル名となります。

テーブルのカラムは public static なフィールドとして定義します。各行には自動生成の主キーが必ず付与され、それに対応するSyntheticKeyは必須となります。それ以外に、実装上の都合でSchemaも必須となります。

@Table(name="User")    // required
public class UserTable implements Serializable {

        public static Schema schema;    // required

        public static SyntheticKey id;  // required

        @Attribute(indexed=true, unique=true)
        public static CharAttribute username;

        public static CharAttribute password;

        @Attribute(length=128, nullable=true)
        public static CharAttribute email;

        @Attribute(nullable=true)
        public static IntegerAttribute age;
}

初期化

データベース操作に利用するDatabaseManagerをDatabaseManagerFactory経由で生成します。DatabaseManagerFactoryの内部では、データベースの種類を自動判別します。何種類かのRDBMSを判別するようになっていますが、現在のところMySQLでしか動作を試していません。

        DataSource dataSource = getDataSource();    // RDBMS 固有の方法
        DatabaseManagerFactory factory = new DatabaseManagerFactory(dataSource);
        DatabaseManager databaseManager = factory.getDatabaseManager();

テーブルの登録と作成

ライブラリの利用を開始する前に、テーブル定義をSchemaManagerに登録する必要があります。DatabaseManagerからSchemaManagerを取得し、manageメソッドでテーブル定義クラスを指定します。

その後、syncメソッドでデータベース上に実際のテーブルを作成します。この時、前回からテーブル定義に変更がない場合は、何も実行されません。もしテーブル定義の変更有無に関わらず、テーブルの再作成を行いたい場合は、2番目の引数にtrueを指定します。

SynchronizeModeにALL_DROP_AND_CREATEを指定していると、テーブル定義の変更があった場合は、変更がないテーブルを含めて、全てのテーブルが一旦DROPされます。

syncメソッドの戻り値は、テーブルの更新が行われた場合にtrueとなります。そこで、この値がtrueの場合だけプログラムの初期データを挿入する等の用途に利用することが可能です。

        SchemaManager schemaManager = databaseManager.getSchemaManager();
        schemaManager.manage(UserTable.class);
        schemaManager.manage(MessageTable.class);

        boolean updated = schemaManager.sync(SynchronizeMode.ALL_DROP_AND_CREATE);
        if (updated) {  // テーブルが更新された場合は true
                // 初期データの挿入など…
        }

Select

データの取得・更新は、Sessionオブジェクトを利用して行います。SessionはDatabaseManagerのnewSessionメソッドにより生成します。Sessionの生成後、openメソッドによりトランザクションを開始します。AutoCommitはされませんので、更新等の場合は明示的なcommit, rollbackが必要です。

Selectの条件等はQueryオブジェクトを利用して指定します。Bools, Condsにある条件指定用のメソッドで生成したオブジェクトを、Sessionのaddメソッドで追加していきます。addメソッドで追加された条件は、自動的に AND 指定されたものとして扱われます。この例では、UserTableのusernameが"user1"と完全一致する条件を指定しています。ちなみに、カラム名の部分にスキーマ定義クラスのフィールド名を利用していますので、フィールド名の変更を行っても自動的に反映されます。

Selectの結果取得等は、RowHandlerインターフェイスにより行います。この例では、結果をListにして返すListHandlerを利用しています。ListHandlerはコンストラクタに渡されたMapperを利用して、Select結果の各行をオブジェクトに変換します。BeanMapperは、行をコンストラクタで指定されたJava Beanに変換するMapperです。

        Session session = null;
        try {
                session = databaseManager.newSession();
                session.open();

                Query query = new Query(UserTable.schema);
                query.add(Bools.exact(UserTable.username, "user1"));  // SQL: WHERE username LIKE 'user1'

                Mapper<User> mapper = new BeanMapper<User>(User.class);
                ListHandler<User> handler = new ListHandler<User>(mapper);

                session.select(query, handler);
                List<User> users = handler.getList();

        } catch (PlainTableException e) {
                // error handling
        } finally {
                if (session != null) {
                        session.close();
                }
        }

BeanMapperで利用するJava Bean には、@Mappedアノテーションを付与する必要があります。@Mappedのschema属性には、対応するテーブル定義クラスを指定します。現在のところ、カラム名と同じ名前のプロパティに対してのみ代入が行われます。

@Mapped(schema=UserTable.class)  // specify Table Definition class
public class User {

        private Long id;

        private String username;

        private String password;

        public Long getId() {
                return id;
        }

        public void setId(Long id) {
                this.id = id;
        }

        public String getUsername() {
                return username;
        }

        public void setUsername(String username) {
                this.username = username;
        }

        public String getPassword() {
                return password;
        }

        public void setPassword(String password) {
                this.password = password;
        }

}

Insert と Update

InsertとUpdateでは、RowProvider により行データを指定します。この例では、Java Beanから行データを取り出すBeanRowProviderを利用しています。
Insertでは、挿入された行に対応した主キーが自動生成され、その値が返されます。

        Session session = null;
        try {
                session = databaseManager.newSession();
                session.open();

                User user = new User();
                user.setUsername("user1");
                user.setPassword("passwd");

                BeanRowProvider<User> provider = new BeanRowProvider<User>(user);
                long id = session.insert(provider);  // 自動生成された主キー

                session.commit();
        } catch (PlainTableException e) {
                // error handling
        } finally {
                if (session != null) {
                        session.close();
                }
        }

Updateでは、更新する行の条件をRestrictionにより指定します。Restrictionに指定できる条件はQueryと同じです。idが分かっている行を1行だけ更新する場合は、Restrictionの代わりに、idを直接指定することも可能です。

        Session session = null;
        try {
                session = databaseManager.newSession();
                session.open();

                User user = new User();
                user.setUsername("user");
                user.setPassword("passwd");

                Restriction restriction = new Restriction();
                restriction.add(Bools.eq(UserTable.id, 2));  // SQL: WHERE id = 2

                BeanRowProvider<User> provider = new BeanRowProvider<User>(user);
                long count = session.update(provider, restriction);  // 更新された行の数
                //   OR
                // long count = session.update(provider, 2);  // simple way

                session.commit();
        } catch (PlainTableException e) {
                // error handling
        } finally {
                if (session != null) {
                        session.close();
                }
        }

BeanRowProviderで利用するBeanには、@Providedアノテーションを付与する必要があります。それ以外の部分は、@Mappedで利用したものと同一です。

@Mapped(schema=UserTable.class)
@Provided(schema=UserTable.class)  // mark as provider
public class User {

        private Long id;

        private String username;

        private String password;

        public Long getId() {
                return id;
        }

        public void setId(Long id) {
                this.id = id;
        }

        public String getUsername() {
                return username;
        }

        public void setUsername(String username) {
                this.username = username;
        }

        public String getPassword() {
                return password;
        }

        public void setPassword(String password) {
                this.password = password;
        }

}

Delete

Deleteでは、対象テーブルのschemaオブジェクトと、削除する行の条件をRestrictionで指定します。

        Session session = null;
        try {
                session = databaseManager.newSession();
                session.open();

                Restriction restriction = new Restriction();
                restriction.add(Bools.contain(UserTable.username, "user"));  // SQL: username LIKE '%user%'

                long count = session.delete(UserTable.schema, restriction);  // 削除された行の数

                session.commit();
        } catch (PlainTableException e) {
                // error handling
        } finally {
                if (session != null) {
                        session.close();
                }
        }