Ceci est une traduction de l’article The skinny on scopes publié sur Edge Rails.

Je me souviens de mon cœur faisant des bonds lorsque le plugin has_finder de Nick Kallen a été implémenté dans rails core sous le nom de named_scope.
named_scope a rapidement rejoint la liste de mes outils préférés de par sa merveilleuse manière de créer des requêtes logiques encapsulées et réutilisables.
Alors qu’il avait ses points faibles (pour ne pas le nommer, le manque du support de :joins et :include), il a redéfini ma manière de penser la logique de mes modèles.
Une fois que vous avez gouté au plaisir des named_scopes, vous ne pouvez jamais revenir en arrière.

Et maintenant Rails 3 arrive avec son refactoring complet de ActiveRecord. Que deviennent nos cher named_scope ? Pour faire simple, cela a été renommé en scope et vous pouvez les utiliser comme vous le faisiez déjà … Mais de manière plus aisée.
Voyons un peu ce que l’on peut faire avec ces scopes dans Rails 3.

Usage basique

Supposons un modèle Post avec des champs published_at, title et content.
Dans rails 2.x, nous devrions définir les scopes published et recent.

class Post < ActiveRecord::Base
    named_scope :published, lambda {
        { :conditions =>
            ["posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now]
        }
    }
    named_scope :recent, : order => "posts.published_at DESC"
end
La raison pour laquelle nous utilisons un lambda ici est que cela permet de reporter l’exécution de Time.zone.now au moment ou la scope est effectivement invoquée.
Sans ce lambda, le moment retourné serait celui ou la classé est évaluée. Pas la scope elle même.

Avec Rails 3, l’architecture d’ActiveRecord est maintenant basée sur une classe Relation. Classe que vous pouvez voir comme une sorte de « named_scope on steroids », permettant de de chainer chaque requête directement dans ActiveRecord.

Vous pouvez voir comment utiliser les méthodes where, ordre etc dans l’article de Pratik sur cette nouvelle interface ou bien encore dans ce Railscast.
Comprendre ceci est important étant donné que les nouvelles scopes sont construites autour de cela.

Voyons comment. Voici nos deux différentes scopes portées sous Rails 3.

class Post < ActiveRecord::Base
    scope :published, lambda {
        where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now)
    }
    scope :recent, order("posts.published_at DESC")
end

Alors que la logique reste la même (les portions SQL), vous pouvez commencer à voir comment les scopes utilisent la nouvelle interface de requêtage pour construire directement la requête au lieu de construire un hash d’options comme c’était fait dans Rails 2.
Ceci est le tout premier aperçu de la flexibilité que la nouvelle interface apporte à nos scopes.
Elles ne sont plus construites différemment que n’importe quelle requête. Elles sont construites au dessus des mêmes méthodes que vous pouvez utiliser lorsque vous construisez directement vos requêtes.
Cette consistance est maintenant présent partout dans ActiveRecord.

Mais ce n’est pas tout …

Réutilisation des scopes

Supposons que nous désirions modifier notre scope « recent » pour n’inclure que les posts publiés. Nous avons déjà défini ce que published signifie et nous ne devrions donc pas avoir à le redéfinir pour créer une nouvelle scope.
Aucun problème ! Nous pouvons chainer les scopes elles mêmes et c’est ce que nous allons faire ici.

class Post < ActiveRecord::Base
    scope :published, lambda {
        where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now)
    }
    scope :published_since, lambda { |ago|
        published.where("posts.published_at >= ?", ago)
    }
    scope :recent, published.order("posts.published_at DESC")
end

Ca commence à devenir intéressant.

Construction dynamique

Déjà dans Rails 2.3, vous pouvez créer des scopes anonymes afin d’obtenir dynamiquement des scopes chainables selon vos besoins.
Un cas typique d’utilisation est lorsque vous désirez créer une méthode de recherche à laquelle vous pouvez toujours ajouter d’autres manipulations.

Par exemple pour chercher parmi nos posts, nous créons cette méthode qui retournera un scope que vous pourrez par la suite filtrer (notez l’utilisation de « scoped » pour démarrer la chaine avec une scope vide à laquelle d’autres peuvent être ajoutées).

class Post < ActiveRecord::Base
    class << self

        # Cherche dans les champs title et body la chaine donnée.
        # Démarre avec une scope vide et la construit pour chaque attribut.
        # Facilitera l'extraction des définitions des champs de recherche pour le futur.
        def search(q)
            [:title, :body].inject(scoped) do |combined_scope, attr|
                combined_scope.where("posts.#{attr} LIKE ?", "%#{q}%")
            end
        end
    end
end

L’utilisation de inject ici masque l’intérêt de cette méthode si vous n’avez pas l’habitude de voir de telles itérations. Voici une version plus aisée à comprendre contenant les champs de recherche en dur (et qui n’utilise même pas de scope anonyme).

class Post < ActiveRecord::Base
    class << self

        # Moins dynamique mais probablement plus lisible
        def search(q)
            query = "%#{q}%"
            where("posts.title LIKE ?", query).where("posts.body LIKE ?", query)
        end
    end
end

Vu que nous construisons nos scopes autour de la nouvelle interface qui est fortement chainable, nous pouvons faire la chose suivante avec notre méthode de recherche :

# What's in the db, titles ~= publish date
Post.all.collect(&:title) #=> ["1 week from now", "Now", "1 week ago", "2 weeks ago"]
Post.published.collect(&:title) #=> ["Now", "1 week ago", "2 weeks ago"]

# Combinaisons de recherche
Post.search('1').collect(&:title) #=> ["1 week from now", "1 week ago"]
Post.search('1').published.collect(&:title) #=> ["1 week ago"]
Post.search('w').published_since(10.days.ago).collect(&:title) #=> ["Now", "1 week ago"]
Post.search('w').order('created_at DESC').limit(2).collect(&:title) #=> ["2 weeks ago", "1 week ago"]

Vous pouvez imaginer un scénario ou des requêtes bien plus complexes pourront être construites en utilisant des scopes anonymes.
Cool non ?

Scopes multi modèles

Les scopes sont parfaites pour manipuler uniquement les colonnes d’un modèle unique. Mais elles peuvent également être utilisées pour construire des requêtes multi modèles (qui requièrent un join).
Ajoutons à nos posts des utilisateurs (qui peuvent être auteur ou commentateur) et écrivons quelques scopes sur le modèle User qui nous permettront de récupérer uniquement ceux ayant publié des billets et ceux qui ont commenté.

class User < ActiveRecord::Base
    has_many :posts, :foreign_key => :author_id
    has_many :comments

    # Récupération de tous les utilisateurs ayant publié un billet
    scope :published, lambda {
        joins("join posts on posts.author_id = users.id").
        where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now).
        group("users.id")
    }

    # Récupération de tous les utilisateurs qui ont commenté un billet
    scope :commented, joins("join comments on comments.user_id = users.id").group("users.id")
end

Notons également que ActiveRelation est également suffisamment intelligent pour savoir comment faire un join sur la définition de l’association, nous autorisant à placer cette relation en référence.

class User < ActiveRecord::Base
# Récupération de tous les utilisateurs ayant publié un billet
scope :published, lambda {
joins(:posts). # Inutile de rédiger le SQL
where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now).
group("users.id")
}

# Récupération de tous les utilisateurs qui ont commenté un billet
scope :commented, joins(:comments).group("users.id") # Just reference :comments
end

C’est une bonne pratique de référencer le nom complet de la colonne, avec sa table (posts.published_at au lieu de published_at).
Cela permet d’éviter des ambiguïtés de noms de colonnes. Particulièrement important lorsque vous construisez des scopes multi modèles ou des colonnes venant de plus d’une table peuvent être jointes.

Pour êtres super flexible, vous pouvez toujours invoquer table_name au lieu de mettre cela en dur.
Pour être franc, c’est quelque chose que je ne fais que rarement. where(« #{table_name}.published_at IS NOT NULL »)

Vu que nous avons l’arsenal complet des opérateurs ActiveRecord à notre disposition dans les scopes, nous pouvons faire des join et des group by dans les scopes qui seront chainées dans les requêtes complexes. Chose que named_scope n’arrivait que rarement à faire.

# Récupération de tous les utilisateurs ayant publié un billet
User.published.collect(&:username) #=> ["tim", "dave"]
User.published.to_sql
  #=> SELECT "users".* FROM "users" join posts on posts.author_id = users.id
  #   WHERE (posts.published_at IS NOT NULL AND posts.published_at <= '2010-02-22 21:33:00.892308')
  #   GROUP BY users.id

# Récupération de tous les utilisateurs ayant posté un commentaire
User.commented.collect(&:username) #=> ["ryan", "john", "tim", "dave"]
User.commented.to_sql
  #=> SELECT "users".* FROM "users" join comments on comments.user_id = users.id
  #   GROUP BY users.id

# Combinaison pour récupérer tous les auteurs qui ont posté un commentaire
User.published.commented.collect(&:username) #=> ["tim", "dave"]
User.published.commented.to_sql
  #=> SELECT "users".* FROM "users"
  #   join posts on posts.author_id = users.id
  #   join comments on comments.user_id = users.id
  #   WHERE (posts.published_at IS NOT NULL AND posts.published_at <= '2010-02-22 21:33:00.892308')
  #   GROUP BY users.id
Comme fait ici, vous pouvez utiliser to_sql afin de visualiser la requête SQL qui sera exécutée. C’est très pratique lors de débogages.

Opérations CRUD sur les scopes

Vu que ActiveRelation vous permet d’invoquer toutes les méthodes builder/update/destroy, celles-ci sont également accessibles pour les scopes.
Jouons un peu à faire des modifications sur nos scopes au lieu de se contenter de les récupérer.

# Incrémente le nombre de vues pour tous les billets publiés
Post.published.collect(&:views_count) #=> [59, 71, 42]
Post.published.update_all("views_count = views_count + 1")
Post.published.collect(&:views_count) #=> [60, 72, 43]

# On se moque des billets non publiés
Post.unpublished.size #=> 1
Post.unpublished.destroy_all
Post.unpublished.size #=> 0

Vous pouvez également créer un nouvel uplet avec des scopes existantes. Supposons que nous avons une scope (très peu utile) qui ne récupère que les posts avec un certain titre

class Post < ActiveRecord::Base
    scope :titled_luda, where(:title => 'Luda')
end

Nous pouvons utiliser cette scope pour créer de nouvelles instances (tout comme new, create, …)

Post.titled_luda.size #=> 0
Post.titled_luda.build
  #=> #<Post id: nil, title: "Luda", ...>
Afin de pouvoir utiliser les méthodes de création dans une scope, celle-ci doit définir l’égalité de l’attribut de manière directe dans le where avec un hash comme fait précédemment.

Si nous avions fait un where(« title = ‘Luda’ »), le titre n’aurait pas été propagé dans le nouvel objet.

Pour les grands fou

Une chose qui m’a toujours choqué est la manière dont la logique pour ce qui fait qu’un billet est publié est séparée entre les scopes de la classe Post et celles de la classe User.
Afin de se rafraichir la mémoire

class Post < ActiveRecord::Base
    scope :published, lambda {
        where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now)
    }
end

Ainsi que

class User < ActiveRecord::Base

    scope :published, lambda {
        joins(:posts).
        where("posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now).
        group("users.id")
    }
end

Tout bon développeur va instantanément noter la duplication du where(« posts.published_at IS NOT NULL AND posts.published_at <= ?", Time.zone.now).

Heureusemnt il existe une méthode pour éviter cette répétition : un merge dont l’alias est ‘&’.
Voyons comment nous pouvons utiliser scope#& afin de faire référence à Post.published dans la scope User.published.

class User < ActiveRecord::Base

    scope :published, lambda {
        joins(:posts).group("users.id") & Post.published
    }
end

Et voici la requête SQL qui sera générée :

User.published.to_sql
  #=> SELECT users.* FROM "users"
  #   INNER JOIN "posts" ON "posts"."author_id" = "users"."id"
  #   WHERE (posts.published_at IS NOT NULL AND posts.published_at <= '2010-02-27 02:55:45.063181')
  #   GROUP BY users.id

Notez comment les conditions définies dans Post.published sont mergées dans les relations join et group de la scope User.published ?
Et ceci fonctionne avec toutes les relations mergables.

Conclusion

Ce billet dérive un petit peu dans une explication de la nouvelle interface de requêtage SQL avec ActiveRecord dans Rails 3 afin d’entrer dans les détails des scopes.
Cependant aucune des nouvelles fonctionnalités des scopes n’aurait pu être mise en place sans ces nouveautés d’ActiveRecord.
Donc si vous êtes encore un petit confus à propos de cela, n’hésitez pas à vous documenter encore un petit peu plus à propos de la nouvelle API ActiveRecord avant de vous penchez sur les scopes.
Une fois que vous aurez ces bases, vous ne pourrez plus vous passer des scopes !

Vous le savez probablement déjà, il est possible, avec Active Record, de faire vos conditions proprement de deux manières différentes :
Model.find(:all, :conditions => ['champ = ?', valeur])
Model.find(:all, :conditions => {:champ => valeur})

Le second est plus propre. Cependant à première vue, il n’est pas possible d’y faire des conditions autres que le égal (=).
Du coup dès que votre condition devient un chouilla complexe, il deviendrait nécessaire d’utiliser les crochets. C’est dommage.

Heureusement, il des conditions autres que le = sont gerées avec les hashes.
Petit exemple de requête
Model.find(:all, :conditions=>{
:title => "Title",          # title='Title'
:published_at => (Date.now-30 .. Date.now),  # published_at BETWEEN xxx AND xxx
:rating => [ 4, 5, 6 ],          # rating IN ( 4, 5, 6 )
:content => /(a|b|c)/          # REGEXP '(a|b|c)'
)

Cela permet déjà de faire quelques requêtes supplémentaires. Cependant dès que l’on va vouloir faire des choses comme des LIKE, des !=, des >= ou des <=, nous allons, de toute façon, devoir passer par des tableaux.

Après avoir vu comment utiliser Active Record sans Rails, voyons maintenant comment créer des migrations de nos bases de données sans rails également.
Tout d’abord, si vous ne savez pas ce qu’est une migration de base de données, je vous invite à vous documenter. Ici par exemple.

Maintenant allons y pour nos migrations en dehors de notre projet rails !
Pour exécuter celles-ci, nous allons utiliser Rake. Si vous en maitrisez les bases, c’est un peu mieux :)

Nous allons tout d’abord créer un dossier db/migrate dans lequel nous plaçerons tous nos fichiers de migrations.
Créons la première, dans 001_create_users.rb

class CreateUsers < ActiveRecord::Migration
    def self.up
        create_table :users do |t|
            t.column :name, :string, :null => false
            t.column :password, :string, :null => false
        end
    end
    def self.down
        drop_table :users
    end
end

Ici, nous versionnons notre document en 001. Rien ne vous empêche cependant d’avoir vos propres règles de versionning ici afin de gérer la chose de manière plus efficace.

Mettons maintenant en place le rakefile qui exécutera nos migrations.

require 'active_record'
require 'yaml'

task :default => :migrate

desc "Migrate the database through scripts in db/migrate. Target specific version with VERSION=x"
task :migrate => :environment do
    ActiveRecord::Migrator.migrate('db/migrate', ENV["VERSION"] ? ENV["VERSION"].to_i : nil )
end

task :environment do
    ActiveRecord::Base.establish_connection(YAML::load(File.open('database.yml')))
    ActiveRecord::Base.logger = Logger.new(File.open('database.log', 'a'))
end

La tâche « migrate » effectue notre migration.
La tâche « environment » définit la connexion à la base de données et la manière de logger la chose. Vous pouvez bien évidemment le faire évoluer afin de gérer les migrations dans vos environnements de développement et de production.

Enfin il ne reste plus qu’à exécuter la migration en exécutant, en console

rake

La création est correctement loggée et la base créée :)
Si par la suite, vous créez un document 002_create_premissions.rb avec la définition d’une table permissions alors celle-ci sera créée de la même manière.
Et comme migrate gère le versionning, si vous exécutez une seconde fois vos migrations, les premières ne seront pas réexécutées.

Si vous désirez cependant les réexécuter tout de même, il vous suffit de vider la base schema_migrations.
Et de supprimer les bases déjà créées par vos migrations évidemment. Sans quoi vous aurez une belle erreur de table déjà existante.


If you’ve already been using Active Record. And as you’re reading this blog and this article, I suppose you have, even though you may not know you have, you know how it is powerful.
In case your memory fails you, active record is the list of methods used to access databases in Ruby on Rails.
It is compatible with MySQL, SQLite and PostgreSQL (and if you wish to add your own adaptor, you can quite easily).
But Rails is a framework for web applications. And God knows the web isn’t the only face of computer programming.
So we’ll see how easy it is to use Active Record without Rails (or without all rails as activerecord is a part of it).
First of all, you need to install the library. If you already Rails installed on your server, you’re ready to go. Otherwise, do the following :

gem install activerecord

It’ll get the latest stable version of the library and install it on your computer.

Now, let’s start having fun with some code lines :)

Create a new document named (for example) main.rb.

First, you need to call the rubygems and activerecord libraries.

require 'rubygems'
require 'active_record'

With those lines at the top of your document, you now have access to all the methods provided by Active Record.

So let’s use them.

ActiveRecord::Base.establish_connection(
    :adapter => 'mysql',
    :host => 'localhost',
    :user => 'root',
    :password => 'root',
    :database => 'test'
)

This will instantiate the library.
<em>However, it'll not connect you to the database. You're getting connected only when you do the first query</em>

We now need to create a model.
I invite you not to create it in the same document. So create a document named "user.rb" in the same directory as your main.rb.
Put in that new document :
[code lang="ruby"]class User > ActiveRecord::Base
end

And in your main.rb, call it.

require 'user'

You now have access, in your main.rb, to the object User, which is a child of ActiveRecord::Base and grants it all the usual methods available to Ruby on Rails models. However, you're not in a rails application.
So let's get all our users.

p User.find(:all)

Based on the fact that the table "users" exists in your database "test", this will print in your console the hash of all your users.

You can now do anything you want using ActiveRecord in your Ruby console or offline user interfaces applications.

But wait it's not over. Until now, we've defined all our configuration datas directly in the program. And that's very bad. So we need to add an external database.yml document.

adapter: mysql
host: localhost
username: root
password: root
database: refcrawler_dev

And to load that external document in ours to get it's values.

require 'yaml'
dbconfig = YAML::load(File.open('config/database.yml'))
ActiveRecord::Base.establish_connection(dbconfig)

Si vous avez déjà utilisé Active Record. Et vu que vous lisez ce blog et cet article, je suppose que c’est le cas, même si vous n’en savez peut-être rien.

Dans le cas ou votre mémoire vous jouerai des tours, voici un petit rappel. Active Record est une librairie utilisée dans Ruby on Rails et permettant aux applications de manipuler de manière aisée les bases de données.

C’est compatible avec MySQL, SQLite et PostgreSQL (il est cependant tout à fait possible de développer son propre adaptateur).

Mais Rails est un framework pour applications web. Et le web ne se résume pas au développement web.

Du coup nous allons voir comment implémenter et utiliser Active Record dans votre application console.

Tout d’abord, vous devez installer la librairie. Si vous avez déjà installé Rails sur votre machine, vous êtes prêt à démarrer. Dans le cas contraire, tapez la commande suivante en console :

gem install activerecord

Cela téléchargera la dernière version stable de la librairie et l’installera sur la machine.

Maintenant commençons à nous amuser un peu en développant :)

Créez un nouveau document que vous appellerez (par exemple) main.rb

Tout d’abord, vous devez appeller les librairies rubygems et activerecord.

require 'rubygems'
require 'active_record'

Avec ces lignes en haut de votre document, vous avez maintenant accès à toutes les méthodes fournies par Active Record.

Du coup, autant les utiliser.

ActiveRecord::Base.establish_connection(
    :adapter => 'mysql',
    :host => 'localhost',
    :user => 'root',
    :password => 'root',
    :database => 'test'
)

Ceci initialisera la librairie.

Cependant, cela ne vous connectera pas à la base de données. Vous ne le serez que lorsque vous ferez votre première requête.

Maintenant, créons un modèle.

Je vous invite à ne pas le créer dans le document, pour des raisons de propreté de l’application. Créons donc un document nommé « user.rb » dans le même répertoire que votre main.rb.

Placez le code suivant dans votre nouveau document :

class User < ActiveRecord::Base
end

Et dans votre main.rb, appellez le :

require 'user'

Vous avez maintenant accès, dans votre main.rb, à l’objet User, qui hérite de ActiveRecord::Base et vous fournit donc toutes les méthodes habituellement utilisées dans les modèles Ruby on Rails. Vous n’êtes cependant pas dans une application Rails.

Du coup récupérons tous nos utilisateurs.

p User.find(:all)

Si votre table « users » existe dans la base de données « test », ceci affichera dans votre console un bump du hash de tous vos utilisateurs.

Vous pouvez donc maintenant faire tout ce que vous faites habituellement dans vos modèles Ruby on Rails. En passant par les associations entre modèles, en passant par les validations. Et même les migrations (ceci fera cependant l’objet d’un second article).

Mais attendez ce n’est pas tout. Jusqu’à maintenant, nous avons défini nos paramètres de configuration directement dans l’application. Et c’est très très mal. Du coup nous allons ajouter un document database.yml externe :

adapter: mysql
host: localhost
username: root
password: root
database: refcrawler_dev

Et pour charger ces paramètres de connexion dans notre document :

require 'yaml'
dbconfig = YAML::load(File.open('config/database.yml'))
ActiveRecord::Base.establish_connection(dbconfig)
 
Fork me on GitHub