※当サイトの記事には、広告・プロモーションが含まれます。

Apache Commons DBUtilsでSELECTしたカラムの値で小数点以下が消える問題

gigazine.net

MetaがP92というTwitterの競合サービスについて検討していることは、ビジネスメディアのMoneycontrolが最初に報じました。Moneycontrolは「MetaがMastodonと相互運用可能な『P29』というコードネームのTwitterの競合サービスを検討している」と報じています。

Twitterの代替となる分散型SNS「P92」をMetaが開発中と報じられる - GIGAZINE

この報道によると、P92はActivityPubをサポートしたスタンドアローンのテキストベースコンテンツアプリになるとのこと。ActivityPubはTwitterのライバルとして頭角を現しつつあるMastodonや、その他のフェデレーションアプリを強化するための分散型ソーシャルネットワーキングプロトコルです。

Twitterの代替となる分散型SNS「P92」をMetaが開発中と報じられる - GIGAZINE

Moneycontrolに情報を提供した人物によると、P92はInstagramブランドとなり、ユーザーはInstagramのユーザー情報を用いてアプリにログインできるようになるとのこと。MoneycontrolはP92の機能について詳しく記された内部文書を入手したとしていますが、その詳細はほとんど明らかにしていません。

Twitterの代替となる分散型SNS「P92」をMetaが開発中と報じられる - GIGAZINE

Instagram使ってない人は使えないんかな?

Apache Commons DBUtilsでSELECTしたカラムの値で小数点以下が消える問題

Apache Commons DBUtilsで、SELECT文を実行した結果、小数点以下が保存されてたカラムの値で、小数点以下の値が消える事象が確認されたんだけど、

stackoverflow.com

Using Oracle 11g.

Reading a number(18, 8) column from it using JDBC ResultSet.getBigDecimal(columnName) - the value is 1, and that is what is returned. However, I want it to return 1.00000000.

Is there a way to get that? I tried ResultSet.getBigDecimal(columnName, 8) (I know it's deprecated, but wanted to try it anyway). Did not work.

https://stackoverflow.com/questions/28328582/java-resultset-getbigdecimal-scale-issue-with-oracle

⇧ 上記サイト様によりますと、まさかのJDBCのバグっぽい気がする...。

JDBCAPIのドキュメントでResultSetを確認してみたところ、

docs.oracle.com

⇧『このResultSetオブジェクトの現在行にある指定された列の値を、完全な精度のjava.math.BigDecimalとして取得します。』って記載なんだけど、「精度」が無視されちゃってるんですな...

似たような事象は、他でも確認されていて、

github.com

⇧ 上記サイト様によりますと、「kafka-connect-jdbc」でもBigDecimalのscaleの問題が起きていたようで、JDBCが原因っぽい...

Apache Commons DBUtilsAPIドキュメントによりますと、

commons.apache.org

⇧ とあって、SQL文を実行してResultSetを取得するクラスとしては、

  • AbstractQueryRunner
    • AsyncQueryRunner
    • QueryRunner

⇧ 上記の「AsyncQueryRunner」か「QueryRunner」のどちらかになりますと。

ちなみに、パッケージの説明には、

Package org.apache.commons.dbutils Description

DbUtils is a small set of classes designed to make working with JDBC easier. JDBC resource cleanup code is mundane, error prone work so these classes abstract out all of the cleanup tasks from your code leaving you with what you really wanted to do with JDBC in the first place: query and update data. This package contains the core classes and interfaces - DbUtils, QueryRunner and the ResultSetHandler interface should be your first items of interest.

https://commons.apache.org/proper/commons-dbutils/apidocs/org/apache/commons/dbutils/package-summary.html

⇧ とあって、JDBCを使い易くというコンセプトらしい。

で、QueryRunnerクラスのドキュメントを確認してみると、

commons.apache.org

Executes SQL queries with pluggable strategies for handling ResultSets. This class is thread safe.

https://commons.apache.org/proper/commons-dbutils/apidocs/org/apache/commons/dbutils/QueryRunner.html

⇧ ということで、SELECT文を実行するメソッドとしては、

  • execute(Connection conn, String sql, ResultSetHandler rsh, Object... params)
  • query(Connection conn, String sql, ResultSetHandler rsh, Object... params)

⇧ 「execute」か「query」のどっちかということになるかと、引数が異なるメソッドが何個かあるのでオーバーロードされてるけども、上記のメソッドの「query」を確認してみたところ、

    /**
     * Calls query after checking the parameters to ensure nothing is null.
     * @param conn The connection to use for the query call.
     * @param closeConn True if the connection should be closed, false otherwise.
     * @param sql The SQL statement to execute.
     * @param params An array of query replacement parameters.  Each row in
     * this array is one set of batch replacement values.
     * @return The results of the query.
     * @throws SQLException If there are database or parameter errors.
     */
    private <T> T query(final Connection conn, final boolean closeConn, final String sql, final ResultSetHandler<T> rsh, final Object... params)
            throws SQLException {
        if (conn == null) {
            throw new SQLException("Null connection");
        }

        if (sql == null) {
            if (closeConn) {
                close(conn);
            }
            throw new SQLException("Null SQL statement");
        }

        if (rsh == null) {
            if (closeConn) {
                close(conn);
            }
            throw new SQLException("Null ResultSetHandler");
        }

        Statement stmt = null;
        ResultSet rs = null;
        T result = null;

        try {
            if (params != null && params.length > 0) {
                final PreparedStatement ps = this.prepareStatement(conn, sql);
                stmt = ps;
                this.fillStatement(ps, params);
                rs = this.wrap(ps.executeQuery());
            } else {
                stmt = conn.createStatement();
                rs = this.wrap(stmt.executeQuery(sql));
            }
            result = rsh.handle(rs);

        } catch (final SQLException e) {
            this.rethrow(e, sql, params);

        } finally {
            closeQuietly(rs);
            closeQuietly(stmt);
            if (closeConn) {
                close(conn);
            }
        }

        return result;
    }

⇧ ハイライトしたあたりが、関係してきそうなのですが、

    /**
     * Wrap the {@code ResultSet} in a decorator before processing it. This
     * implementation returns the {@code ResultSet} it is given without any
     * decoration.
     *
     * <p>
     * Often, the implementation of this method can be done in an anonymous
     * inner class like this:
     * </p>
     *
     * <pre>
     * QueryRunner run = new QueryRunner() {
     *     protected ResultSet wrap(ResultSet rs) {
     *         return StringTrimmedResultSet.wrap(rs);
     *     }
     * };
     * </pre>
     *
     * @param rs
     *            The {@code ResultSet} to decorate; never
     *            {@code null}.
     * @return The {@code ResultSet} wrapped in some decorator.
     */
    protected ResultSet wrap(final ResultSet rs) {
        return rs;
    }    

⇧ wrapメソッドはResultSetをそのまま返してるだけっぽいので、

result = rsh.handle(rs);

⇧ 上記のhandleメソッドの実装を確認する感じになるのだけど、org.apache.commons.dbutils.Interface ResultSetHandler<T>を実装してるクラスが

  • AbstractKeyedHandler
  • AbstractListHandler
  • ArrayHandler
  • ArrayListHandler
  • BaseResultSetHandler
  • BeanHandler
  • BeanListHandler
  • BeanMapHandler
  • ColumnListHandler
  • KeyedHandler
  • MapHandler
  • MapListHandler
  • ScalarHandler

あって、

T handle(ResultSet rs) throws SQLException;

メソッドを実装してるクラスは、

  • BaseResultSetHandler.java
  • BeanHandler.java
  • ScalarHandler.java

あたりになるっぽい。

 

    /**
     * Convert the first row of the {@code ResultSet} into a bean with the
     * {@code Class} given in the constructor.
     * @param rs {@code ResultSet} to process.
     * @return An initialized JavaBean or {@code null} if there were no
     * rows in the {@code ResultSet}.
     *
     * @throws SQLException if a database access error occurs
     * @see org.apache.commons.dbutils.ResultSetHandler#handle(java.sql.ResultSet)
     */
    @Override
    public T handle(final ResultSet rs) throws SQLException {
        return rs.next() ? this.convert.toBean(rs, this.type) : null;
    }    

⇧ で変換処理してるっぽくて、最終的に、

    /**
     * Initializes the fields of the provided bean from the ResultSet.
     * @param <T> The type of bean
     * @param rs The result set.
     * @param bean The bean to be populated.
     * @return An initialized object.
     * @throws SQLException if a database error occurs.
     */
    public <T> T populateBean(final ResultSet rs, final T bean) throws SQLException {
        final PropertyDescriptor[] props = this.propertyDescriptors(bean.getClass());
        final ResultSetMetaData rsmd = rs.getMetaData();
        final int[] columnToProperty = this.mapColumnsToProperties(rsmd, props);

        return populateBean(rs, bean, props, columnToProperty);
    }

    /**
     * This method populates a bean from the ResultSet based upon the underlying meta-data.
     *
     * @param <T> The type of bean
     * @param rs The result set.
     * @param bean The bean to be populated.
     * @param props The property descriptors.
     * @param columnToProperty The column indices in the result set.
     * @return An initialized object.
     * @throws SQLException if a database error occurs.
     */
    private <T> T populateBean(final ResultSet rs, final T bean,
            final PropertyDescriptor[] props, final int[] columnToProperty)
            throws SQLException {

        for (int i = 1; i < columnToProperty.length; i++) {

            if (columnToProperty[i] == PROPERTY_NOT_FOUND) {
                continue;
            }

            final PropertyDescriptor prop = props[columnToProperty[i]];
            final Class<?> propType = prop.getPropertyType();

            Object value = null;
            if (propType != null) {
                value = this.processColumn(rs, i, propType);

                if (value == null && propType.isPrimitive()) {
                    value = primitiveDefaults.get(propType);
                }
            }

            this.callSetter(bean, prop, value);
        }

        return bean;
    }

    /**
     * Calls the setter method on the target object for the given property.
     * If no setter method exists for the property, this method does nothing.
     * @param target The object to set the property on.
     * @param prop The property to set.
     * @param value The value to pass into the setter.
     * @throws SQLException if an error occurs setting the property.
     */
    private void callSetter(final Object target, final PropertyDescriptor prop, Object value)
            throws SQLException {

        final Method setter = getWriteMethod(target, prop, value);

        if (setter == null || setter.getParameterTypes().length != 1) {
            return;
        }

        try {
            final Class<?> firstParam = setter.getParameterTypes()[0];
            for (final PropertyHandler handler : propertyHandlers) {
                if (handler.match(firstParam, value)) {
                    value = handler.apply(firstParam, value);
                    break;
                }
            }

            // Don't call setter if the value object isn't the right type
            if (!this.isCompatibleType(value, firstParam)) {
              throw new SQLException(
                  "Cannot set " + prop.getName() + ": incompatible types, cannot convert "
                  + value.getClass().getName() + " to " + firstParam.getName());
                  // value cannot be null here because isCompatibleType allows null
            }
            setter.invoke(target, value);

        } catch (final IllegalArgumentException | IllegalAccessException | InvocationTargetException e) {
            throw new SQLException(
                "Cannot set " + prop.getName() + ": " + e.getMessage());
        }
    }

⇧ の処理を行っているっぽいのだけど、特にBigDecimalのscaleを調整したりとかしてなさそうに見えますと。

そして、同様の事象が起きていたらしい「kafka-connect-jdbc」の対応を見た限りでは、

github.com

      case Types.DECIMAL: {
        int scale = resultSet.getMetaData().getScale(col);
        BigDecimal bigDecimalValue = resultSet.getBigDecimal(col);
        if (bigDecimalValue == null)
          colValue = null;
        else
          colValue = resultSet.getBigDecimal(col).setScale(scale);    

https://github.com/confluentinc/kafka-connect-jdbc/pull/89/files/2fb62d5707f85b784678af77cf531028d74887ff

⇧ scaleを明示的に設定してるっぽい、何て言うか、この対応だと「kafka-connect-jdbc」のバグと言うよりは、JDBCのバグと言って良い気がするのだけど...

JDBCAPIドキュメントの『このResultSetオブジェクトの現在行にある指定された列の値を、完全な精度のjava.math.BigDecimalとして取得します。』って記載通りにはなっていない気がする...

 resultSet.getBigDecimal(col).setScale(scale);  

⇧ この対応を行う必要がある時点で、JDBCのバグと感じてしまうんだけど...

試しにJDBC単体で動作確認してみたところ、

import static org.junit.Assert.*;

import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Arrays;
import java.util.Properties;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/spring/spring.xml")
public class UserTest {

//	@Autowired
//	private UserRelatedServiceImpl userRelatedServiceImpl;
//	
//	@Autowired
//	private UsersDaoImpl usersDaoImpl;
//	
//	@Autowired
//	private UserDetailDaoImpl userDetailDaoImpl;
//	
//	@Test
//	public void test01() throws SQLException {
//		// テストデータ作成
//		Users users01 = new Users(
//				String.format("%13s",
//						String.valueOf(usersDaoImpl
//								.generateInsertId(userRelatedServiceImpl.createGenerateInsertIdSql("users_seq"))))
//						.replace(" ",
//								"0")
//				, "admin"
//				, Timestamp.valueOf(LocalDateTime.ofInstant(OffsetDateTime.now().toInstant(), ZoneOffset.UTC))
//				);
//		Users users02= new Users(
//				String.format("%13s",
//						String.valueOf(usersDaoImpl
//								.generateInsertId(userRelatedServiceImpl.createGenerateInsertIdSql("users_seq"))))
//						.replace(" ",
//								"0")
//				, "admin"
//				, Timestamp.valueOf(LocalDateTime.ofInstant(OffsetDateTime.now().toInstant(), ZoneOffset.UTC))
//				);
//		Users users03 = new Users(
//				String.format("%13s",
//						String.valueOf(usersDaoImpl
//								.generateInsertId(userRelatedServiceImpl.createGenerateInsertIdSql("users_seq"))))
//						.replace(" ",
//								"0")
//				, "admin"
//				, Timestamp.valueOf(LocalDateTime.ofInstant(OffsetDateTime.now().toInstant(), ZoneOffset.UTC))
//				);
//		Users users04 = new Users(
//				String.format("%13s",
//						String.valueOf(usersDaoImpl
//								.generateInsertId(userRelatedServiceImpl.createGenerateInsertIdSql("users_seq"))))
//						.replace(" ",
//								"0")
//				,"admin"
//				, Timestamp.valueOf(LocalDateTime.ofInstant(OffsetDateTime.now().toInstant(), ZoneOffset.UTC))
//				);
//		
//		List<Users> userList = Arrays.asList(users01, users02, users03, users04);
//		
//		// usersテーブルへの登録処理を実施
//		List<String> userIdList = null;
//		try {
//			userIdList = userRelatedServiceImpl.saveAllUsers(userList);
//		} catch (Exception e) {
//			// テスト失敗
//			fail(Arrays.toString(e.getStackTrace()));
//		}
//		
//		// テストデータ作成
//		final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE;
//		final String deleteFlg = "0";
//		UserDetail userDetail01 = new UserDetail();
//		userDetail01.setUserDetailId(				String.format("%13s",
//						String.valueOf(userDetailDaoImpl
//								.generateInsertId(userRelatedServiceImpl.createGenerateInsertIdSql("user_detail_seq"))))
//						.replace(" ",
//								"0"));
//		userDetail01.setLastName("鈴木");
//		userDetail01.setFirstName("ミッシェル");
//		userDetail01.setBirthday(LocalDate.of(1983, 7, 20).format(dateTimeFormatter));
//		userDetail01.setGender("man");
//		userDetail01.setDeleteFlg(deleteFlg);
//		userDetail01.setInsertUser("admin");
//		userDetail01.setInsertDate(Timestamp.valueOf(LocalDateTime.ofInstant(OffsetDateTime.now().toInstant(), ZoneOffset.UTC)));
//
//		UserDetail userDetail02 = new UserDetail();
//		userDetail02.setUserDetailId(				String.format("%13s",
//						String.valueOf(userDetailDaoImpl
//								.generateInsertId(userRelatedServiceImpl.createGenerateInsertIdSql("user_detail_seq"))))
//						.replace(" ",
//								"0"));
//		userDetail02.setLastName("佐藤");
//		userDetail02.setFirstName("マイク");
//		userDetail02.setBirthday(LocalDate.of(1981, 3, 20).format(dateTimeFormatter));
//		userDetail02.setGender("man");
//		userDetail02.setDeleteFlg(deleteFlg);
//		userDetail02.setInsertUser("admin");
//		userDetail02.setInsertDate(Timestamp.valueOf(LocalDateTime.ofInstant(OffsetDateTime.now().toInstant(), ZoneOffset.UTC)));
//
//		UserDetail userDetail03 = new UserDetail();
//		userDetail03.setUserDetailId(				String.format("%13s",
//						String.valueOf(userDetailDaoImpl
//								.generateInsertId(userRelatedServiceImpl.createGenerateInsertIdSql("user_detail_seq"))))
//						.replace(" ",
//								"0"));
//		userDetail03.setLastName("高橋");
//		userDetail03.setFirstName("スティーブ");
//		userDetail03.setBirthday(LocalDate.of(1980, 11, 20).format(dateTimeFormatter));
//		userDetail03.setGender("man");
//		userDetail03.setDeleteFlg(deleteFlg);
//		userDetail03.setInsertUser("admin");
//		userDetail03.setInsertDate(Timestamp.valueOf(LocalDateTime.ofInstant(OffsetDateTime.now().toInstant(), ZoneOffset.UTC)));
//
//		UserDetail userDetail04 = new UserDetail();
//		userDetail04.setUserDetailId(				String.format("%13s",
//						String.valueOf(userDetailDaoImpl
//								.generateInsertId(userRelatedServiceImpl.createGenerateInsertIdSql("user_detail_seq"))))
//						.replace(" ",
//								"0"));
//		userDetail04.setLastName("田中");
//		userDetail04.setFirstName("メイブ");
//		userDetail04.setBirthday(LocalDate.of(1984, 1, 1).format(dateTimeFormatter));
//		userDetail04.setGender("man");
//		userDetail04.setDeleteFlg(deleteFlg);
//		userDetail04.setInsertUser("admin");
//		userDetail04.setInsertDate(Timestamp.valueOf(LocalDateTime.ofInstant(OffsetDateTime.now().toInstant(), ZoneOffset.UTC)));
//
//		List<UserDetail> userDetailList = Arrays.asList(userDetail01, userDetail02, userDetail03, userDetail04);
//
//		if(!CollectionUtils.isEmpty(userIdList)) {
//			if (userIdList.size() == userDetailList.size()) {
//				for (int index = 0; index < userIdList.size(); index++) {
//					userDetailList.get(index).setUserId(userIdList.get(index));
//				}
//			}
//		}
//
//		// user_detailテーブルへの登録処理を実施
//		try {
//			userRelatedServiceImpl.saveAllUserDetail(userDetailList);
//		} catch (Exception e) {
//			// テスト失敗
//			fail(Arrays.toString(e.getStackTrace()));
//		}
//	}
	
	@Test
	public void test02 () {
		Properties prop = new Properties();

		try {
			prop.load(UserTest.class.getClassLoader().getResourceAsStream("database.properties"));
			String url = prop.getProperty("jdbc.url");
			String user = prop.getProperty("jdbc.username");
			String pass= prop.getProperty("jdbc.password");
		
		    try(Connection conn = DriverManager.getConnection(url, user, pass)) {
		    	Statement stmt = conn.createStatement();
		    	String sql = "SELECT * FROM product_detail";
		    	ResultSet rset = stmt.executeQuery(sql);
		    	
		    	StringBuilder sb = new StringBuilder();
		    	while (rset.next()) {
		    	sb.append(rset.getString(1))
		    	  .append(", ").append(rset.getString(2))
		    	  .append(", ").append(rset.getString(3))
		    	  .append(", ").append(rset.getBigDecimal(4))
		    	  .append(", ").append(rset.getString(5))
		    	  .append(", ").append(rset.getString(6))
		    	  .append(", ").append(rset.getString(7))		    	  
		    	  .append(", ").append(rset.getTimestamp(8))
		    	  .append(", ").append(rset.getString(9))
		    	  .append(", ").append(rset.getTimestamp(10))	
		    	  .append(", ").append(rset.getString(11))
		    	  .append(", ").append(rset.getTimestamp(12))
		    	  .append(System.lineSeparator());
		    	}
		    	System.out.print(sb.toString());
		    } catch (SQLException e) {
				// TODO 自動生成された catch ブロック
		    	fail(Arrays.toString(e.getStackTrace()));
			}
			
		} catch (IOException e) {
			// TODO 自動生成された catch ブロック
			fail(Arrays.toString(e.getStackTrace()));

		}
	}
}

の実行結果。

00000000000000000001, 00000000000000000001, test-product-01, 100, test-product-text-01, 1  , admin, 2023-03-12 17:58:41.561, null, null, 0, null
00000000000000000002, 00000000000000000001, test-product-02, 120, test-product-text-02, 1  , admin, 2023-03-12 17:58:41.561, null, null, 0, null
00000000000000000003, 00000000000000000001, test-product-03, 140, test-product-text-03, 1  , admin, 2023-03-12 17:58:41.561, null, null, 0, null
00000000000000000004, 00000000000000000002, test-product-04, 380, test-product-text-04, 1  , admin, 2023-03-12 17:58:41.561, null, null, 0, null
00000000000000000005, 00000000000000000003, test-product-05, 280, test-product-text-05, 1  , admin, 2023-03-12 17:58:41.561, null, null, 0, null

という感じで、小数点以下が消える問題の犯人は、JDBCだったという...

一応、JDKにバグとして報告を上げておきました、対応してくれるのか分からんけども...。

bugs.java.com

データを変えてみたけども、

⇧ 小数点以下の0終わりが無視されちゃうみたいね...どっちにしろ駄目な気がする...実装の修正とかできないならドキュメントを修正するなどして欲しいかな...

毎度モヤモヤ感が半端ない...

今回はこのへんで。