きり丸の技術日記

技術検証したり、資格等をここに残していきます。

Javaで期間と期間を比較して重複チェックする

よくある内容の記事ですので、特別な内容はありません。私が迷わないようにするための記事です。


日付と日付を比較することは簡単ですが、日付の開始日と終了日をまとめた期間と期間を比較して、重複があることをチェックするのはたいへんです。なお、次の式を満たせれば重複していることのチェックができます。誤りやすいので、テストはコピー&ペーストでもいいので書きましょう。

基準.開始日 <= 対象.終了日 && 対象.開始日 <= 基準.終了日
# 対象日を含むかどうかは、仕様に寄ります

環境

  • Java
    • 17

対象

期間クラスを作成し、重複確認メソッドを作成する。

※recordを使用しているので、読み方に慣れていない人はGitHubのコードを読んでください。

public record Term(LocalDate start, LocalDate end) { 

  // 基準.開始 <= 対象.終了 && 対象.開始 <= 基準.終了
  public boolean isOverlap(@NonNull Term param) {
    return !start.isAfter(param.end()) && !param.start().isAfter(end);
  }
}

テストクラスで重複をチェックします。テスト対象については次の項目をテストします。

  1. 部分的に重複している場合は、true
    1. 同日も含む
  2. 片方の期間に完全に包含している場合は、true
  3. 部分的にも期間が重複していない場合は、false

テストデータ

基準

開始日 終了日
2022/7/1 2022/10/31

比較

重複パターン

開始日 終了日
2022/4/1 2022/7/31
2022/8/1 2022/9/30
2022/10/1 2022/12/31
2022/6/1 2022/11/30
2022/10/31 2022/11/30

重複しないパターン

開始日 終了日
2022/2/1 2022/5/31
2022/12/1 2022/12/31

テストコード

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Nested
class isOverlap {

  Term base = new Term(
      LocalDate.of(2022, 7, 1),
      LocalDate.of(2022, 10, 31)
  );

  @MethodSource(value = "param")
  @ParameterizedTest
  public void test(LocalDate start, LocalDate end, boolean expected) {
    assertThat(base.isOverlap(new Term(start, end))).isEqualTo(expected);
  }

  public Stream<Arguments> param() {
    return Stream.of(
        Arguments.of(LocalDate.of(2022, 4, 1), LocalDate.of(2022, 7, 31), true),
        Arguments.of(LocalDate.of(2022, 8, 1), LocalDate.of(2022, 9, 30), true),
        Arguments.of(LocalDate.of(2022, 10, 1), LocalDate.of(2022, 12, 31), true),
        Arguments.of(LocalDate.of(2022, 6, 1), LocalDate.of(2022, 11, 30), true),
        Arguments.of(LocalDate.of(2022, 10, 31), LocalDate.of(2022, 11, 30), true),
        Arguments.of(LocalDate.of(2022, 2, 1), LocalDate.of(2022, 5, 31), false),
        Arguments.of(LocalDate.of(2022, 12, 1), LocalDate.of(2022, 12, 31), false)
    );
  }
}

備考

「基準.開始 <= 対象.終了 && 対象.開始 <= 基準.終了」の理解が難しいときは、図式しながら満たすことを確認すると分かりやすいと思います。

4つの項目の関係性を比較する関係上、私は混乱するのでよく図式しています。

ソースコード

終わりに

完全に自分用の記事です。

そこまで多発する概念ではないのですが、考え方を抑えておかないとあまり納得ができないので、理解のための図式があるだけでも私は満足です。

式だけ書かれていても、本当にそれで正しいかがピンとこないので、頑張って理解するのは大事です。

参考情報

Recursionの有料会員を使用した感想

Recursionというアメリカ発のオンラインでプログラミング、コンピュータサイエンスを学べるサービスがあります。今回の記事では、Recursionを有料会員を使用した感想を記載します。

結論だけ記載すると、非常に良質な学習ができてよいサービスだと感じました。

Recursionとは

Recursionの会社概要ページより引用させていただきます。

Recursionは「世界で通用するエンジニア」を理念とした、コンピュータサイエンスをアウトプットによって学習できるプラットフォームです。Recursionでは新しいプログラミング学習法に価値があると信じており、アウトプット型のカリキュラム開発に力を入れています。アウトプットによって取り込んだ知識をより定着させるとともに、自力で作り出す達成感を味わうことができます。

また、次のような特徴があります。

  • 現役Facebookエンジニアがカリキュラムを作成、監修
  • 環境構築は不要
  • 圧倒的なアウトプットメインの学習法
  • コーディング問題約500問
  • 個人開発だけでなく、実践的なチーム開発も経験できる
    • CS中級修了など参加条件あり
  • いつでも気軽に質問できるコミュニティ
    • zoomなどで開催する講座への参加
    • 自習室の利用、もくもく会への参加

私が使用していたコース

  • CS初級
    • 公式の学習目安時間:30H
  • CS中級
    • 公式の学習目安時間:80H
  • CS上級(途中)
    • 公式の学習目安時間:95H

2ヵ月ほど利用していました。私の学習時間も公式で提示している学習目安時間と同じくらいかかりました。CS初級に関しては、プログラミングができるのであれば、だいたいスキップできます。CS中級からは普段の仕事では使用しない知識が多いため、非常に苦労しました。

オブジェクト指向プログラミング(OOP)のコースや数学のコースも学びたかったのですが、週に10時間程度しか学習時間が取れないと難しいですね。あくまで、上記コースを体験した内容しか書けないので、感想記事の内容に不備がありましたら申し訳ありません。

私が学ばなかった他のコースや学習目安時間はこちらから確認してください。

私が感じたRecursionのターゲット

  • 学生
  • コンピュータサイエンスを学びたい人・学び直したい人
    • エンジニア経験年数は問わない
  • 多職種からエンジニアにジョブチェンジした人

基礎から学べるため、エンジニア歴が若ければ若いほど学習効果があります。

また、学習ロードマップがあるため、学習中に学習順について悩むことはありません。

私が感じたRecursionのターゲットではない人

  • すぐに役立つ内容を学びたい人
    • フレームワークの使い方、アプリケーションの作り方等々

フレームワークの使い方もコース自体はあります。

しかし、学ぶには前提となるコースを修了する必要があり、目的のコースはすぐに受講できません。あくまで、Recursionはコンピュータサイエンスを学び、そのうえでアプリケーションを作れるようになるサービスですので、最初から実践を学びたい人に対しては向いていません。

ただ、私見ですがエンジニア10年弱の私はコンピュータサイエンスを学んでこなかったため、CS初級ですら学び直すところがありました。実践に飛びつきたい気持ちは非常にわかりますが、まずは腰を据えて基礎を学ぶことをオススメします。

料金体系

最新の情報はこちらのページから確認できます。

  • 月額プラン
    • 61ドル:7,930円
  • 年額プラン
    • 588ドル:76,440円(月額49ドル:6,370円)

1ドル130円で計算しています。ドル換算のため、円安が進んでいる状態だと料金が高くなってしまってつらいですね。

主な内定実績

  • 任天堂株式会社
  • ヤフー株式会社
  • ソフトバンク株式会社
  • 株式会社三菱UFJ銀行
  • 株式会社野村総合研究所
  • GMOペイメントゲートウェイ株式会社
  • エヌ・ティ・ティ・コミュニケーションズ株式会社
  • シンプレクス株式会社
  • ビジョナル株式会社
  • ヘイ株式会社
  • 株式会社ゆめみ
  • Sansan株式会社
  • 株式会社ACES
  • 株式会社アイリッジ
  • 株式会社RevComm

ここで学習したことによって、大手企業の内定も取れる実力が得られるようです。

※ この記事を書いている時点での私の所属企業も載っています。ただ、私はRecursionで学んだようなコンピュータサイエンスを意識した仕事はしていないので、学んだ内容が実務ですぐに役立たない可能性はあります。研究開発をしているような別部署では使用しているかもしれません。

感想

全体の感想

体系的にコンピュータサイエンスを学べるサービスというのは非常に少ないのでとてもおもしろいです。

実践寄りの体系的な知識を知りたければ、ITパスポート試験や基本情報技術者試験を学習すれば広く学ぶことができますが、コンピュータサイエンスを深く知ることはできません。アプリケーションを作るには、コンピュータサイエンスだけでなくネットワークやプロジェクト運営等々の知識も必要ですがそこはチーム開発という形でフォローしてもらえるのも嬉しいですね。

正直なところ、CS初級に関しては既にエンジニアとして働いている人にとっては、ほとんど学ぶことがありません。CS中級から面白くなりました。無料で体験できるのがCS初級までですので、有料会員にならないとRecursionの面白さは分からないと思います。

三項演算子、switch文等々は中級の途中に出てくる等、ミニマムに教えてから、必要ではないものは後回しにするスタイルを良いと感じました。ifの条件分岐を知っていればswitchは知らなくても実装できますし、それよりは幅優先探索の方が学んでいて力になります。また、for文(繰り返し処理)よりも先に再帰処理を学ばせるのも非常に面白いと感じました。

学習順の理由についてまでは具体的な説明はないものの、コース学習の説明文を読めばfor文よりも再帰処理を優先した順番で学習しているかを推し量ることもできます。

また、学習は孤独な闘いになりがちですが、GitHubのような学習したことが記録されて表示されるのは非常にモチベーションを保つのに非常に良いです。(6月は初月だから頑張っていて、7月は本業が忙しくなって全然触れていないですね…。)

問題についての感想

圧倒的なアウトプットメインの学習法を謳っており、コースの要所ごとに通常の知識を問う選択問題以外にも、コーディングしてアウトプットする問題が用意されています。

そして私は知識問題は難なく解けましたが、コーディング問題に関してはEasyレベルでも全然解けなかったです。CS初級の演習問題は簡単でしたが、CS中級からの演習問題は学習内容を理解している必要がありました。学習内容を理解しておくことは当たり前ですが、学習を通して分かった気分になっているだけだったということを冷徹に伝えてきます。そのため、アウトプットの重要性を理解でき、能力は着実に向上できます。

このアウトプットの重要性を伝えるためにも、Recursionでは問題に対しては回答例を出してくれません。この点に対してのRecursionの想いをヘルプ:問題がどうしても解けないです。どうすれば良いですか?から引用します。

他人のコードを参照してしまうとそれが悪い習慣になり、わかった気になってしまいます。Recursionでは「Think For YourSelf」という方針を大切にしています。自分で考えて独自のソリューションが開発できるようになるためにも、頑張って自分のチカラで解いてみましょう。

コミュニティで相談もできるので、どうしてもわからない場合はコミュニティで質問しましょう。分からない点についての言語化が必要なため、言語化に苦しむかもしれませんが、リモートワークが増えてきている現状では仕事上で言語化は必須能力となっています。このサービスを使用して内定を取ることを目指している人もいると思いますので、頑張りましょう。

オンラインエディタについての不満

環境構築不要なオンラインエディタの宿命ではありますが、普段IDEで強力なサポートを得ながらコーディングをしている身にとっては、普段と異なる環境での演習はストレスでした。本来の注力したい学習から外れてしまうので、全員に強制させるのは違うと思いますが、Docker環境、テストコードの公開、ローカルのIDEからの演習問題提出ができれば嬉しいと感じました。あわよくば、プログラミングのサービスですので、IntelliJ IDEAの無料の教育ライセンス等が使えたら非常に嬉しいと思いました。(当件については問い合わせ等々はしていないので、Recursionのサービスが無料教育ライセンスに該当するかは分かりません。)

コミュニティについて

私はDiscordしか参加していませんでしたが、Zoom等でのもくもく会も開催されているようです。

Discordの参加者の自己紹介を読んでいく限りでは、社会人と同等の人数程度に学生も多く参加しているようでした。疑問を投げかけてからのリアクションも早めに帰ってきているので、困っていたら質問を投げかけるのはよいでしょう。

終わりに

長々とした体験日記でしたが、ぜひ体系的にコンピュータサイエンスを学ぶには非常に良いサービスだと思いますので、学習に迷ったときは使用してみると良いでしょう。

コンピュータサイエンスは基礎中の基礎です。私のように業務では使用しないこともありますが、根幹となっている知識を持っていることは非常に大切です。ぜひともコンピュータサイエンスを学んでもらって、コンピュータサイエンスを意識した世の中に良いサービスが溢れるようになると良いですね。

Recursion公式サイト

recursionist.io

【障害メモ】PythonのSQLAlchemyを使用したテストコードで参照系処理を2回実行したら2回目で不具合発生

掲題の事象が発生しました。結論を出すと、ただの私の認識誤りです。ただ、よくある内容ですので、二度目が発生しないようにメモします。

Pythonと記載していますが、同様の条件が揃えば他のライブラリでも発生しうる内容です。JavaのMyBatisでも似た事象は発生しました。

環境

  • FastAPI
    • 0.79
  • SQLAlchemy
    • 1.4.36
  • Python
    • 3.10

事象

参照系処理を2回実行したら2回目で意図しないインスタンスに変更されている。条件は次の通り。

  • テストコードで発生
    • プロダクトコードでは実行ごとに別セッションで実行するので問題なし
  • DBアクセスのためのセッションをパラメータで渡す
    • トランザクション境界でセッションを生成して渡す
  • DBアクセスが発生する
  • DBアクセスしたインスタンスを加工して返却する

処理イメージ

実際に再現する環境は用意していないので、イメージです。

Productコード。

class APIUser:
    # 性別を文字列
    gender: str

class DBUser:
    __tablename__ = "users"
    # 性別は1行のコードで管理
    gender = Column(INTEGER, nullable=False)

class UserRepository:
    async def find_user(
        self,
        session
    ) -> APIUser:
        db_user: DBUser = await session.execute(select(DBUser.gender)).scalars().all()[0]
        api_user: APIUser = cast(APIUser, db_user) # castする
        api_user.gender = 'male' if api_user.gender == 1 else 'female' # 数値を文字に変換する

テストコード。

async test_multiple_execute -> None:
    # GIVEN
    session = await create_session() # いい感じにセッション作成する

    await create_user(gender=1) # いい感じに男ユーザを作成する
    expected = APIUser(gender="male")

    # WHEN, THEN
    assert expected == await UserRepository.find_user(session)
    # なぜか、genderがfemaleでエラーとなる
    assert expected == await UserRepository.find_user(session)

誤認識していた内容

  • 標準ライブラリのtyping.castは別インスタンスを作ってくれない
  • SQLAlchemyは常にDBのデータを元にインスタンスを生成してくれるわけではない

標準ライブラリのtyping.castは別インスタンスを作ってくれない

そもそも、castという言葉に別インスタンスを作ってくれる機能があると誤認していました。Javaでもそんな意味合いはないのですが…。普段castしないので、同名プロパティだけをマッピングしつつ、別インスタンスになっていると思いました…。

api_user: APIUser = cast(APIUser, db_user) 

上記コードだと、実際は型ヒントがAPIUserになっているだけで、実際のtypeDBUserのままです。あくまでPythonは動的型付言語で、castして明示している型によって実際の振る舞いが変わったりはしません。int型に対しても、str型を設定できます。

Pythonの公式のtyping.castを読んでみると、型ヒントだけ変更して、他は何の検査もしないことが記載されています。

値をある型にキャストします。

この関数は値を変更せずに返します。 型検査器に対して、返り値が指定された型を持っていることを通知しますが、実行時には意図的に何も検査しません。 (その理由は、処理をできる限り速くしたかったためです。)

SQLAlchemyは常にDBのデータを元にインスタンスを生成してくれるわけではない

公式の英語を誤読している可能性はあるので、誤っていたら指摘お願いします。

SQLAlchemyでは、キャッシュのような機能があります。クエリをキャッシュしているのではなく、処理結果をキャッシュします。もし、キャッシュした処理結果にクエリの検索条件に引っかかるインスタンスを保持している場合、該当のインスタンスを返却します。

常にDBのデータをもとにインスタンスを生成するのではなく、事前に所持してるインスタンスがあれば、そちらを返却するということです。

原文でもキャッシュのような機能と表現しておりますが、歯に物が挟まった表現が続くと読みづらいため、以後キャッシュと表現します。

Yeee…no. It’s somewhat used as a cache, in that it implements the identity map pattern, and stores objects keyed to their primary key. However, it doesn’t do any kind of query caching. This means, if you say session.query(Foo).filter_by(name='bar'), even if Foo(name='bar') is right there, in the identity map, the session has no idea about that. It has to issue SQL to the database, get the rows back, and then when it sees the primary key in the row, then it can look in the local identity map and see that the object is already there. It’s only when you say query.get({some primary key}) that the Session doesn’t have to issue a query.

結局何が起こっていたのか

1回目の処理実行時に、次のインスタンスが返却されています。

1. データ取得時
    1. DBUser(gender='1')
1. データ返却時
    1. DBUser(gender='male')

2回目の処理実行時に、キャッシュが働き、加工後のインスタンスが返却されます。

1. データ取得時
    1. DBUser(gender='male')
1. データ返却時
    1. DBUser(gender='female') # 1以外(male)が入ったので、femaleに。

対応策

同一セッションに保持されているキャッシュが残っていて、変更後の値を保持していたことが原因です。その変更後の値を破棄する必要があります。

  • キャッシュを使用しないようにする(常にDBの値を元にインスタンスを生成する)
    • 設定方法不明。どこかにはあるはず。
  • rollbackする
    • 私のテストコードの都合で、セットアップしたデータも消える等々面倒な点が多い

上記内容でも回避は可能です。今回はSQLAlchemyのSAVEPOINT機能を使用しました。

async test_multiple_execute -> None:
    # GIVEN
    session = await create_session() # いい感じにセッション作成する

    await create_user(gender=1) # いい感じに男ユーザを作成する
    expected = APIUser(gender="male")

    # WHEN, THEN
    # 1回目
    nested = await sesssion.begin_nested()
    assert expected == await UserRepository.find_user(nested)
    nested.rollback() # DBUser(gender='male')の変更を破棄する

    # 2回目
    # rollbackした時点でSAVEPOINTは破棄されるため、都度作成する必要があります
    nested = await sesssion.begin_nested()
    assert expected == await UserRepository.find_user(nested)
    nested.rokkback()

終わりに

キャッシュが悪いことに頭が回っていなくて、ドハマりしていました。

静的型付言語だと関係性がない型にキャストできず、実行時エラーが発生します。しかし、動的型付言語だとそもそも型自体があくまでヒント機能でしかないことを気付けませんでした。また静的型付言語だと、関係性がない型にキャストできず、別インスタンスにしてモデルを詰め直す必要があるため、内部的にそうなっているだろうと甘く見ていました。

というか、commitしないからいいものの、このままcommitしたらDBの値も書き換わっている可能性があるのは怖いですね。intからstrの書き換えだとDBの制約エラーに引っかかるのでいいですが、strからstrだとアウトですね。ちゃんと別インスタンスを生成したほうが安全に処理できて良さそうです。

なおJavaでも似た事象が発生しうると記載しているのは、MyBatisにはキャッシュ機能があります。DB更新をするためにインスタンスを書き換え、最低限の項目のみをUPDATEかけるためにDBとの差分を得ようと再検索すると、書き換え後のインスタンスを取得してしまい、差分ゼロと見なされて更新がかからない事象が発生しました。

高速化のためにライブラリがキャッシュを活用するのは当たり前になってきていると思いますので、同一セッションで複数回同条件で検索するパターンで変なアップデートはかけないようにしましょう。どうしてもインスタンスを修正したいのであれば、検索の都度インスタンスを再生成するのが安全です。

参考情報