Janusz Pietrzyk
Polcode Sp. z o.o.
Janusz PietrzykPolcode Sp. z o.o.

Wszystko, co musisz wiedzieć o ActiveRecord - część 1

Sprawdź, jak zbudować podstawowy system filtrowania i sortowania danych, używając tylko natywnych możliwości ActiveRecorda w Ruby on Rails.
6.07.20208 min
Wszystko, co musisz wiedzieć o ActiveRecord - część 1

Jedną z najbardziej podstawowych funkcji w większości aplikacji webowych jest komunikacja z bazą danych. Jako developerzy Ruby on Rails z ActiveRecordem mamy do czynienia praktycznie codziennie, a mimo to - przewijając się przez wiele różnych projektów - bardzo często widzę problemy w tym aspekcie. Myślę, że po części odpowiada za to sama filozofia frameworku, która dyktuje, że ma on zastępować pracę programisty tak bardzo jak to możliwe. Oczywiście jest to w dużej mierze zaleta - można zacząć tworzyć aplikacje korzystające z bazy danych bez jakiejkolwiek wiedzy o ich działaniu i o pisaniu zapytań SQL.

Na dłuższą metę to podejście przesłania jednak pewne rzeczy, które programiście wypada wiedzieć. Widać to zwłaszcza w długoterminowych projektach, zaczynanych lata wcześniej przez początkujących programistów.

W tej serii artykułów zamierzam pokazać jak “pod maską” działa ActiveRecord, stworzyć przewodnik po wszystkich jego funkcjach i trikach, które można zastosować, a także wyjaśnić co-nieco SQL-a. W dalszych częściach zbudujemy pełnoprawny system wyszukiwania fulltextowego w PostgreSQL używając tylko i wyłącznie funkcji wbudowanych w Ruby on Rails. 

W ramach części pierwszej zbudujemy podstawowy system filtrowania i sortowania danych używając tylko natywnych możliwości ActiveRecorda. W następnych częściach będziemy go rozbudowywać i refaktoryzować.

Jak przystało na programistów Ruby on Rails - naszą przykładową aplikacją będzie blog. Na początek mamy posty z tytułem, hookiem, body, booleanem określającym czy dany post jest draftem i datą publikacji. Post ma też wiele głosów (votes). Dla utrudnienia votes jest relacją polimorficzną, żebyśmy mogli dodać podobny system również do komentarzy, które zaimplementujemy w przyszłości.

CREATE TABLE public.posts (
	id bigint NOT NULL,
	title character varying,
	hook character varying,
	body text,
	tags text[] DEFAULT '{}'::text[],
	published_at timestamp without time zone,
	draft boolean,
	created_at timestamp(6) without time zone NOT NULL,
	updated_at timestamp(6) without time zone NOT NULL
);
CREATE TABLE public.votes (
	id bigint NOT NULL,
	weight smallint,
	voteable_type character varying NOT NULL,
	voteable_id bigint NOT NULL,
	user_id bigint NOT NULL,
	created_at timestamp(6) without time zone NOT NULL,
	updated_at timestamp(6) without time zone NOT NULL
);


Zbudujmy więc scope, który przyjmie parametry z naszego formularza (widocznego poniżej) i zamieni je na pełnowartościowe zapytanie.

Kod widoku:

<p id="notice"><%= notice %></p>

<%= form_with scope: :search, url: search_posts_path, method: :get, local: true do |f| %>
Query: <%= f.search_field :query, value: params.dig(:search, :query) %>
<p>
  Date range:
  <%= f.date_field :date_from, value: params.dig(:search, :date_from) %> -
  <%= f.date_field :date_to, value: params.dig(:search, :date_to) %>
<br/></p>
Categories: <%= f.select :category, Category.pluck(:name, :id),
  {include_blank: true, selected: params.dig(:search, :category)},
  {multiple: true} %>
<%= f.submit 'Search', data: {disable_with: 'Searching'} %>
<% end %>

<div id='search-results'>
  <% @posts.each do |post| %>
	<h2><%= post.title %></h2>
	<b><%= post.hook %></b>
	<p>Reputation: <%= post.votes.sum(:weight) %></p>
	<span>Published <%= post.published_at %></span>
  <% end %>
</div>

<br>

<%= link_to 'New Post', new_post_path %>


Bazując na doświadczeniu - dostając aplikację o podobnej specyfikacji spodziewałbym się czegoś takiego:

def self.search(prms)
  query = where(draft: false)
  query = query.where('title ilike ?', prms[:query]) if prms[:query].present?
  unless prms[:date_from].blank? || prms[:date_to].blank?
    query = query.where('published at between ? and ?', prms[:date_from], prms[:date_to])
  end
  if prms[:category]&.reject!(&:blank?)&.any?
    query = query.joins(:categories).where(categories: { id: prms[:category] })
  end
  query
end


Pierwszym dobrym pomysłem jest tutaj wyszczególnienie listy atrybutów, które potrzebujemy z bazy. Zapytanie można przyspieszyć pomijając na przykład duży blok tekstu jakim będzie prawdopodobnie body. Przy okazji unikniemy pobierania atrybutów, które mogą zostać dodane w przyszłości. Nasze wyniki wyszukiwania będą pokazywały tytuł będący jednocześnie linkiem (title i id), zajawkę (hook), datę publikacji i ilość głosów. System głosowania jest bardziej wyrafinowany, ale na początek wyświetlimy tylko count.

Pierwszy problem jaki rzuca się tutaj w oczy to n+1, czyli seria dodatkowych zapytań związanych z liczeniem głosów - po jednym dodatkowym zapytaniu na każdy rekord. Najprostszym (chociaż na pewno nie najlepszym) rozwiązaniem jest tutaj dodanie .includes(:votes). Na razie tak to zostawmy - w przyszłej części przerobimy to tak, żeby głosy były liczone wewnątrz samego zapytania.

def self.search(prms)
  query = select(:id, :title, :hook, :published_at).where(draft: false).order(:title).includes(:votes)
  query = query.where('title ilike ?', "%#{prms[:query]}%") if prms[:query].present?
  unless prms[:date_from].blank? || prms[:date_to].blank?
    query = query.where('published at between ? and ?', prms[:date_from], prms[:date_to])
  end
  if prms[:category]&.reject!(&:blank?)&.any?
    query = query.joins(:categories).where(categories: { id: prms[:category] })
  end
  query
end


Wyrzućmy sobie filtrowanie po dacie do osobnego scope’a:

scope :by_date, -> (from, to) { where(‘published_at BETWEEN ? AND ?’, from, to) }


Osobiście nie przepadam za literalami, ale zapewne znacie składnię ze znakami zapytania pod które jest podstawiany dalszy array parametrów. Robi się to w ten sposób, żeby zapewnić prawidłowe escapowanie parametrów. Gdybyśmy po prostu interpolowali string - wartości mogłyby nie być bezpieczne.

Rzadziej widuje się użycie hasha:

scope :by_date, -> (from, to) { where(‘published_at BETWEEN :from AND :to’, from: from, to: to }


W tym przypadku składnia dłuższa i raczej zbędna, ale przydatna gdy jeden parametr wklejamy wiele razy (również po to, żeby uniknąć wielu calli do tej samej metody), albo parametrów jest dużo i chcemy zwiększyć czytelność.

Zwykle takie implementacje widzi się w kodzie pisanym przez programistów innych języków, którzy po prostu są przyzwyczajeni do pisania literali, albo w implementacjach wymagających dodatkowego sprawdzenia, czy podane wartości są prawidłowe... The Rails (6.0+) way:

scope :by_date, -> (from, to) { where(published_at: from..to) }


UWAGA: Z tą składnią nie powinno być problemów w Ruby 2.7+ i Rails 6.0.3+, które obsługują endless (od ruby-2.6) i beginless (od ruby-2.7) range. We wcześniejszych wersjach Ruby pojawi się problem jeśli from, albo to będą miały wartość nil - ArgumentError: bad value for range, a we wcześniejszych wersjach Rails działających na Ruby 2.6+ zostanie wygenerowane nieprawidłowe zapytanie - BETWEEN ‘2020-06-06’ AND NULL co nie zwróci nam ANI JEDNEGO rekordu. Podane wersje Ruby i Rails obsłużą wszystko prawidłowo nawet jeśli obie zmienne będą miały wartość nil.

Przykład z datami jest specyficzny, ale tej samej składni można użyć również przy integerach.

NOTE: Użycie zamkniętego range’a (dwukropkowego) skutkuje użyciem operatora BETWEEN, gdybyśmy jednak użyli otwartego (trzykropkowego) range’a, który nie zawiera ostatniej wartości - ActiveRecord wygeneruje zapytanie WHERE "posts"."published_at" >= $1 AND "posts"."published_at" < $2

Drugim scopem, jaki sobie stworzymy, będzie filtrowanie draftów:

scope :published, -> { where(draft: false) }


Mogłoby się wydawać, że to wszystko i nie ma tu nic więcej do zrobienia, ale jest jeden potencjalny problem. Ruby on Rails podczas generowania migracji domyślnie nie daje polom typu boolean klauzuli NOT NULL.

To oznacza, że w wielu aplikacjach nasze booleany mają trzy potencjalne wartości zamiast dwóch - TRUE, FALSE i NULL. To może się stać pułapką, dlatego że w SQL FALSE i NULL to nie to samo. Filtrując where(draft: false) rekordy gdzie draft ma wartość NULL są pomijane. Tak samo przy filtrowaniu where(draft: true). Dlatego polecam do booleanów w swoich migracjach dodawać klauzulę null: false, albo wartość domyślną, chyba że celowo zamierzacie korzystać z logiki trójwartościowej :)

Kuszące może się wydawać rozwiązanie tego problemu przy pomocy .not:

Miałem identyczny problem w pewnej aplikacji. Po sprawdzeniu rekordów z draft = NULL stwierdziliśmy, że oryginalni twórcy zakładali, że NULL = FALSE. Moglibyśmy po prostu UPDATE-ować wszystkie wartości NULL na FALSE, ale nie mieliśmy pewności, czy wadliwe dane nie przychodzą z zewnątrz (więcej aplikacji łączyło się z tą samą bazą danych). Koniec końców problem rozwiązaliśmy:

scope :published, -> { where(draft: [nil, false] }


W ActiveRecordzie nil reprezentuje NULL, więc takie zapytanie przynosi rezultaty, których się spodziewamy. Co ciekawe, zwykle przekazanie array'a jako wartości w ten sposób skutkuje użyciem operatora IN, ale jeśli jedna z wartości w arrayu to nil - wtedy ActiveRecord wygeneruje zapytanie WHERE ("posts"."draft" = $1 OR "posts"."draft" IS NULL). Jeśli wartości jest więcej - zostanie użyty operator IN, ale NULL-e zawsze będą sprawdzane przez IS NULL (lub IS NOT NULL). Dzieje się tak dlatego, że NULL jest specjalną wartością, która posiada własny swego rodzaju indeks w bazie danych i sprawdzenie tej wartości osobno jest dużo szybsze. Ignorujecie NULL na własną odpowiedzialność.

Jak mogliście zauważyć - ActiveRecord wygenerował nam zapytanie używające operatora OR i robił tak już dużo wcześniej, ale warto przypomnieć, że możliwość własnoręcznego tworzenia scopów z użyciem OR (bez literali) dostaliśmy dopiero w Rails 5, więc moglibyśmy napisać to tak:

scope :published, -> { where(draft: false).or(where(draft: nil)) }


Array jest krótszy, ale warto pamiętać, że mamy możliwość użyć OR.

def self.search(prms)
  query = select(:id, :title, :hook, :published_at).published
  query = query.where('title ilike ?', "%#{prms[:query]}%") if prms[:query].present?
  query = query.by_date(prms[:from], prms[:to]) if prms.slice(:date_from, :date_to).any?
  if prms[:category]&.reject!(&:blank?)&.any?
    query = query.joins(:categories).where(categories: { id: prms[:category] })
  end
  query.order(:title).includes(:votes)
end


NOTE: W tym konkretnym przypadku, pisząc czysty SQL, moglibyśmy użyć operatora IS NOT TRUE, który zadziałałby tak, jak chcemy - zwracając również rekordy, gdzie draft = NULL, ale ActiveRecord nie daje nam możliwości użycia tego operatora przez standardową składnię. Wcześniej również mogliście zauważyć, że AR nie ma problemu z generowaniem zapytań z użyciem operatorów >= i <, do których również nie mamy bezpośredniego dostępu (za wyjątkiem najnowszych wersji Ruby i Rails, gdzie możemy je uzyskać stosując endless i beginless range).

O tym, jak możemy użyć tych (i wielu innych) operatorów, nie porzucając komfortu programowania w Ruby on Rails - już w następnym odcinku, w którym zajmiemy się funkcjami agregującymi i rozprawimy się ze zliczaniem głosów dla naszych postów!

O autorze

Pracuję w Polcode jako senior Ruby on Rails developer, mam w sumie ~10 lat doświadczenia w branży. Pracowałem przy przeróżnych projektach, ale koniec końców najbardziej lubię pracę na backendzie i przy bazach danych. Można mnie spotkać na eventach takich jak KRUG, czy Rails Girls. Poza pracą lubię dobrą muzykę i dobre jedzenie, jestem też wielkim nerdem w kwestiach zdrowia i odżywiania.

<p>Loading...</p>