模範解答的なセクション 3

概要

DBFluteハンズオン、セクション 3 の模範解答的な実装です。

正解は一つではありませんので、あくまで "的な" というところで、参考までに。

このセクションでの登場人物

e.g. このセクションでの登場人物 @Directory
dbflute-hands-on
 |-src/test/java
 |  |-org.docksidestage.handson
 |  |  |-exercise
 |  |  |  |-HandsOn03Test.java
 |  |  |
 |  |  |-unit
 |-src/main/java
 |-...

模範解答的な実装

e.g. 模範解答的な実装やってみた @Java
/**
 * @author jflute
 */
public class HandsOn03Test extends UnitContainerTestCase {

    // ===================================================================================
    //                                                                           Attribute
    //                                                                           =========
    @Resource
    private MemberBhv memberBhv;
    @Resource
    private MemberSecurityBhv memberSecurityBhv;
    @Resource
    private PurchaseBhv purchaseBhv;

    // ===================================================================================
    //                                                                      Silver Stretch
    //                                                                      ==============
    /**
     * [1] 会員名称がSで始まる1968年1月1日以前に生まれた会員を検索
     * o 会員ステータスも取得する
     * o 生年月日の昇順で並べる
     * o 会員が1968/01/01以前であることをアサート
     * o "以前" の解釈は、"その日ぴったりも含む" で。
     * ※もし、よければ HandyDate を使ってみましょう。
     */
    public void test_1() throws Exception {
        // ## Arrange ##
        LocalDate targetDate = new HandyDate("1968/01/01").getLocalDate(); // HandyDateの紹介

        // ## Act ##
        ListResultBean<Member> memberList = memberBhv.selectList(cb -> {
            cb.query().setMemberName_LikeSearch("S", op -> op.likePrefix());
            cb.query().setBirthdate_LessEqual(targetDate);
            cb.query().addOrderBy_Birthdate_Asc();
        });

        // ## Assert ##
        assertHasAnyElement(memberList);
        memberList.forEach(member -> {
            LocalDate birthdate = member.getBirthdate();
            log(member.getMemberName(), birthdate);
            assertTrue(birthdate.isBefore(targetDate) || birthdate.isEqual(targetDate));

            // << いろいろなやり方がある >>
            assertFalse(birthdate.isAfter(targetDate));
            assertTrue(new HandyDate(birthdate).isLessEqual(targetDate));
        });
    }

    /**
     * [2] 会員ステータスと会員セキュリティ情報も取得して会員を検索
     * o 若い順で並べる。生年月日がない人は会員IDの昇順で並ぶようにする
     * o 会員ステータスと会員セキュリティ情報が存在することをアサート
     * ※カージナリティを意識しましょう
     */
    public void test_2() throws Exception {
        // ## Arrange ##
        // ## Act ##
        ListResultBean<Member> memberList = memberBhv.selectList(cb -> {
            cb.setupSelect_MemberStatus();
            cb.setupSelect_MemberSecurityAsOne();
            cb.query().addOrderBy_Birthdate_Desc();
            cb.query().addOrderBy_MemberId_Asc();
        });

        // ## Assert ##
        // done jflute [読み物課題] これ最重要 by jflute
        // 会員から会員ステータスは、NotNullのFKカラムで参照しているので、探しにいけば必ず存在する
        // 会員から会員セキュリティは、FKの方向と探しにいく方向が逆なので同じ理論にはなりませんが、
        // ERDのリレーション線に注目。会員退会情報と比べると一目瞭然、黒丸がついていないので必ず存在する1
        //   会員から会員セキュリティ => 1:必ず1 (1:1)
        //   会員から会員退会情報    => 1:いないかもしれない1 (1:0..1)
        // ただ、物理的な制約はありません。業務的というのは、そういうルールにしているいうことだけなんですね。
        // 細かいですが、これがデータベースプログラミングにおいて、とても重要なんですよね。
        // ぜひ、カージナリティに着目してみてください。
        assertHasAnyElement(memberList);
        memberList.forEach(member -> {
            assertTrue(member.getMemberStatus().isPresent());
            assertTrue(member.getMemberSecurityAsOne().isPresent());
        });
    }

    /**
     * [3] 会員セキュリティ情報のリマインダ質問で2という文字が含まれている会員を検索
     * o 会員セキュリティ情報のデータ自体は要らない
     * o (Actでの検索は本番でも実行されることを想定し、テスト都合でパフォーマンス劣化させないこと)
     * o リマインダ質問に2が含まれていることをアサート
     * o アサートするために別途検索処理を入れても誰も文句は言わない
     * ※実装できたら、Assert内の検索が一回になるようにしてみましょう(もし複数回検索しているなら)。
     * ※さらに実装できたら、(Arrange, Actは変更せずに) 会員名称とリマインダ質問を会員ごとに一行のログに出力するようにしてみましょう。
     */
    public void test_3() throws Exception {
        // ## Arrange ##
        String keyword = "2";

        // ## Act ##
        ListResultBean<Member> memberList = memberBhv.selectList(cb -> {
            cb.query().queryMemberSecurityAsOne().setReminderQuestion_LikeSearch(keyword, op -> op.likeContain());
        });

        // ## Assert ##
        assertHasAnyElement(memberList);

        // << べたべたパターン: ループの中で検索してるのがちょっとよくない >>
        memberList.forEach(member -> {
            memberSecurityBhv.selectByPK(member.getMemberId()).alwaysPresent(security -> {
                String question = security.getReminderQuestion();
                log(member.getMemberName(), question);
                assertTrue(question.contains(keyword));
            });
        });

        // << SQLを救出パターン >>
        // IDの抽出、stream()でこう書ける
        //List<Integer> memberIdList = memberList.stream().map(member -> {
        //    return member.getMemberId();
        //}).collect(Collectors.toList());
        // でも、ExtractColumnを使うのが一番
        ListResultBean<MemberSecurity> securityList = memberSecurityBhv.selectList(cb -> {
            cb.query().setMemberId_InScope(memberBhv.extractMemberIdList(memberList));
        });
        memberList.forEach(member -> {
            securityList.forEach(security -> {
                if (member.getMemberId().equals(security.getMemberId())) {
                    String question = security.getReminderQuestion();
                    log(member.getMemberName(), question);
                    assertTrue(question.contains(keyword));
                    markHere("exists");
                    // あっ、breakできない!?
                }
            });
            assertMarked("exists");
        });

        // << stream()で探してみるパターン >>
        memberList.forEach(member -> {
            securityList.stream().filter(security -> {
                return member.getMemberId().equals(security.getMemberId());
            }).findFirst().ifPresent(security -> {
                String question = security.getReminderQuestion();
                log(member.getMemberName(), question);
                assertTrue(question.contains(keyword));
                markHere("exists");
            });
            assertMarked("exists");
        });

        // << Mapにしちゃうパターン!? >>
        Map<Integer, MemberSecurity> securityMap =
                securityList.stream().collect(Collectors.toMap(bean -> bean.getMemberId(), bean -> bean));
        memberList.forEach(member -> {
            MemberSecurity security = securityMap.get(member.getMemberId());
            String question = security.getReminderQuestion();
            log(member.getMemberName(), question);
            assertTrue(question.contains(keyword));
        });
    }

    // ===================================================================================
    //                                                                        Gold Stretch
    //                                                                        ============
    /**
     * [4] 会員ステータスの表示順カラムで会員を並べて検索
     * o 会員ステータスの "表示順" カラムの昇順で並べる
     * o 会員ステータスのデータ自体は要らない
     * o その次には、会員の会員IDの降順で並べる
     * o 会員ステータスのデータが取れていないことをアサート
     * o 会員が会員ステータスごとに固まって並んでいることをアサート (順序は問わない)
     */
    public void test_4() throws Exception {
        // ## Arrange ##
        // ## Act ##
        ListResultBean<Member> memberList = memberBhv.selectList(cb -> {
            cb.query().queryMemberStatus().addOrderBy_DisplayOrder_Asc();
            cb.query().addOrderBy_MemberId_Desc();
        });

        // ## Assert ##
        assertHasAnyElement(memberList);

        // << forEach()使ってみたパターン >>
        String[] previousBox = new String[1]; // Lambda使うとこういうのしづらい!?
        Set<String> statusSet = new HashSet<String>();
        memberList.forEach(member -> {
            assertFalse(member.getMemberStatus().isPresent());
            String previous = previousBox[0];
            String current = member.getMemberStatusCode();
            log(previous, current);
            if (previous != null && !previous.equals(current)) {
                assertFalse(statusSet.contains(current));
            }
            previousBox[0] = current;
            statusSet.add(current);
        });

        // << 普通のfor文のパターン >>
        statusSet.clear(); // ちと再利用させて
        String previous = null;
        for (Member member : memberList) {
            assertFalse(member.getMemberStatus().isPresent());
            String current = member.getMemberStatusCode();
            log(previous, current);
            if (previous != null && !previous.equals(current)) {
                assertFalse(statusSet.contains(current));
            }
            statusSet.add(current);
            previous = current;
        }

        // << というか、違うやり方 >>
        statusSet.clear(); // また再利用させて
        previous = null; // こっちも
        int switchCount = 0;
        for (Member member : memberList) {
            assertFalse(member.getMemberStatus().isPresent());
            String current = member.getMemberStatusCode();
            log(previous, current);
            if (previous != null && !previous.equals(current)) {
                ++switchCount;
            }
            statusSet.add(current);
            previous = current;
        }
        assertEquals(statusSet.size() - 1, switchCount);

        // << これはちょっと強引かな >>
        // reduce()の機能を間借りしてみた...が、reduceしてわけじゃないので紛らわしいかもね
        // jflute的にはやらないです。これだったら普通のforの方がいい
        statusSet.clear(); // これまた再利用させて
        memberList.stream().reduce((previousMember, currentMember) -> {
            assertFalse(previousMember.getMemberStatus().isPresent());
            assertFalse(currentMember.getMemberStatus().isPresent());
            String currentStatus = currentMember.getMemberStatusCode();
            if (!previousMember.getMemberStatusCode().equals(currentStatus)) {
                assertFalse(statusSet.contains(currentStatus));
            }
            statusSet.add(currentStatus);
            return currentMember; // to be previous at next loop
        });
    }

    /**
     * [5] 生年月日が存在する会員の購入を検索
     * o 会員名称と会員ステータス名称と商品名も一緒に取得(ログ出力)
     * o 購入日時の降順、購入価格の降順、商品IDの昇順、会員IDの昇順で並べる
     * o OrderBy がたくさん追加されていることをログで確認すること
     * o 購入に紐づく会員の生年月日が存在することをアサート
     * ※ログ出力は、スーパークラスの log() メソッドが利用できる。可変長引数でカンマ区切り出力になる。
     */
    public void test_5() throws Exception {
        // ## Arrange ##
        // ## Act ##
        ListResultBean<Purchase> purchaseList = purchaseBhv.selectList(cb -> {
            cb.setupSelect_Member().withMemberStatus();
            cb.setupSelect_Product();
            cb.query().queryMember().setBirthdate_IsNotNull();
            cb.query().addOrderBy_PurchaseDatetime_Desc();
            cb.query().addOrderBy_PurchasePrice_Desc();
            cb.query().addOrderBy_ProductId_Asc();
            cb.query().addOrderBy_MemberId_Asc();
        });

        // ## Assert ##
        assertHasAnyElement(purchaseList);
        purchaseList.forEach(purchase -> {
            Member member = purchase.getMember().get();
            MemberStatus status = member.getMemberStatus().get();
            Product product = purchase.getProduct().get();
            log(purchase.getProductId(), member.getMemberName(), status.getMemberStatusName(), product.getProductName(),
                    member.getMemberId());
            assertNotNull(member.getBirthdate());
        });
    }

    /**
     * [6] 2005年10月の1日から3日までに正式会員になった会員を検索
     * o 画面からの検索条件で2005年10月1日と2005年10月3日がリクエストされたと想定
     * o Arrange で String の "2005/10/01", "2005/10/03" を一度宣言してから日時クラスに変換
     * o 時分秒は "00:00:00" になるようにして、(日付移動などせず)そのまま使って条件を設定
     * o 会員ステータスも一緒に取得
     * o ただし、会員ステータス名称だけ取得できればいい (説明や表示順カラムは不要)
     * o 会員名称に "vi" を含む会員を検索
     * o 会員名称と正式会員日時と会員ステータス名称をログに出力
     * o 会員ステータスがコードと名称だけが取得されていることをアサート
     * o 会員の正式会員日時が指定された条件の範囲内であることをアサート
     * ※Java8 (DBFlute-1.1) なら、assertException(...)を使うとよいでしょう
     * ※実装できたら、こんどはスーパークラスのメソッド adjustMember_FormalizedDatetime_...() を使って、10月1日ジャスト(時分秒なし)の正式会員日時を持つ会員データを作成してテスト実行してみましょう。 もともと一件しかなかった検索結果が「二件」になるはずです。
     * @throws Exception
     */
    public void test_6() throws Exception {
        // ## Arrange ##
        String requestedFrom = "2005/10/01";
        String requestedTo = "2005/10/03";
        LocalDateTime fromDate = new HandyDate(requestedFrom).getLocalDateTime();
        LocalDateTime toDate = new HandyDate(requestedTo).getLocalDateTime();
        String nameKeyword = "vi";
        adjustMember_FormalizedDatetime_FirstOnly(fromDate, nameKeyword);

        // ## Act ##
        ListResultBean<Member> memberList = memberBhv.selectList(cb -> {
            cb.setupSelect_MemberStatus();
            cb.specify().specifyMemberStatus().columnMemberStatusName();
            cb.query().setMemberName_LikeSearch(nameKeyword, op -> op.likeContain());
            cb.query().setFormalizedDatetime_FromTo(fromDate, toDate, op -> op.compareAsDate());
        });

        // ## Assert ##
        assertHasAnyElement(memberList);
        LocalDateTime addedToDate = toDate.plusDays(1);
        for (Member member : memberList) {
            LocalDateTime formalizedDatetime = member.getFormalizedDatetime();
            MemberStatus status = member.getMemberStatus().get();
            String statusName = status.getMemberStatusName();
            log(member.getMemberName(), formalizedDatetime, statusName);

            assertNotNull(status.getMemberStatusCode());
            assertNotNull(statusName);
            assertException(NonSpecifiedColumnAccessException.class, () -> status.getDisplayOrder());
            assertException(NonSpecifiedColumnAccessException.class, () -> status.getDescription());

            assertTrue(fromDate.isEqual(formalizedDatetime) || fromDate.isBefore(formalizedDatetime));
            assertTrue(addedToDate.isAfter(formalizedDatetime));
        }
    }

    // ===================================================================================
    //                                                                    Platinum Stretch
    //                                                                    ================
    /**
     * [7] 正式会員になってから一週間以内の購入を検索
     * o 会員と会員ステータス、会員セキュリティ情報も一緒に取得
     * o 商品と商品ステータス、商品カテゴリ、さらに上位の商品カテゴリも一緒に取得
     * o 上位の商品カテゴリ名が取得できていることをアサート
     * o 購入日時が正式会員になってから一週間以内であることをアサート
     * ※ログ出力と書いてなくても、テストの動作を確認するためにも(自由に)ログ出力すると良い。
     * ※実装できたら、こんどはスーパークラスのメソッド adjustPurchase_PurchaseDatetime_...() を呼び出し、調整されたデータによって検索結果が一件増えるかどうか確認してみましょう。 もし増えないなら、なぜ増えないのか...
     * @throws Exception
     */
    public void test_7() throws Exception {
        // ## Arrange ##
        adjustPurchase_PurchaseDatetime_fromFormalizedDatetimeInWeek();

        // ## Act ##
        //
        // 10/3                    10/10     10/11
        //  13h                      0h  13h   0h
        //   |                       |    |    |
        //   |       D               | I  |    | P
        // A |                       |H  J|L   |O
        //   |C                  E   G    K    N
        //   B                      F|    |   M|
        //   |                       |         |
        //
        ListResultBean<Purchase> purchaseList = purchaseBhv.selectList(cb -> {
            cb.setupSelect_Member().withMemberStatus();
            cb.setupSelect_Member().withMemberSecurityAsOne();
            cb.setupSelect_Product().withProductStatus();
            cb.setupSelect_Product().withProductCategory().withProductCategorySelf();
            cb.columnQuery(colCB -> colCB.specify().columnPurchaseDatetime())
                    .greaterEqual(colCB -> colCB.specify().specifyMember().columnFormalizedDatetime()); // ぴったしは含むとする
            cb.columnQuery(colCB -> colCB.specify().columnPurchaseDatetime())
                    .lessThan(colCB -> colCB.specify().specifyMember().columnFormalizedDatetime())
                    .convert(op -> op.truncTime().addDay(8)); // 24*7 hours + あるふぁ
        });

        // ## Assert ##
        assertHasAnyElement(purchaseList);
        for (Purchase purchase : purchaseList) {
            Product product = purchase.getProduct().get();
            product.getProductCategory().get().getProductCategorySelf().alwaysPresent(parent -> { // categoryなけりゃ落ちる
                assertNotNull(parent.getProductCategoryName()); // not null だから、ここまでくりゃあるはずだけど念のため
            });
            LocalDateTime purchaseDatetime = purchase.getPurchaseDatetime();
            LocalDateTime formalizedDatetime = purchase.getMember().get().getFormalizedDatetime();
            LocalDateTime oneWeekAfter = new HandyDate(formalizedDatetime).moveToDayJust().addDay(8).getLocalDateTime();
            log("purchaseDatetime={}, formalizedDatetime={}, {}", purchaseDatetime, formalizedDatetime, product.getProductName());
            assertTrue(purchaseDatetime.isEqual(formalizedDatetime) || purchaseDatetime.isAfter(formalizedDatetime));
            assertTrue(purchaseDatetime.isBefore(oneWeekAfter));
        }
    }

    /**
     * [8] 1974年までに生まれた、もしくは不明の会員を検索
     * o 画面からの検索条件で1974年がリクエストされたと想定
     * o Arrange で String の "1974/01/01" を一度宣言してから日付クラスに変換
     * o その日付クラスの値を、(日付移動などせず)そのまま使って検索条件を実現
     * o 会員ステータス名称、リマインダ質問と回答、退会理由入力テキストも取得(ログ出力)
     * o 若い順だが生年月日が null のデータを最初に並べる
     * o 生年月日が指定された条件に合致することをアサート (1975年1月1日なら落ちるように)
     * o Arrangeで "きわどいデータ" ※1 を作ってみましょう (Behavior の updateNonstrict() ※2 を使って)
     * o 検索で含まれるはずの "きわどいデータ" が検索されてることをアサート (アサート自体の保証のため)
     * o 生まれが不明の会員が先頭になっていることをアサート
     * ※1: 1974年12月31日生まれの人、1975年1月1日生まれの人。前者は検索に含まれて、後者は含まれない。
     * テストデータに存在しない、もしくは、存在に依存するのがためらうほどのピンポイントのデータは、自分で作っちゃうというのも一つの手。
     * (エクササイズ 6 や 7 でやっていた adjust がまさしくそれ)
     * ※2: 1 から 9 までの任意の会員IDを選び updateNonstrict() してみましょう。
     * まあ、一桁代の会員IDが存在すること自体への依存は割り切りで。最低限それだけのテストデータで用意されていないとお話にならないってことで。
     * ※今後、"きわどいデータ" を作ってアサートを確かなものにするかどうかは自分の判断で。
     * @throws Exception
     */
    public void test_8() throws Exception {
        // ## Arrange ##
        String requestedYear = "1974-01-01";
        LocalDate targetDate = new HandyDate(requestedYear).getLocalDate();
        LocalDate limitDate = adjustExercise8_Birthdate_asLimitDate(targetDate);
        LocalDate overDate = adjustExercise8_Birthdate_asOverDate(targetDate);

        // ## Act ##
        ListResultBean<Member> memberList = memberBhv.selectList(cb -> {
            cb.setupSelect_MemberStatus();
            cb.setupSelect_MemberSecurityAsOne();
            cb.setupSelect_MemberWithdrawalAsOne();
            cb.query().setBirthdate_FromTo(null, targetDate, op -> op.compareAsYear().allowOneSide().orIsNull());
            // おもいで
            //cb.orScopeQuery(orCB -> {
            //    orCB.query().setBirthdate_FromTo(null, targetDate, op -> op.compareAsYear().allowOneSide());
            //    orCB.query().setBirthdate_IsNull();
            //});
            cb.query().addOrderBy_Birthdate_Desc().withNullsFirst();
        });

        // ## Assert ##
        assertHasAnyElement(memberList);
        boolean existsLimitDate = false;
        for (Member member : memberList) {
            MemberStatus status = member.getMemberStatus().get();
            MemberSecurity security = member.getMemberSecurityAsOne().get();
            String reason = member.getMemberWithdrawalAsOne().map(wdl -> wdl.getWithdrawalReasonInputText()).orElse("none");
            log(status.getMemberStatusName(), security.getReminderQuestion(), security.getReminderAnswer(), reason);

            LocalDate birthdate = member.getBirthdate();
            if (birthdate != null) {
                assertTrue(birthdate.isBefore(overDate));
                if (birthdate.isEqual(limitDate)) {
                    existsLimitDate = true;
                }
            }
        }
        assertTrue(existsLimitDate);
        assertNull(memberList.get(0).getBirthdate()); // 先頭なのでこれでOK
    }

    private LocalDate adjustExercise8_Birthdate_asLimitDate(LocalDate targetDate) {
        LocalDate limitDate = new HandyDate(targetDate).moveToYearTerminal().getLocalDate();
        Member member = new Member();
        member.setMemberId(3);
        member.setBirthdate(limitDate);
        memberBhv.updateNonstrict(member);
        return limitDate;
    }

    private LocalDate adjustExercise8_Birthdate_asOverDate(LocalDate targetDate) {
        LocalDate overDate = targetDate.plusYears(1);
        Member member = new Member();
        member.setMemberId(5);
        member.setBirthdate(overDate);
        memberBhv.updateNonstrict(member);
        return overDate;
    }

    /**
     * [9] 2005年6月に正式会員になった会員を先に並べて生年月日のない会員を検索
     * o 画面からの検索条件で2005年6月がリクエストされたと想定
     * o Arrange で String の "2005/06/01" を一度宣言してから日付クラスに変換
     * o その日付クラスの値を、(日付移動などせず)そのまま使って検索条件を実現
     * o 第二ソートキーは会員IDの降順
     * o 検索された会員の生年月日が存在しないことをアサート
     * o 2005年6月に正式会員になった会員が先に並んでいることをアサート (先頭だけじゃなく全体をチェック)
     * @throws Exception
     */
    public void test_9() throws Exception {
        // ## Arrange ##
        String requestedMonth = "2005-06-01";
        LocalDate fromDate = new HandyDate(requestedMonth).getLocalDate();

        // ## Act ##
        ListResultBean<Member> memberList = memberBhv.selectList(cb -> {
            cb.query().setBirthdate_IsNull();
            cb.query().addOrderBy_FormalizedDatetime_Asc().withManualOrder(op -> {
                op.when_FromTo(fromDate, fromDate, ftOp -> ftOp.compareAsMonth());
            });
            cb.query().addOrderBy_MemberId_Desc();
        });

        // ## Assert ##
        assertHasAnyElement(memberList);
        boolean existsTargetMonth = false;
        boolean passedBorder = false;
        HandyDate fromHandy = new HandyDate(fromDate);
        for (Member member : memberList) {
            assertNull(member.getBirthdate());
            LocalDateTime formalizedDatetime = member.getFormalizedDatetime();
            if (formalizedDatetime != null && fromHandy.isMonthOfYearSameAs(formalizedDatetime)) {
                assertFalse(passedBorder);
                existsTargetMonth = true;
            } else { // null or others
                passedBorder = true;
            }
        }
        assertTrue(existsTargetMonth);
        assertTrue(passedBorder);
    }

    // ===================================================================================
    //                                                                              Paging
    //                                                                              ======
    /**
     * 全ての会員をページング検索
     * o 会員ステータス名称も取得
     * o 会員IDの昇順で並べる
     * o ページサイズは 3、ページ番号は 1 で検索すること
     * o 会員ID、会員名称、会員ステータス名称をログに出力
     * o SQLのログでカウント検索時と実データ検索時の違いを確認
     * o 総レコード件数が会員テーブルの全件であることをアサート
     * o 総ページ数が期待通りのページ数(計算で導出)であることをアサート
     * o 検索結果のページサイズ、ページ番号が指定されたものであることをアサート
     * o 検索結果が指定されたページサイズ分のデータだけであることをアサート
     * o PageRangeを 3 にして PageNumberList を取得し、[1, 2, 3, 4]であることをアサート
     * o 前のページが存在しないことをアサート
     * o 次のページが存在することをアサート
     * @throws Exception
     */
    public void test_paging() throws Exception {
        // ## Arrange ##
        int pageSize = 3;
        int pageNumber = 1;

        // ## Act ##
        // [SQL]
        // MySQL's found_rows() is used here
        //  data  : select sql_calc_found_rows ... limit 0, 3
        //  count : select found_rows()
        PagingResultBean<Member> page = memberBhv.selectPage(cb -> {
            cb.setupSelect_MemberStatus();
            cb.query().addOrderBy_MemberId_Asc();
            cb.paging(pageSize, pageNumber);
        });

        // ## Assert ##
        assertHasAnyElement(page);
        page.forEach(member -> {
            log(member.getMemberId(), member.getMemberName(), member.getMemberStatus().get().getMemberStatusName());
        });
        int allRecordCount = page.getAllRecordCount();
        assertEquals(memberBhv.selectCount(cb -> {}), allRecordCount);
        assertEquals((allRecordCount / pageSize) + (allRecordCount % pageSize > 0 ? 1 : 0), page.getAllPageCount());
        assertEquals(pageSize, page.getPageSize());
        assertEquals(pageNumber, page.getCurrentPageNumber());
        assertEquals(pageSize, page.size());
        assertEquals(Arrays.asList(1, 2, 3, 4), page.pageRange(op -> op.rangeSize(3)).createPageNumberList());
        assertFalse(page.existsPreviousPage());
        assertTrue(page.existsNextPage());
    }

    // ===================================================================================
    //                                                                              Cursor
    //                                                                              ======
    /**
     * 会員ステータスの表示順カラムで会員を並べてカーソル検索
     * o 会員ステータスの "表示順" カラムの昇順で並べる
     * o 会員ステータスのデータも取得
     * o その次には、会員の会員IDの降順で並べる
     * o 会員ステータスが取れていることをアサート
     * o 会員が会員ステータスごとに固まって並んでいることをアサート
     * o 検索したデータをまるごとメモリ上に持ってはいけない
     * o (要は、検索結果レコード件数と同サイズのリストや配列の作成はダメ)
     * @throws Exception
     */
    public void test_cursor() throws Exception {
        // ## Arrange ##
        String[] previousBox = new String[1]; // Lambda使うとこういうのしづらい!?
        Set<String> statusSet = new HashSet<String>();

        // ## Act ##
        memberBhv.selectCursor(cb -> {
            cb.setupSelect_MemberStatus();
            cb.query().queryMemberStatus().addOrderBy_DisplayOrder_Asc();
            cb.query().addOrderBy_MemberId_Desc();
        }, member -> {
            // ## Assert ##
            assertTrue(member.getMemberStatus().isPresent());
            String previous = previousBox[0];
            String current = member.getMemberStatusCode();
            log(previous, current);
            if (previous != null && !previous.equals(current)) {
                assertFalse(statusSet.contains(current));
            }
            previousBox[0] = current;
            statusSet.add(current);
        });
        assertHasAnyElement(statusSet);
    }

    // ===================================================================================
    //                                                                           InnerJoin
    //                                                                           =========
    /**
     * いままで書いたエクササイズでもいいですし、新たに適当なテストメソッドを作ってもいいので、
     * ログのSQLを目視で確認して InnerJoinAutoDetect を実感してみるとよいでしょう。
     * @throws Exception
     */
    public void test_confirm_InnerJoinAutoDetect() throws Exception {
        // select ...
        //  from member dfloc
        //    inner join member_status dfrel_0 on dfloc.MEMBER_STATUS_CODE = dfrel_0.MEMBER_STATUS_CODE
        //    left outer join member_security dfrel_1 on dfloc.MEMBER_ID = dfrel_1.MEMBER_ID
        //    inner join member_withdrawal dfrel_3 on dfloc.MEMBER_ID = dfrel_3.MEMBER_ID
        // where dfrel_3.WITHDRAWAL_DATETIME >= '2015-10-15 14:49:56.608'
        memberBhv.selectList(cb -> {
            cb.setupSelect_MemberStatus();
            cb.setupSelect_MemberSecurityAsOne();
            cb.setupSelect_MemberWithdrawalAsOne();
            cb.query().queryMemberWithdrawalAsOne().setWithdrawalDatetime_GreaterEqual(currentLocalDateTime());
        });
    }
}