Blog tech

ActiveRecord c'est aussi ARel !

Rédigé par Nicolas Cavigneaux | 4 juillet 2012

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.

ARel dans ActiveRecord ? Où ça ?

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]

Génération de conditions

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, …

Chaînage de conditions

>> 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"

Utilisation de OR

>> 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)"

Utilisation explicite de AND

>> 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)"

Création d’une jointure

>> 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`"

Création de groupes

>> t.group(:price).class
=> Arel::SelectManager

>> t.group(:price).to_sql
=> "SELECT FROM `products`  GROUP BY price"

Intervalles

>> 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.

Prédicats

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 :

Égalité

>> 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')"

Appartenance

>> 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'))"

Concordances

>> 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%')"

Comparaisons numériques

>> 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, …

Le mot de la fin

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.