SetupSelect(Relation)

概要

基本概念

関連テーブル (相手が一件になるもの: many-to-one, one-to-one など) を取得 するために、その関連テーブルの(全ての)カラムを select 句に展開します(結合処理は内部的に解決)。すると、取得した基本テーブルの Entity から、指定した関連テーブルの(検索されたデータが格納された) Entity を取得することができます。ConditionBeanの中で最も基本となる機能の一つです。

会話上では、せっとあっぷせれくと と表現します。SetupSelect(Relation) は、関連テーブルのみ対する機能なので、りれーしょん を入れなくても伝わりやすいです。(ドキュメント上も SetupSelect と表現することが多いです)

実装方法

実装の流れ

setupSelect_[relation-table]() を呼び出します。

e.g. SetupSelectの実装手順 (Eclipseでコード補完) {MEMBER_STATUS} @Java
MemberCB cb = new MemberCB();
cb.se // ".se" とだけ打ってメソッド(関連テーブル)を選んで enter
--
cb.setupSelect_MemberStatus();
List<Member> memberList = memberBhv.selectList(cb);
for (Member member : memberList) {
    MemberStatus status = member.getMemberStatus();
    ... = status.getMemberStatusName();
}
e.g. SetupSelectを利用したときのSQL {MEMBER_STATUS} @DisplaySql
select dfloc.MEMBER_ID, dfloc.MEMBER_NAME, ...
     , dfrel_0.MEMBER_STATUS_CODE, dfrel_0.MEMBER_STATUS_NAME, ...
  from MEMBER dfloc
    left outer join MEMBER_STATUS dfrel_0 on ... 

さらにネストした関連テーブルを取得する場合は、続けて with[nested-relation-table]() を呼び出します(以降、ネストは with 始まり)。

e.g. ネストした関連テーブル (Eclipseでコード補完) {MEMBER_WITHDRAWAL,...} @Java
MemberCB cb = new MemberCB();
cb.seSMW // ".seSMW" と打って enter
--
cb.setupSelect_MemberWithdrawalAsOne().wiWR // ".wiWR" と打って enter
--
cb.setupSelect_MemberWithdrawalAsOne().withWithdrawalReason();
List<Member> memberList = memberBhv.selectList(cb);
for (Member member : memberList) {
    MemberWithdrawal withdrawal = member.getMemberWithdrawalAsOne();
    WithdrawalReason reason = withdrawal.getWithdrawalReason();
    ... = reason.getWithdrawalReasonText();
}
e.g. SetupSelectを利用したときのSQL {MEMBER_WITHDRAWAL,...} @DisplaySql
select dfloc.MEMBER_ID, dfloc.MEMBER_NAME, ...
     , dfrel_0.MEMBER_ID, dfrel_0.WITHDRAWAL_REASON_CODE, ...
     , dfrel_1.WITHDRAWAL_REASON_CODE, dfrel_1...
  from MEMBER dfloc
    left outer join MEMBER_WITHDRAWAL dfrel_0 on ... 
    left outer join WITHDRAWAL_REASON dfrel_1 on ... 

Java8 ならリレーションも Optional

OptionalEntityの使い方

Java8であれば(1.0.xでもisCompatibleBeforeJava8をfalseにしていれば)、 関連テーブルの Entity を get するときの戻り値 "も" OptionalEntity となります。

この関連テーブル、必ず存在するかな? という風にカージナリティを自然に意識することができます。また、存在しないかもしれないデータを get() してしまったり、SetupSelectし忘れて get() してしまったりしたときの例外が NullPo ではなく、専用の例外となってデバッグしやすいようになっています。

要領は、selectEntity()の方とほぼ同じで、スタイルごとにうまく使い分けるとよいでしょう。 ただ、関連テーブルならではの特徴もあるので、このあとのExampleコードにぜひ注目を!

一発処理でOKスタイル
その場で利用しておしまいなケース
なんども登場スタイル
そのあと何度も利用するようなケース
みんなで勢ぞろいスタイル
入りみだれて利用するようなケース
トラディショナルスタイル
いざとなったら or 迷って手が止まるくらいだったら
スタイル入りみだれスタイル
(後で登場します)
e.g. 関連テーブルにおける OptionalEntity の使い方 @Java
Member member = memberBhv.selectEntity(cb -> {
    cb.setupSelect_MemberStatus(); // 会員ステータス

    // 会員セキュリティ情報: 要らなければ取らない
    //cb.setupSelect_MemberSecurityAsOne();

    // 会員サービス & サービスランク (必須 => 必須)
    cb.setupSelect_MemberServiceAsOne().withServiceRank();

    // 会員退会情報 & 退会理由 (非必須 => 非必須)
    cb.setupSelect_MemberWithdrawalAsOne().withWithdrawalReason();

    // 会員住所の業務的one-to-one & 地域 (非必須 => 必須)
    cb.setupSelect_MemberAddressAsValid(current).withRegion();

    cb.query().setMemberId_Equal(1);
}).get(); // わかりやすくするために、基点テーブルは素のEntityにしちゃいますね

// - - - - - - - - - - - - - - - - - - 一発処理でOKスタイル!
// (その場で利用しておしまいなケース)
//

// 業務的に必ず存在するなら (なければ例外)
member.getMemberStatus().alwaysPresent(status -> {
    ... = status.getMemberStatusName();
    ... = status.getDisplayOrder();
});

// 存在しないこともあるなら (なければ素通り)
member.getMemberStatus().ifPresent(status -> {
    ... = status.getMemberStatusName();
    ... = status.getDisplayOrder();
});

// 存在しないこともあって、"あり、なし" で分岐したいなら
member.getMemberStatus().ifPresent(status -> {
    ... = status.getMemberStatusName();
    ... = status.getDisplayOrder();
}).orElse(() -> {
    // 存在しないときの処理をする
});

// そのまま他のBeanに詰め替えてインスタンス変数に設定するなら
// (業務的に必ず存在するなら)
member.getMemberStatus().map(status -> {
    StatusWebBean bean = new StatusWebBean();
    bean.setStatusCode(status.getMemberStatusCode());
    bean.setStatusName(status.getMemberStatusName());
    bean.setOrder(status.getDisplayOrder());
    return bean;
}).alwaysPresent(bean -> { // map()の戻り値がまたOptionalなので...
    statusWebBean = bean;
});
...

// それぞれの項目を直接インスタンス変数に設定するなら
// (存在しないこともあるなら)
member.getMemberWithdrawal().ifPresent(withdrawal -> {
    withdrawalDatetime = withdrawal.getWithdrawalDatetime();
    withdrawalInputText = withdrawal.getWithdrawalReasonInputText();
    withdrawal.ifPresent(reason -> {
        withdrawalSelectedText = reason.getWithdrawalReasonText());
    }
});

// - - - - - - - - - - - - - - - - - - なんども登場スタイル!
// (そのあと何度も利用するようなケース)
//

// 業務的に必ず存在して、そのあと何度も登場するなら (なければ例外)
MemberStatus status = member.getMemberStatus().get();
... = status.getMemberStatusCode();
...
if (...) {
    ... = status.getMemberStatusName();
}
...
seaLogic.land(status.getDisplayOrder());
if (status.isMemberStatusCodeFormalized()) {
    ...
} else {
    for (...) {
        ... = status.getDescription();
    }
}
...

// 存在しないこともあって、そのあと何度も登場するなら
// (Optionalのメソッドを二回以上呼ぶなら、Optionalのまま変数で受けとる)
OptionalEntity<MemberAddress> optAddress
                        = member.getMemberAddressAsValid();
optAddress.ifPresent(address -> {
    ... = address.getValidBeginDate()
});
...
seaLogic.land(...);
optAddress.ifPresent(address -> {
    ... = address.getValidEndDate();
    address.getRegion().alwaysPresent(region -> {
        ... = region.getRegionName();
    });
}.orElse(() -> {
    ...
});
...

// - - - - - - - - - - - - - - - - - - みんなで勢ぞろいスタイル!
// (入り乱れて利用するようなケース)
//

// 業務的に必ず存在するものは、get()で取っちゃって
// 存在しないこともあるものは、Optionalのまま取っちゃって
MemberStatus status = member.getMemberStatus().get();
MemberService service = member.getMemberServiceAsOne().get();
ServiceRank rank = service.getServiceRank().get();
OptionalEntity<MemberWithdrawal> optWithdrawal
                        = member.getMemberWithdrawal();

// いりみだれー
Integer displayOrder = status.getDisplayOrder();
Integer pointCount = service.getServicePointCount();
if (displayOrder >= 2 && pointCount < 1000) {
    optWithdrawal.ifPresent(withdrawal -> {
        seaLogic.land(status.getDescription()
                , pointCount, withdrawal.getWithdrawalDatetime());
    }.orElse(() -> {
        seaLogic.iks(birthdate, productName);
    }
} else {
    optWithdrawal.ifPresent(withdrawal -> {
        withdrawal.getWithdrawalReason().ifPresent(reason -> {
            seaLogic.amphi(displayOrder, pointCount, reason);
        }).orElse(() -> {
            seaLogic.ambassa(displayOrder, rank);
        });
    });
    seaLogic.miraco(status.getMemberStatusName());
}
...

// - - - - - - - - - - - - - - - - - - トラディショナルスタイル!
// (いざとなったら or 迷って手が止まるくらいだったら)
//

// 存在しないこともあって、"あり、なし" で分岐したり
// (Optionalのメソッドを二回以上呼ぶならやっぱり変数で受け取る)
OptionalEntity<MemberWithdrwal> optWithdrwal
                        = member.getMemberWithdrawal();
if (optWithdrwal.isPresent()) {
    MemberWithdrwal withdrwal = optWithdrwal.get();
    ... = withdrwal.getMemberId();
    ... = withdrwal.getWithdrawalDatetime();
} else {
    // 存在しないときの処理をここで
}

// 業務的に必ず存在して、単に中のEntityを取り出すなら (なければ例外)
// (他のメソッドにがそのままEntity渡す必要があるときなど)
MemberStatus status = member.getMemberStatus().get();
seaLogic.land(status); // Entityで受け取って、nullを受け付けないなら

// 存在しないかもしれなくって、単に中のEntityを取り出すなら (なければnull)
// (他のメソッドにnullありのEntity渡す必要があるとき)
MemberWithdrwal wdl = member.getMemberWithdrawal().orElseNull();
seaLogic.land(wdl); // Entityで受け取って、null分岐を中でやっているなら

さすがに関連テーブルのOptionalの変数名は...

関連テーブルの Optional を変数に受け取るときの変数名は、さすがに entity のままではつらいでしょう。他のテーブルも登場する可能性大だし、そこに優劣があまり付けづらいので。 (entityというアバウトな名前を付けていいのは、基点テーブルだけと考えた方がよいでしょう。もちろん、そもそも基点テーブルもちゃんとした名前をつける方がGoodです)

素直に optStatus とか optWithdrawal とか。もちろん、逆 (statusOpt, withdrawalOpt) でもOKです。jfluteは、その後 status とか withdrawal とか Lambda 式の中で宣言することを考えると、補完のノイズを無くすために opt はどちらかといえば前に付けるかもですね。

Java8の文法では、Lambdaの中で変数の宣言が必ず必要です。Scalaのアンスコドット "_." やGroovyのような "it" があれば、Optional自体に status や withdrawal を付けてもいいかもというところですが、それはできないので Optional と中身のEntityで名前を区別できるようにした方が現時点では安心かもしれません。

すぐに詰め替えるなら一発処理でOKスタイル

検索した後すぐに別のクラスに詰め替えてしまうのであれば、そもそも Optional の変数として受け取らず、一発処理でOKスタイル で書くとよいでしょう。

e.g. すぐに別のクラスに詰め替えてしまうのであれば、一発処理でOKスタイル! @Java
// そのまま他のBeanに詰め替えてインスタンス変数に設定するなら
// (業務的に必ず存在するなら)
member.getMemberStatus().map(status -> {
    StatusWebBean bean = new StatusWebBean();
    bean.setStatusCode(status.getMemberStatusCode());
    bean.setStatusName(status.getMemberStatusName());
    bean.setOrder(status.getDisplayOrder());
    return bean;
}).alwaysPresent(bean -> { // map()の戻り値がまたOptionalなので...
    statusWebBean = bean;
});

// それぞれの項目を直接インスタンス変数に設定するなら
// (存在しないこともあるなら)
member.getMemberWithdrawal().ifPresent(withdrawal -> {
    withdrawalDatetime = withdrawal.getWithdrawalDatetime();
    withdrawalInputText = withdrawal.getWithdrawalReasonInputText();
    withdrawal.ifPresent(reason -> {
        withdrawalSelectedText = reason.getWithdrawalReasonText());
    }
});

詰め替えをするときは、他のテーブルもたくさん出てきて "変数カオス" になりやすいので、Lambdaのコールバックを使うことで自然とスコープを限定されるので、安全性と可読性の向上が期待できます。 (会員ステータスの詰め替え、退会情報系の詰め替えと自然とスコープで分かれる)

スタイル入りみだれスタイル

もし、詰め替えの処理の中で、他の関連テーブルの情報が必要であっても、ちょっとだけなら関連テーブルの Optional を二回呼んでもOKかと思います。 (よほどの "いりみだれ" でなければ)

e.g. Optionalを二回 get してもまあ、ちょっとくらいならOK @Java
// 会員ステータス情報の詰め替え
member.getMemberStatus().alwaysPresent(status -> {
    statusCode = status.getMemberStatusCode());

    // e.g. 会員退会情報があれば、会員ステータスの付加情報も詰め替える
    // (別に退会情報自体を使わないのであれば、isPresent()で普通のif文でOK)
    if (member.getMemberWithdrawal().isPresent()) {
        statusOrder = status.getDisplayOrder();
        statusDesc = status.getDescription();
    }
});

// 会員退会情報の詰め替え
member.getMemberWithdrawal().ifPresent(withdrawal -> {
    withdrawalDatetime = withdrawal.getWithdrawalDatetime();
    withdrawalInputText = withdrawal.getWithdrawalReasonInputText();

    // e.g. 新規受け入れ可能なサービスランクなら理由テキストを詰め替える
    member.getMemberServiceAsOne().alwaysPresent(service -> {
        service.getServiceRank().alwaysPresent(rank -> {
            if (rank.isNewAcceptableFlgTrue()) { // 新規受け入れ可能
                withdrawal.ifPresent(reason -> {
                    withdrawalSelectedText = reason.getWithd...();
                });
            }
        });
    });
});

とはいえ、特に後半は、さすがに少しネストが深くなり過ぎてる感があります。

ネストが深い関連テーブルで "カージナリティ的に必須のもの" であれば、alwaysPresent() を無理に続けるのではなく、サクッと get() してしまってもいいでしょう。

e.g. ネストが深過ぎるなら、サクッと get() もあり @Java
// 会員退会情報の詰め替え
member.getMemberWithdrawal().ifPresent(withdrawal -> {
    withdrawalDatetime = withdrawal.getWithdrawalDatetime();
    withdrawalInputText = withdrawal.getWithdrawalReasonInputText();

    // e.g. 新規受け入れ可能なサービスランクなら理由テキストを詰め替える
    member.getMemberServiceAsOne().alwaysPresent(service -> {
    if (service.getServiceRank().get().isNewAcceptableFlgTrue()) {
        withdrawal.ifPresent(reason -> {
            withdrawalSelectedText = reason.getWithd...();
        });
    });
});

一方で、会員サービス情報やサービスランクが他のところでも利用するならあらかじめ変数にしてもいいかもしれませんが、 詰め替え処理全体の規模や複雑度からあまり変数を増やしたくないと思ったら、こういった その場ちょいget() でもいいかと思います。

関連テーブルの場合は、こういった感じで "一発処理でOKスタイル" と "みんなで勢ぞろいスタイル" との間、グレーゾーンあたりの スタイル入りみだれスタイル が多いと考えられます。

ルールじゃなくて見やすさと安心

結局は、"ルール" ではなく、そのとき何が見やすい方がうれしいのか?どう書いたら安心するか? という観点から "じゃあ、今回はこういう感じで書くかなー" って考えながらプログラミングしていくことが大切だということには何も変わりませんね。

DBFlute on Java8

"DBFlute on Java8" ページ作りました。

メソッド仕様

同じメソッドの複数回の呼び出し

二回目以降の呼び出しは、無意味な呼び出しとなります。 同階層の複数のネストした関連テーブルを呼び出すときに、必要になりますので特に例外にはなりません。 また、SQL上で結合回数が(無駄に)増えてしまうことはありません。

e.g. 同階層の複数のネストした関連テーブル {PURCHASE,MEMBER,MEMBER_STATUS...} @Java
PurchaseCB cb = new PurchaseCB();
cb.setupSelect_Member().withMemberStatus();
cb.setupSelect_Member().withMemberWithdrawalAsOne();

SetupSelect しなければ null

SetupSelect しなかった関連テーブルの、(基点テーブルの) Entity での取得メソッドは null を戻します(関連テーブルのEntityのインスタンス自体が存在しない)。

関連データが存在しなければ null

(基点テーブルのデータに対応する)関連テーブルのデータが存在しない場合は、(その基点テーブルの) Entity において、その関連テーブルの取得メソッドは null を戻します(関連テーブルのEntityのインスタンス自体が存在しない)。

例えば、many-to-one でNotNull制約のFKカラムによる関連テーブルであれば、null が戻ることはありえません。NullableなFKカラムの場合、もしくは、OnClause や InlineView にて関連テーブルを結合前に絞り込んだ場合などにおいては null が戻る可能性があります。

UnionQuery よりも先に呼び出す

UnionQuery よりも前に呼び出す必要があります。ただ、そもそも SetupSelect は、習慣的に他のどの設定メソッドよりも先(の方)に呼び出すことが推奨されていますので、 通常はあまり意識する必要はないと想定されます。(万が一、UnionQueryよりも後に呼び出した場合は、明示的な例外となります)

e.g. UnionQueryよりも先に呼び出すこと {MEMBER_STATUS} @Java
MemberCB cb = new MemberCB();
cb.setupSelect_MemberStatus(); // o
cb.union(new UnionQuery...)
//cb.setupSelect_MemberStatus(); x

サポートされる関連テーブル

  • many-to-one
  • one-to-one
  • 業務的one-to-one

関連テーブルの取得カラムの指定

基点テーブルと同じように、関連テーブルの取得カラムも明示的に指定して、 (主にパフォーマンス考慮のために)取得するカラムを選ぶことができます。 但し、明示的にカラムを指定した場合も、その関連テーブルに対する SetupSelect の呼び出しは必要です。(SetupSelect された上で、取得するカラムを明示的に指定)

Query(Relation) で同じ関連テーブル

Query(Relation) で、SetupSelect で指定した同じ関連テーブルを利用した場合でも、SQL上は一つの結合で表現されます。 同じ関連テーブルに対して、"取得" と "絞り込み" が同時に行われる場合は、その関連テーブルに対する SetupSelect と Query(Relation) の両方を利用して実装します。

e.g. Query(Relation)で同じ関連テーブル {MEMBER_STATUS} @Java
MemberCB cb = new MemberCB();
cb.setupSelect_MemberStatus(); // 会員ステータスを取得
cb.query().queryMemberStatus().set...; // 会員ステータスで絞り込み

SetupSelect が不要なケース

カウント検索やスカラ検索のとき、また、ExistsReferrer(SubQuery) や UnionQuery などの、ConditionBeanの Query 情報だけを必要とする場合においては、SetupSelect の呼び出しは必要ありません。 (そもそも処理として必要とされないため、呼び出しても利用されません)

結合方式は...外部結合"的"!?

結合はあくまで手段

ConditionBeanで取得される検索結果は、必ず外部結合"的"な結果になります。 "的" というのは、実際には inner join と left outer join の両方がSQLに混じりますが、

結合処理だけで基点テーブルが絞り込まれてしまうことがないように

...なっています。絞り込みは明示的に指定して初めて絞り込まれる、ということに重きを置いているため、結果としては、すべての結合を left outer join した時と同じ結果になるということです。

何も考えずに内部結合して無意識のうちに基点テーブルが絞り込まれていた、というようなバグを発生させないようにしています(厳密には100%防げているわけじゃないですが基本はそうです)。また、結合処理は "関連テーブルのデータ取得" もしくは "関連テーブルを使った絞り込み" のための手段であって、それ自身が目的ではないと捉えています。なので、結合だけで挙動が変わらないことを重視しています。

業務的に自然な外部結合"的"

また、それが業務的にとても自然なものであるとも考えています。例えば、"会員の一覧を閲覧する" という業務に "同時に会員ステータスの情報も見たい" という要件が加わる場合、概念的にフィットするのは以下のどちらでしょう?

  • A. 会員と会員ステータスをマージしたビュー (内部結合した結果) が見たい
  • B. 会員ステータスの情報が付与されている会員 (外部結合した結果) が見たい

この場合、どっちも結果は同じです。どっちに捉えるかは人の感覚の世界の話であって、挙動としてはどうでもいいことかもしれません。 ただ、DBFluteではこのように捉えています。

業務的には、"会員の一覧が見たい" のであって、会員ステータスはあくまでその中での付加情報と考えます。 {n : 1} 型リレーションなのでどう捉えようが結果は変わりませんが、仮に会員ステータスが {n : 0..1} 型リレーションだったら、その捉え方で結果が変わります。

"A" の捉え方では、会員ステータスが不明の会員は一覧から消えてしまいます。"B" の捉え方であれば、会員ステータスがあろうがなかろうが、あくまで付加情報なのでその会員は残ります。 どちらが概念的に業務にフィットしているか?その場面が多いかというと "B" と考えています。

"会員ステータスが不明なことなんてないよ" と思われるなら、会員ステータスを会員退会情報に置き換えるとよいでしょう。"同時に会員退会情報も見たい" というときに、退会会員だけになっても困ります。見たいのは退会会員の一覧ではなく、退会情報がくっ付いた会員一覧なのです。 ただ、相手がいないときだけの話というより、そもそも求められている一覧の本質に着目しています。

基点テーブルから広がる世界

重視しているポイントとしては、ユーザーはどういうつもりでその一覧を見ているのか? 会員と退会情報をマージしたものを見てるというより、あくまで会員の一覧を見ているという風に捉えています。 退会会員だけに絞り込むかどうかは、絞り込み要件次第で別の話と捉えます。

ちょっとした捉え方の違いではありますが、"B" を中心に捉えていった方が業務的にフィットすることが多いだろうという考えから、 少なくとも ConditionBean は、そういった基点テーブルというのを概念を重視した API となっています。

リレーショナルモデルには、外部結合の演算は存在していないため、どうしても "結合と言えば内部結合" というようなイメージがありますが、業務に照らし合わせると一概に外部結合は全く不要とも言えず、 逆にその考え方自体... 外部結合"的" はとても重要だろうと思っています。

いざというときの内部結合

かといって、問答無用で left outer join にはしていません。 外部結合"的" は論理的な話であって、物理的な話ではまた少し独自のロジックが存在します。

inner join でも left outer join でも結果が変わらないケース では、(できるだけ) inner join が利用されます。それを自動判別します。これを InnerJoinAutoDetect と呼びます。

物理的な話と先ほどの概念的な論理の話は別です。外部結合"的" を現実問題とバランスと取りつつ実現するために、inner joinを積極的に利用します。 (ただ、inner join にしたことによってパフォーマンスが劣化したケースも稀にありました...稀の稀ではありますが、なかなか難しいものですね)