Č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 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ů.
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 :)
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">]
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 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'
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.
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'
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'
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.
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.