Edited at

spring.jpa.hibernate.ddl-auto を "create" にした際、テーブルの命名規則に複数回適用される現象の対処策

More than 1 year has passed since last update.


概要

公式のページ にあるように、Spring Data JPA にてエンティティを構成すると、application.properties のプロパティにて、インメモリのDBが接続対象のデータベースとして指定されている場合、アプリケーション実行時に、@Entity アノテーションをつけたクラスのテーブルを作成してくれます。


spring.jpa.hibernate.ddl-auto= # DDL mode. This is actually a shortcut for the "hibernate.hbm2ddl.auto" property. Default to "create-drop" when using an embedded database, "none" otherwise.


こちらのページ にあるように、Create、または、Create-Drop を指定すると、DDL が厳密に設計されていない状態においても、テストに必要なエンティティが DB に作成されるため、テストを迅速に行い、テスト内容を設計に反映させることができますため、大変なメリットを享受することができます。


You can set spring.jpa.hibernate.ddl-auto explicitly and the standard Hibernate property values are none, validate, update, create, create-drop. Spring Boot chooses a default value for you based on whether it thinks your database is embedded (default create-drop) or not (default none). An embedded database is detected by looking at the Connection type: hsqldb, h2 and derby are embedded, the rest are not. Be careful when switching from in-memory to a ‘real’ database that you don’t make assumptions about the existence of the tables and data in the new platform. You either have to set ddl-auto explicitly, or use one of the other mechanisms to initialize the database.


しかしながら、Create を ddl-auto プロパティに指定した際、@Column アノテーションの name 属性にて指定した値でカラム名が生成されないため、JPA にて SQL が実行されるとエラーが発生する現象が発生しました。


詳細

新規開発しているシステムにおいて、システム要件上、やむを得ず、既存システムのDBの接続を行うことを余儀無くされています。

既存DB のテーブルの命名規則は、テーブル名はパスカルケース/列はキャメルケースが採用されておりますが、Spring Data JPA のデフォルトの命名規則はスネークケースであるため、新規開発しているシステムについては、スネークケースで指定したいと考えておりました。


既存のエンティティ


current.sql

CREATE TABLE UserInformation (

userId int
)


UserInformation.java

@Entity

@Embededdable
@Getter
public class UserInformation (
@Id
private int userId;
)


新規エンティティ


new.sql

CREATE TABLE user_new_information (

user_id int
)


UserNewInformation.java

@Entity

@Table(name = "user_new_information")
@Getter
public class UserNewInformation (
@Id
private int id;

@Column(name = "userId")
private int userId;

private UserInformation userInformation;

)



補足

上記のように考えたのは、ドメインモデルを使いまわしたい、と考えたためです。

つまり、UserInformation.userId を新規テーブルに投入する際、順応者的な立場をとり、集約ルートに UserInformation を集約させて、そのまま user_new_information.userId 列にコピーするような形でパーシストする戦略を採用しようとしていたのでした。

(今思うとこれはコンテキストの境界を放棄しようとする怠惰の産物で、実際は腐敗防止層的に、別途別システムアクセス用のクラスを作成して、変換サービスにて新規システム用のクラスにマップすればよかったかと思います。。)

Spring Data JPA のデフォルトの命名規則を採用すると、どうやら強制的にスネークケースでエンティティが生成されるようなので、下記定義を実施し、デフォルトを java にて定義したネーミングルールとして、新規システムのエンティティについては、@Table, @Column の name 属性にてエンティティの命名を実施できるよう PhysicalNamingStrategyStandardImpl を指定しました。


application.properties

spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl


しかしながら、テストを実行すると下記のエラーが発生しました。


error.log

Caused by: com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: Unknown column 'user_new_information0_.userId' in 'field list'

at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at com.mysql.jdbc.Util.handleNewInstance(Util.java:425)
at com.mysql.jdbc.Util.getInstance(Util.java:408)
at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:943)


error.sql

select

user_new_information0_.userId as user_new2_2_0_

from
user_new_information user_new0_


これは、UserInformation に適用されている PhysicalNamingStrategyStandardImpl のネーミング規則が結果的に採用されているためであると考えられます。


現象について

テストを実行して、クライアントツールにて該当テーブルの select を連続して実行していると、どうやら、DDL が 2 度走っている動作になるように思われました。

最初の方で実行している SQL においては、user_new_information テーブルにおいて userId で列が生成されていることが確認できました。

しかしながら、しばらくすると、user_new_information テーブルにおいて user_id に列名が変更されておりました。


回避策について

戦略の 1 つとして、org.h2.tools#RunScript メソッドにてテスト実行後に、テーブルの列名を変更する方法が考えられますが、テーブルが万が一増えた場合、煩雑になるように思われますし、都度都度コードを書くのも面倒です。

また、既存システムの命名規則に合わせてエンティティを定義することが考えられますが、将来的なことを考えて、Spring Data JPA のデフォルトの命名規則を採用したいと考えました。

この現象は、テスト時において発生すると想定されます。(#注) つまり、テーブルの生成の動作そのものに問題が発生するのであり、商用環境においては、テーブル生成の動作は意図されておりません。

そのため、test ディレクトリ配下にカスタム NamingStrategy を 1 つ作り、このクラスにて列名の定義の解決をはかりました。

これにより、テスト時に想定通りの列名にてエンティティが適用され、正常終了させることができました。


TestNamingStrategy.java

public class TestNamingStrategy extends SpringPhysicalNamingStrategy {

private static Class[] classes = {UserInformation.class};
private static List<String> columns = new LinkedList();
private static List<String> tables = new LinkedList();
static {
for (Class clz : classes) {
tables.add(clz.getSimpleName());
for (Field field : clz.getDeclaredFields()) {
columns.add(field.getName());
}
}
}

@Override
public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment context) {
if (tables.contains(name.getText())) return name;
return super.toPhysicalTableName(name, context);
}
@Override
public Identifier toPhysicalColumnName(Identifier name, JdbcEnvironment context) {
if (columns.contains(name.getText())) return name;
return super.toPhysicalColumnName(name, context);
}
}



application-test.properties

spring.jpa.hibernate.naming.physical-strategy=TestNamingStrategy



Spring Data JPA のカラム名の実装まで追っていないので、要検証かと思いました。

想定では、spring.jpa.hibernate.ddl-auto を "validate" にしてテーブル作成の動作を除外すれば、問題は発生しないのではないかと考えていたのですが、もしかしたらデータ取得時のSQLの命名規則にエンティティの @Table, @Column の name 属性で指定した値が反映されないかもしれません。

一度、最初に想定通りのスネークケースでカラム名が指定されているので、この時の列名がキャッシュされて、そのまま validate の動作に移行するのであれば問題はないですが、キャッシュせず都度都度エンティティのパースが走る動作 (都度命名規則の適用が発生する動作) では、同様の問題が発生する可能性があると考えられます。


補足

UserNewInformation クラスにおいて、userId の getter を定義し、列定義を追加しようとしましたが、フィールドに存在しないプロパティについては、該当列の列定義がテーブル上に生成されないため、SQL 実行時にエラーとなってしまいました。


UserNewInformation.java

@Entity

@Table(name = "user_new_information")
@Getter
public class UserNewInformation (
@Id
private int id;

@Column(name = "userId") // 列定義が生成されない
public int getUserId() {
return userInformation.getUserId();
}

private UserInformation userInformation;

)


上記構成の場合、下記のようなエラーが出ます。getUserId にて定義を拾ってくれたら余計な定義を記述しなくてもよかったのですが。。


error.log

Caused by: org.hibernate.PropertyNotFoundException: Could not locate field name [userId] on class [UserNewInformation]

at org.hibernate.internal.util.ReflectHelper.findField(ReflectHelper.java:356)
at org.hibernate.property.access.internal.PropertyAccessFieldImpl.<init>(PropertyAccessFieldImpl.java:34)

UserNewInformation#getUserId において、DB と Java 間においてデータのやりとり方法を明示できる AccessAccessType.Property に設定しても動作は変わりませんでした。


補足2

本件と関連はないと考えられますが、@Table にてアノテーションを指定しない場合、ddl-auto が "create" の場合において、PhysicalNamingStrategyStandardImpl を採用すると、キャメルケース、スネークケースのテーブルが2つ作成される動作となりました。


noTable.java

@Entity

@Getter
public class UserNewInformation (
@Id
private int id;

private int userId;

)



result.sql

CREATE TABLE user_new_information (

user_id int
);
CREATE TABLE UserNewInformation (
userId int
);