読者です 読者をやめる 読者になる 読者になる

Java で クエリ(SELECT) を 安全に改変したい。Doma 編

.NET + SQL Server の組み合わせなら、この blog でも何回か取り上げている ScriptDom があります。
NuGet Gallery | Microsoft.SqlServer.TransactSql.ScriptDom 12.0.1
Microsoft.SqlServer.TransactSql.ScriptDom 名前空間 ()

Java + Oracle でそういうのが無いか調べてみました。

やりたい事:SELECT の WHERE に定型的な条件を追加する。

select
  id
  , name
from 
  table_1
where
  age <= ?
  and age >= ?
order by 
  name desc

みたいなクエリを

select
  id
  , name
from 
  table_1
where
  age <= ?
  and age >= ?
  and delete_flg = 0
order by 
  name desc

な風に変更したいなーという感じです。

とりあえず使い慣れた Doma で出来るか試してみました。
Doma も内部的には Parser と Generator をもっているようなので、一応出来そうです。

package sample;

import java.util.Optional;
import javax.sql.DataSource;
import org.junit.Assert;
import org.junit.Test;
import org.seasar.doma.internal.jdbc.sql.NodePreparedSqlBuilder;
import org.seasar.doma.internal.jdbc.sql.PreparedSql;
import org.seasar.doma.internal.jdbc.sql.SimpleSqlNodeVisitor;
import org.seasar.doma.internal.jdbc.sql.SqlParser;
import org.seasar.doma.internal.jdbc.sql.node.EolNode;
import org.seasar.doma.internal.jdbc.sql.node.FromClauseNode;
import org.seasar.doma.internal.jdbc.sql.node.LogicalOperatorNode;
import org.seasar.doma.internal.jdbc.sql.node.OtherNode;
import org.seasar.doma.internal.jdbc.sql.node.SelectStatementNode;
import org.seasar.doma.internal.jdbc.sql.node.WhereClauseNode;
import org.seasar.doma.internal.jdbc.sql.node.WhitespaceNode;
import org.seasar.doma.internal.jdbc.sql.node.WordNode;
import org.seasar.doma.jdbc.Config;
import org.seasar.doma.jdbc.SqlKind;
import org.seasar.doma.jdbc.SqlNode;
import org.seasar.doma.jdbc.dialect.Dialect;
import org.seasar.doma.jdbc.dialect.Mssql2008Dialect;

public class AppendWhereTransformerTest {
  private String buildSql(String sql) {
    SqlParser parser = new SqlParser(sql);
    SqlNode root = parser.parse();
    root.accept(new AppendWhereTransformer(), null);
    NodePreparedSqlBuilder builder = new NodePreparedSqlBuilder(new TestConfig(), SqlKind.SELECT, null);
    PreparedSql result = builder.build(root, s -> s);
    return result.getRawSql();
  }
  @Test
  public void シンプル() {
    String sql = "select\r\n"
        + "  id\r\n" 
        + "  , name\r\n"
        + "from\r\n"
        + "  table_1";
    String actual = buildSql(sql);
    Assert.assertEquals("select\r\n"
        + "  id\r\n" 
        + "  , name\r\n"
        + "from\r\n"
        + "  table_1\r\n"
        + "where\r\n"
        + "   appendColumn = ?", actual);
  }
  @Test
  public void 条件とソート有() {
    String sql = "select\r\n"
        + "  id\r\n" 
        + "  , name\r\n"
        + "from\r\n"
        + "  table_1\r\n"
        + "where\r\n"
        + "  age <= ?\r\n"
        + "  and age >=?\r\n"
        + "order by\r\n"
        + "  name desc";
    String actual = buildSql(sql);
    Assert.assertEquals("select\r\n"
        + "  id\r\n" 
        + "  , name\r\n"
        + "from\r\n"
        + "  table_1\r\n"
        + "where\r\n"
        + "  age <= ?\r\n"
        + "  and age >=?\r\n"
        + "  and appendColumn = ?\r\n"
        + "order by\r\n"
        + "  name desc", actual);
  }
  @Test
  public void 集計() {
    String sql = "select\r\n"
        + "  id\r\n" 
        + "  , sum(amount)\r\n"
        + "from\r\n"
        + "  table_1\r\n"
        + "where\r\n"
        + "  age <= ?\r\n"
        + "  and age >=?\r\n"
        + "group by\r\n"
        + "  id\r\n"
        + "order by\r\n"
        + "  id desc";
    String actual = buildSql(sql);
    Assert.assertEquals("select\r\n"
        + "  id\r\n" 
        + "  , sum(amount)\r\n"
        + "from\r\n"
        + "  table_1\r\n"
        + "where\r\n"
        + "  age <= ?\r\n"
        + "  and age >=?\r\n"
        + "  and appendColumn = ?\r\n"
        + "group by\r\n"
        + "  id\r\n"
        + "order by\r\n"
        + "  id desc", actual);
  }
  @Test
  public void サブクエリ() {
    String sql = "select\r\n"
        + "  id\r\n" 
        + "  , name\r\n"
        + "  , (select max(table_2.age) from table_2 where table_2.parentId = nest_.id) as MaxAge\r\n"
        + "from\r\n"
        + "  (\r\n"
        + "    select\r\n"
        + "      id\r\n"
        + "      , name\r\n"
        + "      , appendColumn\r\n"
        + "    from\r\n"
        + "      table_1\r\n"
        + "    where"
        + "      age <= ?\r\n"
        + "      and age >= ?\r\n"
        + "  ) nest_\r\n"
        + "order by\r\n"
        + "  name desc";
    String actual = buildSql(sql);
    Assert.assertEquals("select\r\n"
        + "  id\r\n" 
        + "  , name\r\n"
        + "  , (select max(table_2.age) from table_2 where table_2.parentId = nest_.id) as MaxAge\r\n"
        + "from\r\n"
        + "  (\r\n"
        + "    select\r\n"
        + "      id\r\n"
        + "      , name\r\n"
        + "      , appendColumn\r\n"
        + "    from\r\n"
        + "      table_1\r\n"
        + "    where"
        + "      age <= ?\r\n"
        + "      and age >= ?\r\n"
        + "  ) nest_\r\n"
        + "\r\n"
        + "where\r\n"
        + "   appendColumn = ?\r\n"
        + "order by\r\n"
        + "  name desc", actual);
  }
}
class TestConfig implements Config {
  @Override
  public DataSource getDataSource() {
    return null;
  }
  @Override
  public Dialect getDialect() {
    return new Mssql2008Dialect();
  }
}
class AppendWhereTransformer extends SimpleSqlNodeVisitor<SqlNode, Void> {
  @Override
  protected SqlNode defaultAction(SqlNode node, Void p) {
    node.getChildren().forEach(n -> n.accept(this, p));
    return node;
  }
  
  @Override
  public SqlNode visitSelectStatementNode(SelectStatementNode node, Void p) {
    WhereClauseNode whereNode = Optional.ofNullable(node.getWhereClauseNode())
        .orElseGet(() -> {
          WhereClauseNode n = new WhereClauseNode("where");
          n.appendNode(new EolNode("\r\n"));
          // setWhereClauseNode にすると、sql の一番下に where が追加されてしまう。order by や group by があると困るー。
          FromClauseNode from = node.getFromClauseNode();
          from.appendNode(new EolNode("\r\n"));
          from.appendNode(n);
          return n;
        });
    
    whereNode.appendNode(WhitespaceNode.of(" "));
    whereNode.appendNode(WhitespaceNode.of(" "));
    whereNode.appendNode(new LogicalOperatorNode("and"));
    whereNode.appendNode(WhitespaceNode.of(" "));
    whereNode.appendNode(new WordNode("appendColumn"));
    whereNode.appendNode(WhitespaceNode.of(" "));
    whereNode.appendNode(OtherNode.of("="));
    whereNode.appendNode(WhitespaceNode.of(" "));
    whereNode.appendNode(new WordNode("?"));
    whereNode.appendNode(new EolNode("\r\n"));
    return node;
  }
}

Java で見やすいクエリ書くの面倒!Xtend か Groovy なら見やすく書けるのに!

とりあえず出来ましたが、若干怖い感じ。
Where 句が無い時に、SelectStatementNode#setWhereClauseNode で Where 句を追加してしまうと、間違ったクエリになる事があります。
SelectStatementNode の set~ClauseNode の実装は、呼ばれたたびに後ろに追加していく様になっているので、Order By 等があると Order By の後に Where が追加されてしまいます。
なので、致し方無く From の最後に Where 追加してる無理やり感。。

もちろん Doma はこういう使い方するものではないので全然構わないのですが。

というわけで、次は別のライブラリで試してみます。
一番良いのは、ANTLR なんでしょうが難しそうだしなぁ。。

2015/02/12 追記:
Doma の作者の @ さんに教えてもらいました。

こんな感じで SelectStatementNode を差換えると、問題無さそうですね!

SelectStatementNode result = new SelectStatementNode();
result.setSelectClauseNode(node.getSelectClauseNode());
result.setFromClauseNode(node.getFromClauseNode());
result.setWhereClauseNode(node.getWhereClauseNode());
result.setGroupByClauseNode(node.getGroupByClauseNode());
result.setHavingClauseNode(node.getHavingClauseNode());
result.setOrderByClauseNode(node.getOrderByClauseNode());
result.setForUpdateClauseNode(forUpdate);
return result;