はじめに
表題の件、かなり昔から知られている問題のようですが、備忘録として整理しておこうと思います。
問題が発生する条件
データベースが PostgreSQL である。
ORM が Hibernate である。
Jakarta Persistence の Lob アノテーションを String 型フィールドに付けている。
@Lob
@Column(name = "description")
private String description;
Lob アノテーションが付けられたフィールドに対応するデータベースカラムの型が OID ではない。典型的には TEXT である。
description TEXT
発生する問題
PostgreSQL の JDBC ドライバは、カラムに OID (Object Identifier) を示す数値が入っていることを期待しているのに、実際の値が数値ではないため (典型的には文字列が入っているため)、"Bad value for type long" というエラーが発生してしまう。
cause:
org.postgresql.util.PSQLException: Bad value for type long : {value}
stack_trace:
org.postgresql.jdbc.PgResultSet.toLong(PgResultSet.java:3389)
org.postgresql.jdbc.PgResultSet.getLong(PgResultSet.java:2629)
org.postgresql.jdbc.PgResultSet.getClob(PgResultSet.java:509)
org.jboss.jca.adapters.jdbc.WrappedResultSet.getClob(WrappedResultSet.java:1060)
org.hibernate.type.descriptor.jdbc.ClobJdbcType$1.doExtract(ClobJdbcType.java:57)
org.hibernate.type.descriptor.jdbc.BasicExtractor.extract(BasicExtractor.java:44)
org.hibernate.sql.results.jdbc.internal.JdbcValuesResultSetImpl.getCurrentRowValue(JdbcValuesResultSetImpl.java:387)
org.hibernate.sql.results.internal.RowProcessingStateStandardImpl.getJdbcValue(RowProcessingStateStandardImpl.java:152)
org.hibernate.sql.results.graph.basic.BasicResultAssembler.extractRawValue(BasicResultAssembler.java:54)
org.hibernate.sql.results.graph.basic.BasicResultAssembler.assemble(BasicResultAssembler.java:60)
org.hibernate.sql.results.graph.embeddable.internal.EmbeddableInitializerImpl.extractRowState(EmbeddableInitializerImpl.java:486)
org.hibernate.sql.results.graph.embeddable.internal.EmbeddableInitializerImpl.resolveInstance(EmbeddableInitializerImpl.java:341)
org.hibernate.sql.results.graph.embeddable.internal.EmbeddableInitializerImpl.resolveInstance(EmbeddableInitializerImpl.java:52)
org.hibernate.sql.results.internal.StandardRowReader.coordinateInitializers(StandardRowReader.java:239)
org.hibernate.sql.results.internal.StandardRowReader.readRow(StandardRowReader.java:141)
org.hibernate.sql.results.spi.ListResultsConsumer.read(ListResultsConsumer.java:249)
org.hibernate.sql.results.spi.ListResultsConsumer.consume(ListResultsConsumer.java:201)
org.hibernate.sql.results.spi.ListResultsConsumer.consume(ListResultsConsumer.java:35)
org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.doExecuteQuery(JdbcSelectExecutorStandardImpl.java:224)
org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.executeQuery(JdbcSelectExecutorStandardImpl.java:102)
org.hibernate.sql.exec.spi.JdbcSelectExecutor.executeQuery(JdbcSelectExecutor.java:91)
org.hibernate.sql.exec.spi.JdbcSelectExecutor.list(JdbcSelectExecutor.java:165)
org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.lambda$new$1(ConcreteSqmSelectQueryPlan.java:152)
org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.withCacheableSqmInterpretation(ConcreteSqmSelectQueryPlan.java:442)
org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.performList(ConcreteSqmSelectQueryPlan.java:362)
org.hibernate.query.sqm.internal.QuerySqmImpl.doList(QuerySqmImpl.java:380)
org.hibernate.query.spi.AbstractSelectionQuery.list(AbstractSelectionQuery.java:143)
org.hibernate.query.spi.AbstractSelectionQuery.getSingleResult(AbstractSelectionQuery.java:275)
問題の原因
この問題は Hibernate もしくは PostgreSQL JDBC ドライバの不具合のせいだと思われることが多いようですが、双方のソースコードを追ったところ、どちらの問題でもなさそうです。
原因は、データベースカラムの型が PostgreSQL の Large Object に対応する型 (OID) ではないことです。
Hibernate 側
Lob アノテーションのついた String フィールドに対応するデータを読む時、Hibernate は java.sql.ResultSet インターフェースの getClob(int) メソッドを呼びます。具体的には、ClobJdbcType.java の getExtractor(JavaType<X>) メソッドの実装が生成する BasicExtractor インスタンスの doExtract(ResultSet, int, WrapperOptions) メソッドが、ResultSet.getClob(int) メソッドを呼びます。
@Override
protected X doExtract(ResultSet rs, int paramIndex, WrapperOptions options) throws SQLException {
return javaType.wrap( rs.getClob( paramIndex ), options );
}
PostgreSQL JDBC ドライバ側
PostgreSQL JDBC ドライバでは、ResultSet インターフェースは PgResultSet.java が実装しています。getClob(int) メソッドの実装は次のようになっています。
@Override
@Pure
public @Nullable Clob getClob(int i) throws SQLException {
byte[] value = getRawValue(i);
if (value == null) {
return null;
}
return makeClob(getLong(i));
}
getClob(int) の実装が最後に呼んでいる makeClob(long) の実装は次のようになっています。
protected Clob makeClob(long oid) throws SQLException {
return new PgClob(connection, oid);
}
これらの実装は、カラムの値を long 型として解釈できること、それが OID であること、を期待しています。
PostgreSQL の Large Object に関するドキュメント Server-Side Functions に出てくる CREATE TABLE 文の raster カラムの定義や、
CREATE TABLE image (
name text,
raster oid
);
それに続く INSERT 文や SELECT 文を見ても、
INSERT INTO image (name, raster)
VALUES ('beautiful image', lo_import('/etc/motd'));
INSERT INTO image (name, raster) -- same as above, but specify OID to use
VALUES ('beautiful image', lo_import('/etc/motd', 68583));
SELECT lo_export(image.raster, '/tmp/motd') FROM image
WHERE name = 'beautiful image';
PostgreSQL の Large Object に対応するカラムに数値が入っているのは、不具合ではなく意図した設計・実装であることが分かります。
どちらも悪くない
Hibernate 側では、インターフェースに基づくコーディングをしています。具体的には java.sql.ResultSet インターフェースの getClob(int) メソッドを呼んでいます。このインターフェースの実装は JDBC ドライバが提供することになっていますから、Hibernate 側の責任ではありません。
一方の PostgreSQL 側では、getClob(int) メソッドを、自身の CLOB (Character Large Object) の定義に沿うように実装しています。これも問題ありません。
解決方法
カラムの型を TEXT から OID に変更すれば問題は解決するでしょう。
Lob アノテーション削除
しかしながら、そもそも本当に Large Object を使いたいのでしょうか?
TEXT は、SQL 標準ではなく PostgreSQL 独自の型で、長さ制限の無い可変長の文字列とされています。単純に「長いテキストを保持したい」だけなのであれば、Large Object ではなく TEXT を使えば済むでしょう。
もしも TEXT で済むのなら、単純に Lob アノテーションを削除するだけで問題は解決します。
Hibernate Type アノテーション type 属性
String フィールドに Hibernate の Type アノテーションを付け、type に "org.hibernate.type.TextType" を指定することで問題が解消されたという報告もあります。
@Lob
@Type(type = "org.hibernate.type.TextType")
しかしながら、これは「型は TextType である」と宣言することで問題を解決しようとするものであり、それは同時に「型は Large Object ではない」と言っていることにもなりますから、そもそも Lob アノテーションをつける意味がありません。
Column アノテーション columnDefinition 属性
Jakarta Persistence の Column アノテーションの columnDefinition 属性を用いてカラムの型を明示する方法を示している例もあります。
@Column(columnDefinition = "text")
しかし、これをやってしまうと、Java のソースコードが特定のデータベースの実装に依存してしまいます。推奨できる方法ではありません。
Lob アノテーションを使う理由
そもそも Lob アノテーションを使う理由は何なのでしょうか?
Jakarta Persistence のドキュメントは Lob アノテーションを次のように説明しています。
A Lob annotation specifies that a persistent property or field should be persisted as a large object to a database-supported large object type. Portable applications should use the Lob annotation when mapping to a database Lob type.
要は、「データを Large Object として保存したい場合に Lob アノテーションを使う」ということです。
しかしながら、私は下記の理由から、深く考えもせずに Lob アノテーションを付けてしまい、問題を踏むことになってしまいました。
【理由1】 Liquibase の CLOB 型を使いたかった
Liquibase を使うと、データベースに依存しない方法でスキーマを定義することができます。Liquibase が定義する抽象度の高い型を用いておけば、実際に使用するデータベースに合わせて、Liquibase が適切な型にマッピングしてくれます。例えば、Liquibase の BOOLEAN 型は、データベースごとに次の型にマッピングされます。
| データベース | 型 |
|---|---|
| IBM DB2 |
BOOLEAN, SMALLINT
|
| Databricks | BOOLEAN |
| MariaDB | TINYINT(1) |
| MS SQL | BIT |
| MySQL |
BIT, TINYINT
|
| Oracle |
BOOLEAN, NUMBER
|
| PostgreSQL | BIT |
| Snowflake | BOOLEAN |
「カラム定義時に最大長をハードコーディングしたくない」という理由から、サイズがかなり大きくなる可能性のある文字列型カラムに対し、私は CLOB 型を指定することにしました。
- column:
name: description
type: CLOB
Liquibase の型対応表によると、CLOB 型は PostgreSQL では TEXT 型にマッピングされます。MariaDB や Oracle など、ネイティブに CLOB 型を持つものは、そのまま CLOB 型にマッピングされます。
CLOB という名称や、ネイティブに CLOB 型を持つデータベースが存在することも考慮し、「Lob アノテーションを付けたほうがよいだろう (少なくとも害はないはず)」と考え、私は Lob アノテーションを付けることにしました。
しかし、このせいで "Bad value for type long" 問題を踏んでしまいました。
理屈的には Liquibase が CLOB 型を OID 型にマッピングすればこの問題は起こらないのですが、OID 型の扱いにくさや TEXT の機能性を考えれば、CLOB を TEXT にマッピングするのは自然であり、この点で Liquibase を責めるのは酷でしょう。逆に、データベースを直接のぞいたときに、文字列が入っていると思っていた箇所に OID が入っていたら困惑してしまいます。 加えて、"PostgreSQL Toast and Working with BLOBs/CLOBs Explained" という記事の IMPORTANT NOTES によると、Large Object の管理はレコード管理とは別に行わなければならないため、運用がかなり厄介なことになるので、むしろ OID 型にされると困ってしまいます。
【理由2】 不完全な fetch を懸念した
過去、Google App Engine 上で JDO (Java Data Objects) を使用していた際に、「長い文字列が部分的にしか fetch されない」という問題に悩まされたことがありました。この問題は、javax.jdo.annotations.Column アノテーションに length を指定することで解決しました (注:解決した理由はよく分かっていません)。
@Persistent
@Column(name = "description", length = 65535)
private String description;
この経験から、「長い文字列の場合、データベース抽象化レイヤーにカラムサイズに関するヒントを与えないと問題が起こりうる」と心配するようになりました。このため、「Lob アノテーションでそのような懸念が払拭されるならありがたい」と思い、Lob アノテーションを安易に付けてしまいました。
一方、Jakarta Persistence では、Basic アノテーションの説明によると、次の条件が満たされない場合は EAGER フェッチになります。
- プロパティベースのアクセス
-
Basicアノテーションを付けてfetchにLAZYを指定
@Basic(fetch=LAZY)
protected String getName() { return name; }
ですので、わざわざ @Basic(fetch=LAZY) と書かない限り、「LAZY フェッチのせいで String フィールドのデータが利用できない」という問題が発生しうる可能性を心配する必要はありません。
結局
私は「最大でもせいぜい数千〜数万文字程度が格納できる可変長文字列型カラム」が欲しかっただけで、「何百メガバイト〜数ギガバイトのデータを格納できる可変長文字列型カラム」が欲しかったわけではありません。そのため、カラムが真の意味で Large Object である必要はありませんでした。
結局、「Lob アノテーションを削除する」という対処で十分でした。逆に言うと、そもそも Lob アノテーションを付ける必要がありませんでした。(安易に Lob アノテーションを付けていなければ、問題調査に時間を使う必要もありませんでした
)
将来、CLOB 型をネイティブでサポートするデータベースを使用したときに問題が起こる可能性はあります。しかし、まずは JDBC ドライバが問題なく対応してくれることを期待するのが筋なので、実際に問題が発生するまでは気にしないことにします。