PostgreSQLを使っていて、金額のカラムをdecimalに変更した時に、小数が表示されるようになりました。to_iすれば正数になりますが、to_iがto_integerだとするとINT_MAX(2147483647)を超えたらどうなるんだろう?
結論は問題ないが、そもそもサイズを指定した方が良い。
ちなみにMySQLでは、decimalは正数になるので、小数使いたい場合はサイズ指定が必要。

to_iを試してみる

% rails c
> (2147483647 + 1).to_d
 => 0.2147483648e10 
> (2147483647 + 1).to_d.class
 => BigDecimal 

to_sして確認
> (2147483647 + 1).to_d.to_s
 => "2147483648.0" 

to_iしも溢れない
> (2147483647 + 1).to_d.to_i
 => 2147483648 

to_iの型はIntegerのまま
> (2147483647 + 1).to_d.to_i.class
 => Integer 

どうやら32bit以上の場合、裏で拡張されて、溢れない(オーバーフローしない)ようだ。
但し、DBには制限があるので、保存で失敗します。超えないように or 超えた時の考慮は必要。

各言語での整数型の最大値と最小値 – HHeLiBeXの日記 正道編
Ruby は 64-bit より大きい整数値をどう扱っているか? – ユユユユユ

to_i使う前に

ActiveRecordが便利すぎて、ついついデフォルトで作っちゃいますが、
特にdecimalはDBによりデフォルトが違うので、サイズを明示した方が良い。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 11.1.1 数値型の概要

DECIMAL[(M[,D])] [UNSIGNED] [ZEROFILL]
パックされた「正確な」固定小数点数。M は桁数の合計 (精度) で、D は小数点以下の桁数 (スケール) です。小数点と、負の数に対する「-」の記号は M にはカウントされません。D が 0 のときは、小数点や小数部はありません。DECIMAL の最大桁数 (M) は 65 です。サポートされる小数部の最大桁数 (D) は 30 です。D が省略された場合のデフォルトは 0 です。M が省略された場合のデフォルトは 10 です。

PostgreSQL 9.2.4文書 – 数値データ型

decimal 可変長 ユーザ指定精度、正確 小数点前までは131072桁、小数点以降は16383桁
numeric 可変長 ユーザ指定精度、正確 小数点前までは131072桁、小数点以降は16383桁

※PostgreSQLは、decimalでmigrateするとnumericになる。同じっぽいですね。

サイズ指定有り無しで試してみる

% rails g model test price:decimal
Running via Spring preloader in process 52347
      invoke  active_record
      create    db/migrate/20210802233629_create_tests.rb
      create    app/models/test.rb
      invoke    rspec
      create      spec/models/test_spec.rb
      invoke      factory_bot
      create        spec/factories/tests.rb

db/migrate/20210802233629_create_tests.rb

class CreateTests < ActiveRecord::Migration[6.1]
  def change
    create_table :tests do |t|
      t.decimal :price
+      t.decimal :price65_0, precision: 65, scale: 0
+      t.decimal :price65_2, precision: 65, scale: 2

      t.timestamps
    end
  end
end
% rails db:migrate     
== 20210802233629 CreateTests: migrating ======================================
-- create_table(:tests)
   -> 0.0729s
== 20210802233629 CreateTests: migrated (0.0729s) =============================

MySQL

デフォルトはdecimal(10,0)

% rails db
> SHOW CREATE TABLE tests\G
*************************** 1. row ***************************
       Table: tests
Create Table: CREATE TABLE `tests` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `price` decimal(10,0) DEFAULT NULL,
  `price65_0` decimal(65,0) DEFAULT NULL,
  `price65_2` decimal(65,2) DEFAULT NULL,
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
1 row in set (0.005 sec)

MySQLのデフォルトは小数なし。

% rails c
> test = Test.new(price: 100.5, price65_0: 100.5, price65_2: 100.5)
 => #<Test id: nil, price: 100, price65_0: 100, price65_2: 0.1005e3, created_at: nil, updated_at: nil> 

小数は無視され、Integerになる。
> test.price
 => 100
> test.price.class
 => Integer 

同様に小数は無視され、Integerになる。
> test.price65_0
 => 100 
> test.price65_0.class
 => Integer 

> test.price65_2
 => 0.1005e3 
> test.price65_2.class
 => BigDecimal 
> test.price65_2.to_s
 => "100.5" 

PostgreSQL

デフォルトはnumeric(サイズの記載がないので最大)

% rails db
# \d tests
                                          Table "public.tests"
   Column   |              Type              | Collation | Nullable |              Default              
------------+--------------------------------+-----------+----------+-----------------------------------
 id         | bigint                         |           | not null | nextval('tests_id_seq'::regclass)
 price      | numeric                        |           |          | 
 price65_0  | numeric(65,0)                  |           |          | 
 price65_2  | numeric(65,2)                  |           |          | 
 created_at | timestamp(6) without time zone |           | not null | 
 updated_at | timestamp(6) without time zone |           | not null | 
Indexes:
    "tests_pkey" PRIMARY KEY, btree (id)

PostgreSQLのデフォルトは小数あり。

% rails c
> test = Test.new(price: 100.5, price65_0: 100.5, price65_2: 100.5)
 => #<Test id: nil, price: 0.1005e3, price65_0: 100, price65_2: 0.1005e3, created_at: nil, updated_at: nil> 

> test.price
 => 0.1005e3 
> test.price.class
 => BigDecimal 
> test.price.to_s
 => "100.5" 

小数は無視され、Integerになる。
> test.price65_0
 => 100 
> test.price65_0.class
 => Integer 

> test.price65_2
 => 0.1005e3 
> test.price65_2.class
 => BigDecimal 
> test.price65_2.to_s
 => "100.5" 

SQLite3

デフォルトはdecimal(サイズの記載がないので最大)、但し・・・

% rails db
sqlite> .schema tests
CREATE TABLE IF NOT EXISTS "tests" (
	"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, 
	"price" decimal, 
	"price65_0" decimal(65,0), 
	"price65_2" decimal(65,2), 
	"created_at" datetime(6) NOT NULL, 
	"updated_at" datetime(6) NOT NULL
);

SQLite3は、PostgreSQLと変わらない。

% rails c
> test = Test.new(price: 100.5, price65_0: 100.5, price65_2: 100.5)
 => #<Test id: nil, price: 0.1005e3, price65_0: 100, price65_2: 0.1005e3, created_at: nil, updated_at: nil> 
> test.price
 => 0.1005e3 
> test.price.class
 => BigDecimal 
> test.price.to_s
 => "100.5" 

小数は無視され、Integerになる。
> test.price65_0
 => 100 
> test.price65_0.class
 => Integer 

> test.price65_2
 => 0.1005e3 
> test.price65_2.class
 => BigDecimal 
> test.price65_2.to_s
 => "100.5" 

ちなみにオーバーしたら

代入はできるが、保存に失敗する。

> test.price65_0 = 10 ** 65
 => 100000000000000000000000000000000000000000000000000000000000000000 
> test.save
  TRANSACTION (8.2ms)  BEGIN
  Test Create (24.7ms)  INSERT INTO `tests` (`price`, `price65_0`, `price65_2`, `created_at`, `updated_at`) VALUES (100, 100000000000000000000000000000000000000000000000000000000000000000, 100.5, '2021-08-04 09:42:50.914251', '2021-08-04 09:42:50.914251')
  TRANSACTION (3.3ms)  ROLLBACK
Traceback (most recent call last):
        1: from (irb):10:in `<main>'
ActiveRecord::RangeError (Mysql2::Error: Out of range value for column 'price65_0' at row 1)
 => false 

オーバーしなければOK

> test.price65_0 = 10 ** 65 - 1
 => 99999999999999999999999999999999999999999999999999999999999999999 
> test.save
  TRANSACTION (2.8ms)  BEGIN
  Test Update (18.8ms)  UPDATE `tests` SET `tests`.`price65_0` = 99999999999999999999999999999999999999999999999999999999999999999, `tests`.`updated_at` = '2021-08-04 09:45:59.038654' WHERE `tests`.`id` = 1
  PaperTrail::Version Create (4.9ms)  INSERT INTO `versions` (`item_type`, `item_id`, `event`, `object`, `created_at`) VALUES ('Test', 1, 'update', '---\nid: 1\nprice: 100\nprice65_0: 10000000000000000000000000000000000000000000000000000000000000000\nprice65_2: !ruby/object:BigDecimal 27:0.1005e3\ncreated_at: &1 2021-08-04 09:42:50.914251000 Z\nupdated_at: *1\n', '2021-08-04 09:45:59')
  TRANSACTION (6.9ms)  COMMIT
 => true 

但し、SQLite3はオーバーしても保存出来てします。
stringもそうだけど、サイズ指定という概念がなさそう。

> test.price65_0 = 10 ** 65
 => 100000000000000000000000000000000000000000000000000000000000000000 
> test.save
  TRANSACTION (0.0ms)  begin transaction
  Test Create (0.5ms)  INSERT INTO "tests" ("price", "price65_0", "price65_2", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["price", 100.5], ["price65_0", 100000000000000000000000000000000000000000000000000000000000000000], ["price65_2", 100.5], ["created_at", "2021-08-04 09:56:49.499984"], ["updated_at", "2021-08-04 09:56:49.499984"]]
  PaperTrail::Version Create (0.3ms)  INSERT INTO "versions" ("item_type", "item_id", "event", "created_at") VALUES (?, ?, ?, ?)  [["item_type", "Test"], ["item_id", 2], ["event", "create"], ["created_at", "2021-08-04 09:56:49.499984"]]
  TRANSACTION (0.3ms)  commit transaction
 => true 

結論

decimalもサイズ指定すれば、DB変えても挙動は変わらなくなる。
そもそも、小数が不要なら指定した方が後の実装が楽。
昔なら、ほぼ全て最大サイズ意識していたが、今はリソースに余裕があるので、一部だけ意識すれば良さそう。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です