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.
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.
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
.
Voici la structure SQL correspondante avec la syntaxe SQLite :
-- 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 :
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
.
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.
# @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.
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 :
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 :
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.
# …
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 :
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 :
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.