Toto je repost mého článku z 7.1.2015 na Bonobo Blogu zde: https://blog.bonobo.cz/2015/01/07/rails-hstore/.

Rails a PostgreSQL hstore

Čas od času ve své aplikaci narazíte na model, ke kterému potřebujete uložit data, jejichž struktura se může s časem nebo model od modelu lišit. Například uživatelské preference. To se dost dobře nedá vyřešit nekonečným přidáváním sloupců do databáze, protože skončíte u obrovských tabulek s nekonzistentními daty.

V poslední době jsou populární různé NoSQL databáze, které s proměnlivou strukturou dat problém nemají. Nicméně přidat si do stacku další technologii bude většinou zbytečný overkill. A pokud používáte jako databázi PostgreSQL, tak to vůbec nepotřebujete, protože ta nabízí hned dva luxusní key-value datové typy, které přesně pro tyto potřeby můžete využít. My se podíváme na hstore a práci s ním v Ruby on Rails aplikacích.

Hstore

Hstore je relativně starý datový typ. V PostgreSQL se v podobě extension objevil již ve verzi 8.2. Od té doby solidně vyzrál a nyní je pro něj k dispozici přes 30 různých operátorů a funkcí. Abyste ho mohli používat, musíte v db extension explicitně zapnout. Často budete předtím potřebovat ve svém systému doinstalovat balíček apt-get install postgresql-contrib, který právě různé extensions obsahuje. Pro zapnutí většiny extensions, hstore nevyjímaje, budete také muset mít v databázi práva superusera.

Hstore interně ukládá data v binární podobě. Díky tomu a podpoře hash, b-tree, GIN a GIST indexů je velice rychlý. Bohužel momentálně je možné v hstore ukládat pouze jednoúrovňové struktury. Pokud potřebujete ukládat více-úrovňová data, tak Vám pomůže druhý key-value data typ JSON, na který se podíváme v některém z dalších článků.

Rails a hstore

Hstore je nativně v Railsech, resp. ActiveRecord podporován od verze 4.0. Je namapován na klasický Hash se string klíčy, takže můžete s daty ihned pohodlně pracovat.

Pro testování hstoru si ideálně vygenerujeme úplně prázdnou aplikaci:


$ rails new hstore_app --database=postgresql

Aplikaci máme. Teď je potřeba v db zapnout hstore extension, jinak by nám později migrace modelu spadla na neznámém datovém typu. ActiveRecord PostgreSQL adaptér podporuje i zapínání a vypínání extensions, takže se vůbec nemusíme dotknout SQL.


$ rails g migration EnableHstoreExtension

Do migrace přidáme jediný řádek, a to enable_extension :hstore.


class EnableHstoreExtension < ActiveRecord::Migration
   def change
      enable_extension :hstore
   end
end

Pomocí $ rake db:migrate zmigrujeme a hstore extension je zapnutá. Klidně si zkuste $ rake db:rollback. I u metody enable_extension Rails vědí, jak jí správně vrátit.

Teď samotný model.


$ rails g model User name:string preferences:hstore

Jak vidíte, i generátory podporují hstore atributy. Tento příkaz nám mimo jiné vytvoří následující migraci:


class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
      t.string :name
      t.hstore :preferences
      t.timestamps
    end
    add_index :users, :preferences, using: :gin
  end
end

Jediné, co jsme přidali manuálně je add_index, i ten jde zadat do generátoru nicméně my chceme speciálně typ GIN, což už přes generátor vybrat nejde. Teď nám nic nebrání migraci projet a máme připravený model i s tabulkou. Stejně jako minule, všechno se dá krásně rollbacknout.

Teď si zapneme Rails konzoli a vytvoříme nějaká testovací data.


irb(main):001:0>  User.create(name: 'Tomáš', preferences: { language: 'cs', newsletter: true })
irb(main):002:0>  User.create(name: 'Martina', preferences: { language: 'cs', newsletter: true, app_theme: 'moon' })
irb(main):003:0>  User.create(name: 'Patrik', preferences: { language: 'en', newsletter: true, app_theme: 'moon' })

Asi tak tři záznamy nám zatím budou stačit :)

Queries

Co nás bude zajímat asi nejvíc jsou queries. Pro ilustraci tady uvedu dvě.

Nejčastěji nás bude zajímat, kdo všechno má vůbec určitou preferenci nastavenou: Všechny uživatele, kteří mají “app_theme” nastaveno získáme následovně:


irb(main):001:0> User.where("preferences ? :preference", preference: 'app_theme')
irb(main):002:0> => User Load (0.8ms)  SELECT "users".* FROM "users"  WHERE (preferences ? 'app_theme')
irb(main):003:0> => [#"en", "app_theme"=>"moon", "newsletter"=>"true"}, created_at: "2014-12-17 19:52:43", updated_at: "2014-12-17 19:52:43">,
#"cs", "app_theme"=>"moon", "newsletter"=>"true"}, created_at: "2014-12-17 19:52:57", updated_at: "2014-12-17 19:52:57">]

To nám správně vrátí Martinu a Patrika, protože jen oni mají preferenci “app_theme” nastavenou.

Konkrétnější pak můžeme být s operátorem ->, který pro určitý klíč vrátí hodnotu. Takže pokud bychom chtěli všechny uživatele, kteří si nastavili řeč na angličtinu, tak můžeme udělat:


irb(main):001:0> User.where("preferences -> 'language' = ?", 'en')
irb(main):002:0> => User Load (1.3ms)  SELECT "users".* FROM "users"  WHERE (preferences -> 'language' = 'en')
irb(main):003:0> => [#"en", "app_theme"=>"moon", "newsletter"=>"true"}, created_at: "2014-12-17 19:52:43", updated_at: "2014-12-17 19:52:43">]

A tady jsme správně dostali zpátky jen Patrika, protože ostatní si jako řeč nastavili češtinu.

Jak jsem již zmiňoval, operátorů pro práci s hstorem je celá kupa. Všechny je zde vypisovat nemá cenu. K tomu doporučuji pročíst dokumentaci. Indexy jsou podporovány u @>, ?, ?&, ?|.


irb(main):001:0> User.where("preferences -> 'language' = ?", 'en')
irb(main):002:0> => User Load (1.3ms)  SELECT "users".* FROM "users"  WHERE (preferences -> 'language' = 'en')
irb(main):003:0> => [#"en", "app_theme"=>"moon", "newsletter"=>"true"}, created_at: "2014-12-17 19:52:43", updated_at: "2014-12-17 19:52:43">]

store_accessor a validace

Pro ještě pohodlnější práci s hstore nabízí Rails užitečné makro store_accessor, které vygeneruje settery a gettery ke zvoleným klíčům, takže potom můžete data jednoduše použít např. ve formuláři.


class User < ActiveRecord::Base
  store_accessor :preferences, :language, :theme
end

irb(main):001:0> user = User.create(name: ‘Fred’, language: ‘cs’)
irb(main):002:0> user.language
irb(main):003:0> => 'cs'
irb(main):005:0> user.language = 'en'
irb(main):006:0> user.language
irb(main):007:0> => 'en'

A jakmile máte gettery a settery, můžete jednotlivé hodnoty validovat klasickými přes klasické Rails validace.


class User < ActiveRecord::Base
  store_accessor :preferences, :language, :theme, :newsletter

  validates :language,
    presence: true
end

irb(main):001:0> user = User.new(language: nil)
irb(main):002:0> user.valid?
irb(main):003:0> => false
irb(main):004:0> user.errors
irb(main):005:0> => #nil}, created_at: nil, updated_at: nil>, @messages={:language=>["can't be blank"]}>

Defaultní hodnoty

Defaultní hodnoty hstore atributu můžete nastavit přímo v databázi. Vše jde zase pohodlně přímo v migraci. Žádné přímé SQL.

Celá migrace i s not-null constraint pak vypadá takto:


class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
       t.string :name
       t.hstore :preferences, default: { language: 'cs' }, null: false
       t.timestamps
     end
  end

   add_index :users, :preferences, using: :gin
 end

Ukázka z konzole:


irb(main):001:0> user = User.new
irb(main):002:0> user.language
irb(main):003:0> => 'cs'

Pár tipů nakonec aneb na co si dát pozor

Kombinace PostgreSQL hstore a Railsu je production-ready. Nicméně je pár věcí, na které je potřeba si dát pozor protože by Vás mohli v průběhu implementace překvapit.

Hodnoty se v databázi převedou na stringy

Vše co do hstore uložíte se při zápisu převede na string. To může být matoucí zejména u true a false hodnot. Jakmile totiž takovou hodnotu uložíte a Rails model znovu načtete, tak dostanete zpátky string “true” nebo “false”.

Na string ale hodnoty převádí až Postgres sám, což znamená, že do doby, než data uložíte a znovu načtete, tak v hashi reprezentujícím atribut máte stále totožné hodnoty, jako jste do něj přiradili. Tedy u true a false budete mít do uložení a znovunačtení pořád true a false. Tato nekonzistence by alespoň částečně měla být vyřešena v Rails 4.2, kdy se při nastavování hodnot přes settery bude na string převádět okamžitě.


irb(main):001:0> user = User.new
irb(main):002:0> user.newsletter = true
irb(main):003:0> user.newsletter
irb(main):004:0> => true
irb(main):005:0> user.save
irb(main):006:0> user.reload.newsletter
irb(main):007:0> => 'true'

Hodnoty se uloží jen při nastavení přes settery

Pokud změníte v hstoru nějakou hodnotu jinak než přes setter ze store_accessor, tak následující #save hodnotu neuloží. Je to proto, že Rails nemají možnost zjistit, zda se hodnota v namapovaném hashi změnila nebo ne a tedy neví, zda mají atribut opravdu uložit. Je nutné hodnoty nastavovat přes setter, nebo pokud víte, že se hodnota změní, tak před tím explicitně zavolat metodu z ActiveModel::Dirty #{attribute_name}_will_change!, což atribut označí k uložení. I toto by mělo být v Rails 4.2 vyřešeno. Hstore atributy se budou stejně jako serializované atributy ukládat pro jistotu vždy.


irb(main):001:0> user = User.new(language: 'cs')
irb(main):002:0> user.preferences['language'] = 'en'
irb(main):003:0> user.save
irb(main):004:0> user.language
irb(main):005:0> => 'en'
irb(main):006:0> user.reload.language
irb(main):007:0> => 'cs'

Rozdíly mezi Rails 4.0 a 4.1

V Rails 4.0 byl hstore namapován na HashWithIndiferrentAccess, takže jste mohli k hodnotám přistupovat přes string i symbol klíče. Od Rails 4.1 je ale hstore namapován na klasický Hash se string klíčy, takže se symboly už adresovat nemůžete.

hstore s Rails < 4.0

Podpora hstoru byla do Railsů přidána až ve verzi 4.0. Pokud ale máte aplikaci s nižší verzí, můžete pro podporu hstoru použít gem activerecord-postgres-hstore.