Boolean w Ruby on Rails czyli czy chciałbyś zaoszczędzić 2 dni swego życia?

booleanChciałbym, żeby mi ktoś zaoszczędził 2 dni z mojego życia. Jeden z weekendów mógłby być o 2 dni dłuższy. Wyjazd na deskę mógłby trwać tyle ile by się chciało. Byłoby więcej czasu na czytanie książek, spędzanie czasu z bliskimi itp.
Ten „ktoś” jednak postanowił mi te 2 dni zabrać zamiast sprezentować… A tym „kimś” jest sposób interpretacji kolumny boolean przez Ruby on Rails.

Nie ma co jednak wybiegać do przodu, więc wszystko po kolei.

We wszystkich swoich dotychczasowych projektach używałem bazy MySQL, więc na tym będę bazował swoje rozważania.

Tworzę więc sobie model Person i daję mu atrybut friend, który może być true albo false. W migracjach, zgodnie z konwencją Rails, w modelu Person przy polu :friend daję typ :boolean. Wszystko na razie idzie zgodnie z planem. Po wykonaniu rake:db pojawia się tabela „people” i w niej pole ‘friend’ ma typ tinyint(1). Tu w sumie też wszystko idzie z planem, gdyż kolumna o typie tinyint(1) przez ActiveRecord rozpoznawana jest jako ‘boolean’.

MySQL ciągle się nie dopracował prawdziwego typu ‘boolean’, więc nawet w natywnym MySQL gdy się tworzy kolumnę typu BOOLEAN, to jest to równoznaczne ze stworzeniem kolumny o typie tinyint(1). Wartość zerowa jest traktowana jako false, a każda inna wartość jako true. (MySQL 5.0 Reference Manual – Overview of Numeric Types)

No i gdybym nie był „sprytniejszy”, niż trzeba, albo może gdybym kodował to nie będąc w pełni sił umysłowych, to nie byłoby żadnego problemu. Będąc jednak pomysłowym Dobromirem spojrzałem w strukturę bazy danych, zauważyłem typ tinyint(1), doszedłem do wniosku, że ani true ani false w tinyint się nie zmieści, więc będą tam tylko zera lub jedynki. I dalej szczęśliwie wrzucałem sobie do kodu tu i ówdzie takie oto kwiatki:

if Person.friend == 1 ...
if Person.friend == 0 ...

W kodzie miałem oczywiście zmienne, a nie nazwy modeli, ale podaję dla zobrazowania. W każdym bądź razie te kawałki aż prosiły się o poprawienie, ale nów umysłowy (antonim pełni) był silniejszy i z zadowoleniem pisałem sobie dalej.
Można łatwo sobie dopowiedzieć czym to się skończyło. Absolutnie bezsensownymi dwoma dniami, gdzie aplikacja „dziwnie się zachowywała”.

Jeśli więc ktoś kiedyś również będzie pisał kod nie wkładając w niego za wiele myśli (z braku sił czy też jakiegokolwiek innego powodu), to chciałbym, żeby poniższe przykłady zapadły dostatecznie w pamięć, żeby napisać to dobrze. Jak się na to patrzy, to wszystko to powinno być proste i logiczne, ale rzeczy proste i logiczne daleko nie zawsze są proste.

Znajdujemy więc sobie dwie osoby, jedną która jest moim przyjacielem, a druga – niekoniecznie.

t = Person.find(:first, :conditions => ["..."])     # atrybut friend o typie tinyint(1) ma wartość 1
f = Person.find(:first, :conditions => ["..."])     # atrybut friend o typie tinyint(1) ma wartość 0

Sprawdzamy czy te osoby naprawdę są przyjaciółmi czy też nie.

t.friend    # true
f.friend    # false

t.friend?    # true
f.friend?    # false

I właśnie sposób powyższy (najlepiej ze znakiem zapytania, bo jest bardziej czytelny i jasny w przekazie) jest prawidłowym i najlepszym sposobem na testowanie wartości boolean w Rails. Prawda, że proste?
Warto jednak rozpatrzyć jeszcze kilka przykładów, które czasami dają nam rezultaty, których oczekujemy, a czasem nas zaskakują. I to w niemiły sposób.

t.friend.blank?     # false
f.friend.blank?     # true

t.friend.empty?     # NoMethodError: undefined method `empty?' for true:TrueClass

f.friend.empty?     # NoMethodError: undefined method `empty?' for false:FalseClass

Tutaj blank? działa, natomiast empty? odmawia współpracy. Jednak mimo iż blank? działa poprawnie, to jest to raczej niefortunny i nieklarowny zapis, więc lepiej go unikać.
Poniżej zaś to, co próbowałem robić w swoim kodzie. Dziecinny błąd. Children, don’t try this at home! :)

t.friend == 0     # false
t.friend == 1     # false
t.friend == "0"   # false
t.friend == "1"   # false

t.friend.to_i   # NoMethodError: undefined method `to_i' for true:TrueClass
f.friend.to_i   # NoMethodError: undefined method `to_i' for false:FalseClass

Jak widać ani porównywanie tego do wielkości integer, ani string nie daje oczekiwanych rezultatów. Nawet próba konwersji wyrzuca błąd. Wniosek: nigdy nie porównywać boolean do wielkości liczbowych (i konkretnie do zer i jedynek).
Można porównywać je do wartości true/false (ale koniecznie nie jako string), ale to też jest mało eleganckie.

t.friend == true  # true
f.friend == true  # false

t.friend == "true"    # false
f.friend == "true"    # false
t.friend == "false"   # false
f.friend == "false"   # false

t.friend.to_s   # "true"
f.friend.to_s   # "false"

Jeśli więc ktoś kiedyś dzięki powyższemu użyje formy prawidłowej zamiast niepoprawnej i zaoszczędzi przynajmniej kilka godzin (nie mówiąc już o dwóch dniach), to… przynajmniej nie będzie musiał pisać postów rozpoczynających się słowami „Chciałbym, żeby mi ktoś zaoszczędził 2 dni z mojego życia”.

Liczba komentarzy: 6 ↓

#1 Radarek 10.09.08 o 13:40

Nie chcę być złośliwy, ale jesteś sam sobie winien :). To jak jest reprezentowany boolean w MySQL nie powinno Cię za bardzo interesować (chyba że używasz czystego SQL). Po stronie Rubiego masz typ boolean (true/false) i tego się powinieneś trzymać. Jeszcze próby porównania z 0 i 1 rozumiem, ale następne próby to już spore fantazje :).

#2 Sabon 10.09.08 o 13:42

Wiem, że sam sobie jestem winien. I chyba dość wyraźnie to zaznaczyłem. Porównywałem do 0 i 1, co jest bezsensem.
A dalsze dywagacje i przykłady służą tylko do zobrazowania jak działa cały mechanizm i utrwalenia jedynej słusznej metody robienia tego, nic ponadto :)

#3 Radarek 10.09.08 o 13:57

Właśnie sęk w tym że dalsze dywagacje nie mają kompletnie sensu (np. porównanie true == „true”). Boolean to boolean, nad czym tu dywagować?:)

Za to nie zwróciłeś uwagi na inną kwestię.
t.friend? # false
t.friend = „t”
t.friend? # true
t.friend = „f”
t.friend? # false
t.friend = „1″
t.friend? # true
t.friend = „0″
t.friend? # false

#4 Sabon 10.09.08 o 14:05

Tak, sprawdzałem jak działa przypisywanie różnych wartości i jakie to ma później implikacje. Nie dałem tego jednak w tekście.
Tak czy owak, słuszne spostrzeżenie i dobre uzupełnienie.
A porównanie true == „true” dałem gdyby dziwnym trafem ktoś chciał w ten sposób porównywać boolean, bo z true/false (bez cudzysłowów) działa poprawnie.

#5 daniel 10.10.08 o 14:41

Jak to babcia mawiała „use a real database luke” ;)

#6 Sabon 10.10.08 o 14:43

Wystarczy „use your brain”, a wtedy typ database będzie miał poślednie znaczenie ;)

Zostaw swój komentarz