gemを読む[1]: ActiveSupportが拡張するDateクラス


## gemを読むとは

Rubyの実装力を上げる手法のひとつとして、質の高いソースコードを理解することが挙げられます。
広く使われているgemは質の高い製品そのものであるため、gemを読んでRubyレベルを上げようという魂胆です。

## ActiveSupport

今回はActiveSupportの挙動を理解するために、Rails 7.2のソースコードを読みたいと思います。

https://github.com/rails/rails/tree/7-2-stable/activesupport

READMEを翻訳すると、下記のように記載されています。

> Active Supportは、Railsフレームワークにとって有用であると見なされたユーティリティクラスや標準ライブラリの拡張機能のコレクションです。これらの追加機能は、このパッケージ内に収められているため、Rails以外のRubyプロジェクトでも必要に応じてロードすることができます。

つまり、ActiveSupportはRailsをRailsたらしめるライブラリであると言えそうです。

### Dateクラス

ActiveSupportは下記にてRubyの標準クラスである`Date`にメソッドを追加・拡張しています。

https://github.com/rails/rails/tree/7-2-stable/activesupport/lib/active_support/core_ext/date

まずはこのクラスに対して実装されているメソッドを見てみます。

#### blank?, present? メソッド

Railsではほとんどのクラスで`blank?`や`present?`メソッドを使用することが可能です。
Dateクラスもその一つであり、これらのメソッドを使用することができます。

```ruby
[1] pry(main)> Date.today.blank?
=> false
[2] pry(main)> Date.today.present?
=> true
```

これらは、irb（生のruby）ではNoMethodErrorになってしまいます。

```ruby
irb(main):001> require 'date'
=> true
irb(main):002> Date.today
=> #<Date: 2025-02-21 ((2460728j,0s,0n),+0s,2299161j)>
irb(main):003> Date.today.blank?
(irb):3:in `<main>': undefined method `blank?' for #<Date: 2025-02-21 ((2460728j,0s,0n),+0s,2299161j)> (NoMethodError)
        from /usr/local/rvm/gems/default/gems/irb-1.10.0/exe/irb:9:in `<top (required)>'
        from /usr/local/rvm/gems/default/bin/irb:25:in `load'
        from /usr/local/rvm/gems/default/bin/irb:25:in `<main>'
irb(main):004> Date.today.present?
(irb):4:in `<main>': undefined method `present?' for #<Date: 2025-02-21 ((2460728j,0s,0n),+0s,2299161j)> (NoMethodError)
        from /usr/local/rvm/gems/default/gems/irb-1.10.0/exe/irb:9:in `<top (required)>'
        from /usr/local/rvm/gems/default/bin/irb:25:in `load'
        from /usr/local/rvm/gems/default/bin/irb:25:in `<main>'
```

これは[blank.rb](https://github.com/rails/rails/blob/7-2-stable/activesupport/lib/active_support/core_ext/date/blank.rb)にて実装されています。

それぞれのメソッドが呼ばれたときに`true`, `false`を返すように実装されています。


#### current, tomorrow, yesterday メソッド

実装箇所: https://github.com/rails/rails/blob/7-2-stable/activesupport/lib/active_support/core_ext/date/calculations.rb#L37-L51

`Time.zone`やRailsアプリケーション内で`config.time_zone`が設定されている場合、`Date.current`は Time.zone.today を基準に日付を取得します。
もし`Time.zone`が設定されていない場合、`Date.current`はシステムのローカルタイム（`Date.today`）を基準に動作します。

ということは、Railsで本日を取得するときは`Date.current`を用いるのが安全であると考えることができます。

`Date.current.tomorrow`は、「`Date.current`の翌日」を表します。 
ActiveSupportにより、Rubyの`Date`クラスに`tomorrow`メソッドが追加され、Dateオブジェクトから直接`tomorrow`を呼び出すことが可能になります。
この時の「`Date.current`」の挙動は、上で説明したように、タイムゾーン（`Time.zone`）設定に依存します。

`Date.current.yesterday`も同様で、「`Date.current`の前日」を表します


#### beginning_of_day

実装箇所: https://github.com/rails/rails/blob/7-2-stable/activesupport/lib/active_support/core_ext/date/calculations.rb#L67-L72

Dateオブジェクトに対して`beginning_of_day`メソッドを実行すると、その日の始まりの時間（AM 0:00）を取得することができます。

```ruby
pry(main)> Date.current.beginning_of_day
=> Sun, 23 Feb 2025 00:00:00.000000000 JST +09:00
```

このメソッドは`in_time_zone`を返します。
`beginning_of_day`メソッドが実装されているcalculations.rbはactive_support/core_ext/date_and_time/calculationsをrequireしており、[このファイル内で`in_time_zone`が定義](https://github.com/rails/rails/blob/7-2-stable/activesupport/lib/active_support/core_ext/date_and_time/zones.rb#L20-L29)されています。

`in_time_zone`内では`time`定数を`acts_like?(:time)`の真偽で判断しています。
`acts_like?(duck)`は[active_support/core_ext/object
/acts_like.rb](https://github.com/rails/rails/blob/7-2-stable/activesupport/lib/active_support/core_ext/object/acts_like.rb)で定義されており、レシーバのクラスが引数に入れたクラスと同じ振る舞いをするか確認するメソッドです。

```ruby
pry(main)> Date.current.acts_like?(:date)
=> true
pry(main)> Date.current.acts_like?(:time)
=> false
```

in_time_zoneの実装箇所ではacts_like?メソッドはレシーバが省略されています。
省略されたレシーバは`self`であり、そのメソッドを呼びだしたオブジェクト自身となります。
今回はDateオブジェクトから呼び出されておりTimeクラスではないため、`time`にはnilが代入されます。

`time_zone`はtrueを返すので、`in_time_zone`は`time_with_zone`を返します。
さらにif文で、`time`はnilでfalsyなので、最終的に`in_time_zone`は`ActiveSupport::TimeWithZone.new(nil, zone, to_time(:utc))`を返すことになります。
これは[TimeWithZone](https://github.com/rails/rails/blob/main/activesupport/lib/active_support/time_with_zone.rb#L13-L14)に書かれている通り、任意のタイムゾーンの時間を表現できる時間に似たクラスです。

以上から、`beginning_of_day`はレシーバのタイムゾーンを考慮したその日の始まりの時間を返すことができます。

`midnight`, `at_midnight`, `at_beginning_of_day`は`beggining_of_day`のエイリアスとして定義されているので、これらは同じふるまいをします。

```ruby
pry(main)> Date.current.midnight == Date.current.beginning_of_day
=> true
```

#### middle_of_day, end_of_day メソッド

`middle_of_day`は、レシーバの正午を返すメソッドです。

```ruby
pry(main)> Date.current
=> Tue, 25 Feb 2025
pry(main)> Date.current.middle_of_day
=> Tue, 25 Feb 2025 12:00:00.000000000 JST +09:00
```

同様に`end_of_day`はレシーバの23:59:59.999...を返します。

`middle_of_day`や`end_of_day`の[定義箇所](https://github.com/rails/rails/blob/7-2-stable/activesupport/lib/active_support/core_ext/date/calculations.rb#L75-L88)を見ると、`in_time_zone`に対してそれぞれ同名のメソッドを呼び出しています。

ここで、in_time_zoneに対して呼び出しているmiddle_of_dayやend_of_dayはどこで定義されているのでしょうか？
まずはin_time_zoneの返り値のクラスを見てみます。

```ruby
pry(main)> date = Date.current.in_time_zone
=> Sun, 23 Feb 2025 00:00:00.000000000 UTC +00:00
pry(main)> date.class
=> ActiveSupport::TimeWithZone
```

in_time_zoneの結果はTimeWithZoneクラスですが、、[TimeWithZoneクラス](https://github.com/rails/rails/blob/7-2-stable/activesupport/lib/active_support/time_with_zone.rb)にmiddle_of_dayメソッドは定義されていません。

そこで、TimeWithZoneクラスの説明文を見てみると以下のように書かれています。

> = Active Support \Time With Zone
>
> 任意のタイムゾーンで時刻を表現できる、Timeに似たクラスです。  
> 標準のRubyのTimeインスタンスはUTCとシステムの<tt>ENV['TZ']</tt>で設定されたタイムゾーンに制限されているため、このクラスが必要になります。
>
> 通常、+new+を使って直接TimeWithZoneインスタンスを作成する必要はありません。  
> 代わりに、TimeZoneインスタンスのメソッドである+local+、+parse+、+at+、+now+を使うか、  
> TimeやDateTimeインスタンスの+in_time_zone+を使ってください。
>
> （中略）
>
> TimeWithZoneインスタンスは、RubyのTimeインスタンスと同じAPIを実装しているため、  
> TimeインスタンスとTimeWithZoneインスタンスは相互に置き換えて利用できます。

よって、`middle_in_day`の実装箇所はTimeクラスを見れば分かりそうです。

:::note
本当はTimeWithZoneオブジェクトがTimeクラスから移譲されているっぽいのですが、深堀りは別の機会にします。
:::

`Time#middle_of_day`は以下のように実装されていました。

```ruby
# Returns a new Time representing the middle of the day (12:00)
def middle_of_day
  change(hour: 12)
end

def change(options)
  new_year   = options.fetch(:year, year)
  new_month  = options.fetch(:month, month)
  new_day    = options.fetch(:day, day)
  new_hour   = options.fetch(:hour, hour)
  new_min    = options.fetch(:min, options[:hour] ? 0 : min)
  new_sec    = options.fetch(:sec, (options[:hour] || options[:min]) ? 0 : sec)
  new_offset = options.fetch(:offset, nil)
  ...
```

これによって、レシーバの時間を12:00:00に変更することができます。

同様に、`Time#end_of_day`はレシーバの時間を23:59:59.999999999に変更しています。

## まとめ

今回はRailsのActiveSupportがRubyの標準クラスである`Date`クラスを拡張する様子を深堀りしました。
同様に、ActiveSupportは`activesupport/lib/active_support/core_ext`配下で標準クラスの拡張をしていると考えられます。

## 参考

https://www.y-hakopro.com/entry/activesupport-code-reading

https://railsguides.jp/v7.2/active_support_core_extensions.html