Go to Hackademy website

ActiveRecord c'est aussi ARel !

Nicolas Cavigneaux

Posté par Nicolas Cavigneaux dans les catégories back

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.

Articles connexes

Ruby, Sidekiq et Crystal

25/07/2019

Dans le cadre d’une application web il nous est tous déjà arrivé de devoir effectuer une tâche assez longue en asynchrone pour ne pas gêner le flux de notre application. En Ruby la solution la plus...

Du dosage à la rouille

13/06/2019

Parlons de la rouille, cette délicieuse sauce qui accompagne nos soupes de poisson. Le bon dosage des ingrédients ravira le palais vos convives ! Pour faire une portion de rouille les ingrédients...

Une brève histoire d'Elixir et Erlang/OTP

31/01/2019

Je développe depuis plusieurs années en Ruby. Depuis mon arrivée chez Synbioz, j’expérimente en plus avec Elixir de façon assez naturelle. En quoi Elixir est-il différent, me demanderez-vous ? Pour...

Écrire une thread pool en Ruby

10/01/2019

Pouvoir exécuter plusieurs tâches en parallèle, que ce soit dans un script ou une application, peut être vraiment très utile, surtout dans le cas où le traitement de ces tâches peut être très long....