De l'utilisation de Scenic au sein d'un Rails Engine

Publié le 5 octobre 2017 par François Vantomme | rails

Cet article est publié sous licence CC BY-NC-SA

Nous avons vu dans le précédent article l’intérêt d’utiliser Scenic, une gem créée par Thoughtbot — encore eux — qui se présente sous la forme d’une boîte à outils venant compléter les fonctionnalités d’ActiveRecord, notamment en ce qui concerne la migration de vues SQL.

Ce que je vous propose ici, c’est de pousser un peu plus loin l’expérimentation en intégrant Scenic à un Rails Engine, puis en mettant en place les outils qui nous permettront de jouer les migrations de nos vues SQL en toute transparence.

Qu’est-ce qu’un Rails Engine ? À quoi ça sert ?

Un engine est une mini-application Rails destinée à être intégrée au sein d’une application hôte. Un engine a bien souvent un domaine d’application restreint et est généralement pensé pour être réutilisable.

On peut voir l’engine comme un plugin pour notre application hôte. D’ailleurs, pour créer un engine nous utiliserons la commande rails plugin new.

Je ne m’attarderai pas ici sur ce sujet, celui-ci étant parfaitement traité par le guide Rails Getting Started with Engines.

Jetons tout de même un œil au fichier my-engine.gemspec qui va nous permettre de définir les dépendances de notre engine à installer lorsque celui-ci sera requis par son application hôte.

# ./my-engine.gemspec
require "my/engine/version"

Gem::Specification.new do |spec|
  spec.name          = "my-engine"
  spec.version       = My::Engine::VERSION
  spec.authors       = ["Synbioz"]
  spec.summary       = %q(Write a short summary. Required.)
  spec.description   = %q(a longer description. Optional.)

  # …

  spec.add_dependency "scenic", "1.4.0"
end

Nous observons donc que notre engine dépend de Scenic. Cette dépendance sera installée lorsque, depuis l’application hôte, nous lancerons la commande bundle install.

Jouer les migrations

Comment, depuis l’hôte, joue-t-on des migrations décrites au sein de l’engine ? En important les fichiers de migration de l’engine, puis en jouant les migrations de la manière habituelle.

Pour importer les fichiers de migration, Rails met à notre disposition une commande :

❯ bin/rails my_engine:install:migrations

Une fois les migrations de l’engine copiées dans le dossier db/migrate de l’hôte, nous n’avons plus qu’à jouer nos migrations comme à l’accoutumée :

❯ bin/rails db:migrate

Le cas particulier de Scenic

Seulement voilà : comme nous l’avons vu dans l’article précédent, Scenic attend de nous que nous stockions nos vues SQL dans un dossier db/views. La commande install:migrations de Rails n’est pas du tout prévue pour gérer cela, et il nous faudra songer à importer à la main les vues de notre engine avant de procéder à quelque migration.

Outillons-nous !

S’assurer, à chaque fois que l’on désire jouer les migrations, de bien récupérer les vues nécessaires à Scenic est chose fastidieuse et immanquablement il vous arrivera d’oublier cette étape préalable.

Pour parer à cela, mettons en place une tâche Rake qui se chargera de ce fardeau et sera exécutée systématiquement lors de la copie des migrations de notre engine au sein de notre application hôte.

Pour ce faire, créons une tâche install.rake au sein de notre engine. Son contenu en substance ressemblera à ceci :

# ./lib/my/engine/rails/lib/tasks/install.rake
namespace :my_engine do
  namespace :install do
    desc "Copy views from my_engine to application"
    task views: :environment do
      # copy…
    end

    task migrations: :views
  end
end

Ainsi, il nous suffira d’exécuter rake my_engine:install:migrations pour que soit automatiquement appelé my_engine:install:views.

Le contenu de notre tâche consistera en un appel à la méthode copy de la classe ScenicTask::View, comme ceci :

ScenicTask::View.copy(ScenicTask::View.views_path, railties, on_copy: on_copy)

À cette méthode, nous passons trois paramètres : le chemin de destination, les engines sources concernés, ainsi qu’une méthode à appeler lors de la copie effective d’une vue Scenic.

Le chemin de destination se retrouve ainsi :

module ScenicTask
  class View
    def self.views_path
      @views_path ||= Rails.application.paths["db/views"].to_a.first
    end
  end
end

Les engines concernés — c’est-à-dire ceux ayant déclaré un répertoire db/views — sont récupérés de cette manière :

railties = Rails.application.migration_railties.
  each_with_object({}) do |railtie, memo|
    if railtie.respond_to?(:paths) && (path = railtie.paths["db/views"].&first)
      memo[railtie.railtie_name] = path
    end
  end

Enfin, le callback à appeler lors de la copie d’une vue ressemblera à cela :

on_copy = Proc.new do |name, view|
  puts "Copied view #{view.basename} from #{name}"
end

La méthode copy pour sa part effectue les opérations suivantes : elle crée le répertoire de destination s’il n’existe pas, puis pour chaque engine source, copie chaque vue Scenic à l’emplacement cible, à moins bien sûr que celle-ci n’y soit déjà présente.

En voici son implémentation :

module ScenicTask
  class View
    def self.copy(destination, sources, options = {})
      FileUtils.mkdir_p(destination) unless File.exist?(destination)

      destination_views = views(destination)

      sources.each_with_object([]) do |(scope, path), copied|
        source_views = views(path)

        source_views.each do |view|
          source = File.binread(view.filename)

          next if destination_views.find { |v| v.basename == view.basename }

          new_path = File.join(destination, view.basename)
          old_path, view.filename = view.filename, new_path

          File.binwrite(view.filename, source)
          copied << view
          options[:on_copy]&.call(scope, view, old_path)
          destination_views << view
        end
      end
    end
  end
end

Le seul point restant obscure à ce stade est l’appel à la méthode views. Cette dernière collecte, pour un chemin donné, l’ensemble des fichiers SQL correspondant à la nomenclature de Scenic, soit tous les fichiers dont le nom répond au format suivant : *_v[0-9]*.sql.

On prendra soin d’extraire le numéro de version de notre vue Scenic et de stocker tout ça dans un objet de notre cru ; un simple Struct suffira :

module ScenicTask
  class View
    VIEW_FILENAME_REGEXP = /\A([_a-z0-9]*)_v([0-9]+)\.sql\z/

    def self.parse_view_filename(filename)
      File.basename(filename).scan(VIEW_FILENAME_REGEXP).first
    end

    def self.views(paths)
      files = Dir[*Array(paths).map { |p| "#{p}/**/*_v[0-9]*.sql" }]

      views = files.map do |file|
        name, version = parse_view_filename(file)
        raise IllegalViewNameError.new(file) unless version
        version = version.to_i
        name = name.camelize

        ViewProxy.new(name, version, file)
      end

      views.sort_by(&:filename)
    end
  end

  class ViewProxy < Struct.new(:name, :version, :filename)
    def basename
      File.basename(filename)
    end
  end
end

Je n’entrerai pas dans le détail de la classe d’exception IllegalViewNameError qui n’a que peu d’intérêt ici.

Et voici notre tâche Rake dans son intégralité :

# ./lib/my/engine/rails/lib/tasks/install.rake
namespace :my_engine do
  namespace :install do
    desc "Copy views from my_engine to application"
    task views: :environment do
      railties = Rails.application.migration_railties.
        each_with_object({}) do |railtie, memo|
          if railtie.respond_to?(:paths) && (path = railtie.paths["db/views"].&first)
            memo[railtie.railtie_name] = path
          end
        end

      on_copy = Proc.new do |name, view|
        puts "Copied view #{view.basename} from #{name}"
      end

      ScenicTask::View.copy(ScenicTask::View.views_path, railties, on_copy: on_copy)
    end

    task migrations: :views
  end
end

module ScenicTask
  class View
    VIEW_FILENAME_REGEXP = /\A([_a-z0-9]*)_v([0-9]+)\.sql\z/

    def self.views_path
      @views_path ||= Rails.application.paths["db/views"].to_a.first
    end

    def self.parse_view_filename(filename)
      File.basename(filename).scan(VIEW_FILENAME_REGEXP).first
    end

    def self.views(paths)
      files = Dir[*Array(paths).map { |p| "#{p}/**/*_v[0-9]*.sql" }]

      views = files.map do |file|
        name, version = parse_view_filename(file)
        raise IllegalViewNameError.new(file) unless version
        version = version.to_i
        name = name.camelize

        ViewProxy.new(name, version, file)
      end

      views.sort_by(&:filename)
    end

    def self.copy(destination, sources, options = {})
      FileUtils.mkdir_p(destination) unless File.exist?(destination)

      destination_views = views(destination)

      sources.each_with_object([]) do |(scope, path), copied|
        source_views = views(path)

        source_views.each do |view|
          source = File.binread(view.filename)

          next if destination_views.find { |v| v.basename == view.basename }

          new_path = File.join(destination, view.basename)
          old_path, view.filename = view.filename, new_path

          File.binwrite(view.filename, source)
          copied << view
          options[:on_copy]&.call(scope, view, old_path)
          destination_views << view
        end
      end
    end
  end

  class ViewProxy < Struct.new(:name, :version, :filename)
    def basename
      File.basename(filename)
    end
  end

  class ViewError < StandardError
    def initialize(message = nil)
      message = "\n\n#{message}\n\n" if message
      super
    end
  end

  class IllegalViewNameError < ViewError
    def initialize(name = nil)
      if name
        super("Illegal name for view file: #{name}\n\t(only lower case letters, numbers, and '_' allowed).")
      else
        super("Illegal name for view.")
      end
    end
  end
end

Conclusion

Voyez comme il est aisé de compléter notre arsenal d’outils en s’inspirant des existants. Dès qu’une tâche manuelle devient répétitive, elle est généralement source d’erreur et c’est là le signe qu’il est temps d’automatiser cette procédure. Au final vous gagnerez en temps et en sérénité !


L’équipe Synbioz.
Libres d’être ensemble.