Dans un article précédant, Nicolas vous présentait Sequel et vous démontrait ses possibilités dans le domaine de l’intéraction avec les SGBD.
Aujourd’hui place à ARel qui est une librairie gérant toute l’abstraction bas niveau SQL. L’idée d’ARel est de fournir une première couche d’abstraction permettant d’écrire son propre ORM en se concentrant sur le développement des fonctionnalitées spécifiques et novatrices de celui-ci.
ActiveRecord repose entièrement sur ARel pour ses fondements. Il est donc important de connaître ARel si vous voulez maîtriser pleinement ActiveRecord.
Prenons l’exemple d’une table “products” classique, contenant nom, description, prix, etc. Dans votre code un Product.columns fera appel à ARel pour récupérer les infos concernant les colonnes. En fait, l’objet Arel::Table
est lui même stocké dans l’objet ActiveRecord :
>> Product.arel_table
=> #<Arel::Table:0x007fbff55887b8 @name="products", @engine=Product(id: integer, brand_id: integer, name: string, created_at: datetime, updated_at: datetime), @columns=nil, @aliases=[], @table_alias=nil, @primary_key=nil>
>> Product.arel_engine
=> Product(id: integer, brand_id: integer, name: string, created_at: datetime, updated_at: datetime)
Cet objet Arel::Table
peut nous servir directement pour générer du SQL qui sera utilisé par ActiveRecord. Nous pouvons récupérer l’objet Arel::Table
depuis ActiveRecord comme au dessus ou l’initialiser nous même :
>> t = Arel::Table.new(:products)
=> #<Arel::Table:0x007fbff5855fe0 @name="products", @engine=ActiveRecord::Base, @columns=nil, @aliases=[], @table_alias=nil, @primary_key=nil>
Sur cette table, nous pourrons récupérer les colonnes ainsi que leurs propriétés (nom, relation, …)
>> t.columns
=> [#<struct Arel::Attributes::Integer relation=#<Arel::Table:0x007fbff5e82a80 @name="products", @engine=ActiveRecord::Base, @columns=[...], @aliases=[], @table_alias=nil, @primary_key=nil>, name=:id>, #<struct Arel::Attributes::Integer relation=#<Arel::Table:0x007fbff5e82a80 @name="products", @engine=ActiveRecord::Base, @columns=[...], @aliases=[], @table_alias=nil, @primary_key=nil>, name=:brand_id>, #<struct Arel::Attributes::String relation=#<Arel::Table:0x007fbff5e83a80 @name="products", @engine=ActiveRecord::Base, @columns=[...], @aliases=[], @table_alias=nil, @primary_key=nil>, name=:name>, #<struct Arel::Attributes::Time relation=#<Arel::Table:0x007fbff5e82a80 @name="products", @engine=ActiveRecord::Base, @columns=[...], @aliases=[], @table_alias=nil, @primary_key=nil>, name=:created_at>, #<struct Arel::Attributes::Time relation=#<Arel::Table:0x007fbff5e82a80 @name="products", @engine=ActiveRecord::Base, @columns=[...], @aliases=[], @table_alias=nil, @primary_key=nil>, name=:updated_at>]
>> t.columns.map &:name
=> [:id, :brand_id, :created_at, :updated_at, :shape, :slug, :kind_id, :oxylane_kind]
Tout l’intérêt d’ARel réside dans sa capacité à générer du SQL pour vous et de manière agnostique. Plus besoin de gérer les “LIKE” / “ILIKE” en fonction du SGBD, vos conditions sont portables.
>> t[:name].eq("iPhone").class
=> Arel::Nodes::Equality
>> t[:name].eq("iPhone").to_sql
=> "`products`.`name` = 'iPhone'"
>> t[:email].matches("%iPhone%").class
=> Arel::Nodes::Matches
>> t[:email].matches("%iPhone%").to_sql
=> "`products`.`email` LIKE '%iPhone%'"
>> t.where(t[:name].eq("iPhone")).class
=> Arel::SelectManager
>> t.where(t[:name].eq("iPhone")).to_sql
=> "SELECT FROM `products` WHERE `products`.`name` = 'iPhone'"
>> t.where(t[:name].eq("iPhone")).project(t[:name], t[:id]).to_sql
=> "SELECT `products`.`name`, `products`.`id` FROM `products` WHERE `products`.`name` = 'iPhone'"
On peut donc, en Ruby, générer sa requête SQL. Il est biensûr possible de créer des conditions bien plus complexes, de les chaîner, …
>> t.where(t[:name].eq('Test')).where(t[:price].lt(100)).class
=> Arel::SelectManager
>> t.where(t[:name].eq('Test')).where(t[:price].lt(100)).to_sql
=> "SELECT FROM `products` WHERE `products`.`name` = 'Test' AND `products`.`price` < 100"
>> t.where(t[:name].eq('Test').or(t[:price].lt(100))).class
=> Arel::SelectManager
>> t.where(t[:name].eq('Test').or(t[:price].lt(100))).to_sql
=> "SELECT FROM `products` WHERE (`products`.`name` = 'Test' OR `products`.`price` < 100)"
>> t.where(t[:name].eq('Test').and(t[:price].lt(100))).class
=> Arel::SelectManager
>> t.where(t[:name].eq('Test').and(t[:price].lt(100))).to_sql
=> "SELECT FROM `products` WHERE (`products`.`name` = 'Test' AND `products`.`price` < 100)"
>> photos = Arel::Table.new(:photos)
=> #<Arel::Table:0x007fbff5cd8090 @name="photos", @engine=ActiveRecord::Base, @columns=nil, @aliases=[], @table_alias=nil, @primary_key=nil>
>> t.join(photos).on(t[:id].eq(photos[:product_id])).class
=> Arel::SelectManager
>> t.join(photos).on(t[:id].eq(photos[:product_id])).to_sql
=> "SELECT FROM `products` INNER JOIN `photos` ON `products`.`id` = `photos`.`product_id`"
>> t.group(:price).class
=> Arel::SelectManager
>> t.group(:price).to_sql
=> "SELECT FROM `products` GROUP BY price"
>> t.take(5).skip(10).class
=> Arel::SelectManager
>> t.take(5).skip(10).to_sql
=> "SELECT FROM `products` LIMIT 5 OFFSET 10"
Vous pouvez tout chainer à loisir, créer des méthodes pour vos outils de recherche. ARel ne présente pas de réelle limitation et permet d’aller beaucoup plus loin qu’ActiveRecord qui limite volontairement son interface publique.
Nous allons d’ailleurs maintenant voir la liste de prédicats que nous offre ARel pour étoffer nos requêtes.
Vous pourrez retrouver l’ensemble des prédicats directement dans le code source sur GitHub mais je vais tout de même prendre le temps de vous les lister :
>> t[:name].eq("iPhone").class
=> Arel::Nodes::Equality
>> t[:name].eq("iPhone").to_sql
=> "`products`.`name` = 'iPhone'"
>> t[:name].not_eq("iPhone").class
=> Arel::Nodes::NotEqual
>> t[:name].not_eq("iPhone").to_sql
=> "`products`.`name` != 'iPhone'"
>> t[:name].eq_any(["iPhone", "BlackBerry"]).class
=> Arel::Nodes::Grouping
>> t[:name].eq_any(["iPhone", "BlackBerry"]).to_sql
=> "(`products`.`name` = 'iPhone' OR `products`.`name` = 'BlackBerry')"
>> t[:name].not_eq_any(["iPhone", "BlackBerry"]).class
=> Arel::Nodes::Grouping
>> t[:name].not_eq_any(["iPhone", "BlackBerry"]).to_sql
=> "(`products`.`name` != 'iPhone' OR `products`.`name` != 'BlackBerry')"
>> t[:name].eq_all(["iPhone", "BlackBerry"]).class
=> Arel::Nodes::Grouping
>> t[:name].eq_all(["iPhone", "BlackBerry"]).to_sql
=> "(`products`.`name` = 'iPhone' AND `products`.`name` = 'BlackBerry')"
>> t[:name].not_eq_all(["iPhone", "BlackBerry"]).class
=> Arel::Nodes::Grouping
>> t[:name].not_eq_all(["iPhone", "BlackBerry"]).to_sql
=> "(`products`.`name` != 'iPhone' AND `products`.`name` != 'BlackBerry')"
>> t[:name].in(["iPhone", "BlackBerry"]).class
=> Arel::Nodes::In
>> t[:name].in(["iPhone", "BlackBerry"]).to_sql
=> "`products`.`name` IN ('iPhone', 'BlackBerry')"
>> t[:name].not_in(["iPhone", "BlackBerry"]).class
=> Arel::Nodes::NotIn
>> t[:name].not_in(["iPhone", "BlackBerry"]).to_sql
=> "`products`.`name` NOT IN ('iPhone', 'BlackBerry')"
>> t[:name].in_any(["iPhone", "BlackBerry"]).class
=> Arel::Nodes::Grouping
>> t[:name].in_any(["iPhone", "BlackBerry"]).to_sql
=> "(`products`.`name` IN ('iPhone') OR `products`.`name` IN ('BlackBerry'))"
>> t[:name].not_in_any(["iPhone", "BlackBerry"]).class
=> Arel::Nodes::Grouping
>> t[:name].not_in_any(["iPhone", "BlackBerry"]).to_sql
=> "(`products`.`name` NOT IN ('iPhone') OR `products`.`name` NOT IN ('BlackBerry'))"
>> t[:name].in_all(["iPhone", "BlackBerry"]).class
=> Arel::Nodes::Grouping
>> t[:name].in_all(["iPhone", "BlackBerry"]).to_sql
=> "(`products`.`name` IN ('iPhone') AND `products`.`name` IN ('BlackBerry'))"
>> t[:name].not_in_all(["iPhone", "BlackBerry"]).class
=> Arel::Nodes::Grouping
>> t[:name].not_in_all(["iPhone", "BlackBerry"]).to_sql
=> "(`products`.`name` NOT IN ('iPhone') AND `products`.`name` NOT IN ('BlackBerry'))"
>> t[:name].matches("%Foo%").class
=> Arel::Nodes::Matches
>> t[:name].matches("%Foo%").to_sql
=> "`products`.`name` LIKE '%Foo%'"
>> t[:name].does_not_match("%Foo%").class
=> Arel::Nodes::DoesNotMatch
>> t[:name].does_not_match("%Foo%").to_sql
=> "`products`.`name` NOT LIKE '%Foo%'"
>> t[:name].matches_any(["%Foo%", "%Bar%"]).class
=> Arel::Nodes::Grouping
>> t[:name].matches_any(["%Foo%", "%Bar%"]).to_sql
=> "(`products`.`name` LIKE '%Foo%' OR `products`.`name` LIKE '%Bar%')"
>> t[:name].does_not_match_any(["%Foo%", "%Bar%"]).class
=> Arel::Nodes::Grouping
>> t[:name].does_not_match_any(["%Foo%", "%Bar%"]).to_sql
=> "(`products`.`name` NOT LIKE '%Foo%' OR `products`.`name` NOT LIKE '%Bar%')"
>> t[:name].matches_all(["%Foo%", "%Bar%"]).class
=> Arel::Nodes::Grouping
>> t[:name].matches_all(["%Foo%", "%Bar%"]).to_sql
=> "(`products`.`name` LIKE '%Foo%' AND `products`.`name` LIKE '%Bar%')"
>> t[:name].does_not_match_all(["%Foo%", "%Bar%"]).class
=> Arel::Nodes::Grouping
>> t[:name].does_not_match_all(["%Foo%", "%Bar%"]).to_sql
=> "(`products`.`name` NOT LIKE '%Foo%' AND `products`.`name` NOT LIKE '%Bar%')"
>> t[:price].gt(100).class
=> Arel::Nodes::GreaterThan
>> t[:price].gt(100).to_sql
=> "`products`.`price` > 100"
>> t[:price].lt(100).class
=> Arel::Nodes::LessThan
>> t[:price].lt(100).to_sql
=> "`products`.`price` < 100"
>> t[:price].gteq(100).class
=> Arel::Nodes::GreaterThanOrEqual
>> t[:price].gteq(100).to_sql
=> "`products`.`price` >= 100"
>> t[:price].lteq(100).class
=> Arel::Nodes::LessThanOrEqual
>> t[:price].lteq(100).to_sql
=> "`products`.`price` <= 100"
>> t[:price].gt_any([100, 120]).class
=> Arel::Nodes::Grouping
>> t[:price].gt_any([100, 120]).to_sql
=> "(`products`.`price` > 100 OR `products`.`price` > 120)"
>> t[:price].lt_any([100, 120]).class
=> Arel::Nodes::Grouping
>> t[:price].lt_any([100, 120]).to_sql
=> "(`products`.`price` < 100 OR `products`.`price` < 120)"
>> t[:price].gteq_any([100, 120]).class
=> Arel::Nodes::Grouping
>> t[:price].gteq_any([100, 120]).to_sql
=> "(`products`.`price` >= 100 OR `products`.`price` >= 120)"
>> t[:price].lteq_any([100, 120]).class
=> Arel::Nodes::Grouping
>> t[:price].lteq_any([100, 120]).to_sql
=> "(`products`.`price` <= 100 OR `products`.`price` <= 120)"
>> t[:price].gt_all([100, 120]).class
=> Arel::Nodes::Grouping
>> t[:price].gt_all([100, 120]).to_sql
=> "(`products`.`price` > 100 AND `products`.`price` > 120)"
>> t[:price].lt_all([100, 120]).class
=> Arel::Nodes::Grouping
>> t[:price].lt_all([100, 120]).to_sql
=> "(`products`.`price` < 100 AND `products`.`price` < 120)"
>> t[:price].gteq_all([100, 120]).class
=> Arel::Nodes::Grouping
>> t[:price].gteq_all([100, 120]).to_sql
=> "(`products`.`price` >= 100 AND `products`.`price` >= 120)"
>> t[:price].lteq_all([100, 120]).class
=> Arel::Nodes::Grouping
>> t[:price].lteq_all([100, 120]).to_sql
=> "(`products`.`price` <= 100 AND `products`.`price` <= 120)"
Il est donc facile d’entrevoir toutes les possibilités qu’offre ARel pour générer du SQL complexe sans avoir à l’écrire soit même.
On pourrait aussi imaginer étendre toutes les colonnes avec ces prédicats (via method_missing
par exemple). On aurait ainsi accès à des “scopes” dans tous les modèles héritants de ActiveRecord::Base tels que name_matches, price_lte, category_ids_matches_all, …
Pour conclure pensez à passer par ARel pour écrire vos requêtes complexes plûtot que de les écrire à la main. Votre code sera plus lisible et maintenable et en vous aurez en bonus une compatibilité assurée entre tous le SGBD !
Si vous souhaitez créer votre propre ORM, ARel vous fournira déjà tout le nécessaire pour la connexion aux différents SGBD disponibles mais aussi tous les outils de base pour générer des requêtes SQL complexes.
Vous en priver reviendrait à vous auto-flageller !
L’équipe Synbioz.
Libres d’être ensemble.