Le blog d'Archiloque

Écrire un ORM en Ruby partie 2 : structure

Ceci est le deuxième article d’une série de cinq décrivant pas à pas comment écrire un ORM SQL minimal en Ruby.

Après avoir introduit le sujet, je vais ici poser les bases de l’outil.

Le code (framework et code d’exemple) sera dans un projet unique et ne sera pas packagé sous forme d’une gem.

Le faire ajouterait pas mal de code et un peu de complexité sans que cela apporte quelque chose.

Les dépendances

Tout d’abord il faut fixer la version de Ruby, je vais prendre la dernière disponible au moment d’écrire l’article.

ruby-version
2.7.1

Ensuite pour les bibliothèques, en plus de la gem permettant d’utiliser SQLite je vais me servir de Rake pour définir la tâche de génération de code.

Gemfile
source "https://rubygems.org"

gem "rake", "~> 12.0"
gem "sqlite3", "~> 1.4"

Comme exemple : un jeu de construction

Dans la suite, je vais prendre comme exemple un jeu de construction.

Ce jeu de construction est composé des différents types de briques (Brick), qui sont chacun d’une certaine couleur (Color). Des modèles à construire (Kit) sont constitués d’un ensemble de types de briques, chacun contient un certain nombre de briques (tant de briques d’une sorte, tant de briques d’une autre sorte), la relation modèle - type de brique étant modélisée par un KitBrick.

schema

Voici la structure SQL  correspondante avec la syntaxe SQLite :

structure.sql
-- Table color
CREATE TABLE 'color' (
  'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,

  'name' TEXT NOT NULL
);

CREATE UNIQUE INDEX idx_color_unique
  ON color('name');

-- Table brick
CREATE TABLE 'brick' (
  'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,

  'name' TEXT NOT NULL,
  'description' TEXT NOT NULL,
  'color_id' INTEGER NOT NULL,

  FOREIGN KEY('color_id') REFERENCES 'color'('id')
);

-- Table kit
CREATE TABLE 'kit' (
  'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,

  'name' TEXT NOT NULL,
  'description' TEXT NOT NULL
);

-- Table kit_brick
CREATE TABLE 'kit_brick' (
  'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,

  'kit_id' INTEGER NOT NULL,
  'brick_id' INTEGER NOT NULL,
  'quantity' INTEGER NOT NULL,

  FOREIGN KEY('kit_id') REFERENCES 'kit'('id'),
  FOREIGN KEY('brick_id') REFERENCES 'brick'('id')
);
CREATE UNIQUE INDEX 'idx_kit_brick_uniqu'
  ON 'kit_brick'('kit_id', 'brick_id');

Pour créer une base avec la bonne structure, vous pouvez lancer la commande :

$ sqlite3 orm-ruby.sqlite < structure.sql

Et ensuite explorer la base ainsi :

$ sqlite3 orm-ruby.sqlite
SQLite version 3.24.0 2018-06-04 14:10:15
sqlite> select count(*) from color;
0
sqlite>

Certains ORMs comme Rails et Sequel fournissent des outils pour gérer les modifications de schéma de la base. J’ai fait le choix ici de ne pas implémenter cette fonctionnalité car — même si elle peut partager du code avec le reste de l’ORM  — elle est largement séparée et n’influe donc pas sur le noyau de l’outil.

Dans le monde Java, l’ORM hibernate qui est très utilisé ne fournit ainsi pas d’outil de migration.

Un DSL pour configurer les modèles

Pour générer les classes de modèle, je vais utiliser un fichier de configuration qui sera lu par un script. Au début le fichier de configuration contiendra seulement les noms des modèles et les noms des tables correspondantes.

La syntaxe s’inspire des DSL de configuration qu’on trouve dans Rails :

schema.rb
define_model 'Color' do |model_definition|
  model_definition.table 'color'
end

define_model 'Brick' do |model_definition|
  model_definition.table 'brick'
end

define_model 'Kit' do |model_definition|
  model_definition.table 'kit'
end

define_model 'KitBrick' do |model_definition|
  model_definition.table 'kit_brick'
end

La capacité d’utiliser des noms de table par défaut en les déduisant des noms des classes demanderait un peu plus de code sans changer le fonctionnement d’ensemble, du coup je ne vais pas l’intégrer.

Pour générer les modèles je dois commencer par lire le contenu de fichier.

Pour cela je commencer par créer la classe ModelDefinition qui contiendra les contenus des modèles tels que définis dans le fichier, en étant passé dans chacun des blocs define_model.

generator.rb
class ModelDefinition

  attr_reader :name, :table_name

  # @param name [String]
  def initialize(name)
    @name = name
  end

  # @param [String]
  # @return [void]
  def table(table_name)
    @table_name = table_name
  end
end

Comme le script de génération generator.rb des modèles sera lancé de manière indépendante du reste du code, je peux définir la méthode define_model de manière globale (dans un script indépendant elle ne risque pas de polluer l’espace de noms), puis de faire un require_relative sur le fichier de configuration.

Lorsque le fichier sera chargé, la méthode define_model sera ainsi appelée pour chaque bloc du fichier schema.rb.

Chaque appel va instancier un ModelDefinition avec le nom du modèle, puis le passe en paramètre du bloc.

generator.rb
# @yield [model_definition]
# @yieldparam [ModelDefinition] model_definition
# @yieldreturn [void]
def define_model(model_name, &block)
  puts "Defining model [#{model_name}]"
  model_definition =
    ModelDefinition.new(model_name)
  block.yield(model_definition)
end

require_relative 'schema'

Pour pouvoir utiliser ensuite ces ModelDefinition, le constructeurs les stockera dans un tableau au fur et à mesure.

generator.rb
class ModelDefinition

  MODELS_DEFINITIONS = []

  attr_reader :name, :table_name

  # @param name [String]
  def initialize(name)
    @name = name
    MODELS_DEFINITIONS << self
  end

  # …

Après le chargement du fichier de configuration, ModelDefinition::MODELS_DEFINITIONS contiendra la ainsi liste des définitions.

Un template pour générer le fichier

Une fois la configuration chargée je vais m’intéresser à la génération du code.

Comme à l’étape précédente, la première étape est de définir la syntaxe cible qui m’intéresse :

models.rb
class Color

  # @return [String]
  def self.table_name
      'color'
  end
end

Chaque modèle est dans une classe, exposant une méthode de classe pour récupérer le nom de la table.

Comme expliqué plus haut, je me sers d’erb pour la génération, voici donc le template de classe correspondant :

models.rb.erb
class <%= model.name %>

  # @return [String]
  def self.table_name
      '<%= model.table_name %>'
  end
end

Pour générer le fichier, il faut alors charger ce template, l’appliquer à chacun des définitions qui sont disponibles dans ModelDefinition::MODELS_DEFINITIONS et stocker le résultat dans un fichier.

generator.rb
# …

require 'erb'

# Récupère le template
erb = ERB.new(IO.read('models.rb.erb'))

# Applique le template aux modèles
models_code = ModelDefinition::MODELS_DEFINITIONS.
    map do |model|
  # Fait en sorte que le ModelDefinition soit disponible dans le template
  # via la variable `model`
  erb.result_with_hash(model: model)
end

# Concatène le code des modèles et l'écrit dans un fichier
IO.write(
    'models.rb',
    models_code.
        join("\n\n")
)

Le code est alors terminé, il me manque seulement une tâche Rake pour pouvoir l’invoquer. Comme les chemins des fichiers sont tous en dur dans le code, il n’y a pas besoin de le rendre paramétrable :

Rakefile
desc 'Génère les modèles à partir du fichier schema.rb'
task :generate_models do
  require_relative 'generator'
end

On peut alors lancer la génération :

$ rake generate_models
Defining model [Color]
Defining model [Brick]
Defining model [Kit]
Defining model [KitBrick]

Et observer le résultat :

models.rb
class Color

  # @return [String]
  def self.table_name
      'color'
  end
end

class Brick

  # @return [String]
  def self.table_name
      'brick'
  end
end

class Kit

  # @return [String]
  def self.table_name
      'kit'
  end
end

class KitBrick

  # @return [String]
  def self.table_name
      'kit_brick'
  end
end

Pour le moment, tout ce que je peux faire c’est d’instancier les différentes classes :

require_relative 'models'
black = Color.new

Mais la structure est en place et dans l’article suivant je vais pouvoir m’en servir pour faire mes premières requêtes.