Walidacja i konwersja liczb "walutopodobnych"

Założenie jest takie:

  • w bazie trzymam ‘amount’ jako liczbę całkowitą,
  • użytkownik może ją wpisywać z przecinkiem lub kropką, max. 5 miejsc po przecinku,
  • konwersja z “ludzkiego” formatu na wewnętrzny bazy idzie wg wzoru:
    input * 100000000 (oczywiście trzeba najpierw zamienić przecinek na kropkę).

Próbuję coś takiego, ale bez sukcesu:

class Bid < ActiveRecord::Base

validates :user_id, presence: true	
validates :bet_id, presence: true
validates :amount, presence: true, numericality: true,
        format: { :with => /\A\d{1,3}([\.,]\d{0,5})?\z/i  }
before_save :btc_to_stc
private
def btc_to_stc
	if new_record?
		self.amount = 100000000 * amount_before_type_cast.to_s.gsub(',', '.').to_f
	end
end
end

Ale - po wpisaniu prawidłowej liczby w formularzu, wartość zapisywana jest prawidłowo (“0.123” => 12300000), natomiast takie testy nie przechodzą:

describe "should be invalid with not valid amount" do
    before { @bid.amount = "aassdd" }
	it { should_not be_valid }
    end

describe "amount" do

	describe "should be converted" do
  	before do
        	@bid.amount = "0.1"
        	@bid.save
            end
            its(:amount) { should eq 10_000_000 }
	end

	describe "should be converted" do
  	before do
        	@bid.amount = "0,2"
        	@bid.save
            end
            its(:amount) { should eq 20_000_000 }
	end

Może to złe podejście jest. Może trzeba:

  • zapisywać i obrabiać jako float/decimal (ale integer jest bardziej odpowiedni do operacji “walutowych”),
  • przerzucić całą konwersję do kontrolera,
  • użyć ValueObject (ale w tym przypadku niepotrzebna komplikacja, wystarczą operacje na liczbach a do wyświetlenia podzielić, zaokrąglić i po sprawie).

Popatrz na kolejność wykonywania operacji i pomyśl czy before_save to jest dobre miejsce na taką konwersję

Oraz: o wiele lepiej napisać setter, działający tak:

def amount_in_cents=(cents)
    self.amount = cents * 10000
end
1 Like

Próbowałem coś z before_validation (bo zapewne o to chodzi), ale nie wychodziło :wink: Ale podejmę ten trop, dzięki!

OK, pokombinowałem i doszedłem do działającego rozwiązania:

class Bid < ActiveRecord::Base
validates :amount, presence: true, numericality: true
before_validation :amount_in_stc

private
def amount_in_stc
	def numeric(n) 
		s = n.to_s.gsub(',', '.')
		s.to_i.to_s == s || s.to_f.to_s == s
	end
	if !amount.nil?
	  if numeric(amount_before_type_cast)
	    self.amount = (100000000 * amount_before_type_cast.to_s.gsub(',', '.').to_f).to_i
		end
	end
end
end

Testy:

it "should be invalid" do
	inputs = %w[0,,2 asdf 3..3 3a]
	inputs.each do |invalid_input|
    	@bid.amount = invalid_input
    	expect(@bid).not_to be_valid
  	end
end

it "should be valid" do
	inputs = %w[0,2 3.3 3]
	inputs.each do |valid_input|
    	@bid.amount = valid_input
    	expect(@bid).to be_valid
  	end
end

Więc to działa. Olałem na razie sprawdzenie dokładności do 5 miejsc, bo to można łatwo zrobić (regexem albo zaokrąglając do 5 mpp i porównując z oryginałem).
Jedyna mała niedogodność to, że przy błędzie do formularza wstawiana jest wartość po konwersji, więc trzeba w kontrolerze przypisać wartość oryginalnie wpisaną:

def create
@bet = Bet.find(params[:bet_id])
@bid = @bet.bids.build(bid_params)
if @bid.save
	...
else
	@bid.amount = bid_params[:amount]
	render :template => 'bets/show'
end
end

Wydaje mi się, że lepiej, gdybyś zaaplikował sposób Tomasza, tj.

def amount_in_stc=(value)
  self.amount = (BigDecimal(value.gsub(',', '.')) * 100000000).to_i rescue 0
end

def amount_in_stc
  amount / 100000000
end

A w formularzu używaj po prostu :amount_in_stc zamiast :amount

Edit: BigDecimal używam z oczywistych względów

irb(main):013:0> (Float('73.74') * 100000000).to_i
=>7373999999

irb(main):015:0> (BigDecimal('73.74') * 100000000).to_i
=> 7374000000

Oczywiście możesz też nadpisać sobie klasę string, dzięki czemu unikniesz rescue

class String
   def numeric?
    !(self =~ /^-?\d+(\.\d*)?$/).nil?
  end
end

I wtedy

def amount_in_stc=(value)
  self.amount = if value.numeric? 
    (BigDecimal(value.gsub(',', '.')) * 100000000).to_i
  else
    0
  end
end
1 Like

Z ciekawości, jakie są przesłanki? Nie wygodniej byłoby po prostu trzymać decimal?

Wydaje mi się, że powodem jest precyzja zapisywania liczb zmiennoprzecinkowych, np:

2.0.0p195 :001 > (0.33*10).round(15)
3.300000000000001

Rozmawiamy o decimal, a nie float :smile:

Typ kolumny DECIMAL, o którym pisze @mdrozdziel, to liczba stałoprzecinkowa i na taką samą (klasa BigDecimal) jest rzutowana w ActiveRecord.

Fakt, BigDecimal + decimal w bazie może być dobrym i poprawnym rozwiązaniem.

Motywacją do użycia integer było:

Możemy zrobić ankietę, kto by co wybrał (integer, float, decimal) do takiego zastosowania. Ja robię ćwiczeniowo, więc to nie musi być ani w 100% zgodne ze standardami przemysłowymi ani super optymalne :wink:
Chociaż dobrze by było wiedzieć, co jest standardem i co jest optymalne.
Możemy przenieść dyskusję do innego działu, bo tutaj pomoc już została udzielona. Jeszcze faktycznie pokombinuję z getter/setter jak podpowiadacie.

No i znów pokombinowałem (sport to zdrowie :P) i zrobiłem getter/setter, jak to zaproponowali Tomash i konole:

def amount_in_stc=(value)
    s = value.to_s.gsub(',', '.')
    if s.to_i.to_s == s || s.to_f.to_s == s || s.to_f.to_s == "0" + s
	self.amount = (BigDecimal(s) * 100000000.0).to_i
    else
	self.amount = 0
    end
end

def amount_in_stc
  amount / 100000000.0 unless amount.nil?
end

Dodatkowy warunek jest na poprawność stringów bez zera na początku, typu “.1” i “,21”.
Tylko w tym rozwiązaniu martwi mnie nieco, że wszystkie wartości amount_in_stc w formularzu są konwertowane na prawidłowe liczby (tzn. bzdury wracają jako “0.0” a “1,2” jako “1.2”).