De l'utilisation de Scenic au sein d'un Rails Engine
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 = ["Strigo Code"]
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é !