Ceci est le troisè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 puis avoir posé les bases de l’outil, je vais ici ajouter ce qu’il manque pour faire les premières requêtes.
Des modèles avec des champs
La première étape va s’intéresser aux champs des modèles.
Il serait possible de les paramétrer via le fichier de configuration de schéma schema.rb
, mais je vais plutôt utiliser les métadonnées de la base de données, car c’est une bonne occasion de donner un aperçu de leur fonctionnement.
En effet, même si tous les ORMs n’utilisent pas les métadonnées, beaucoup le font il est donc important de comprendre comment cela fonctionne et de réaliser que ça n’est pas si compliqué.
Même si cela ne fait pas partie du standard SQL, la majorité des bases de données de ce type fournissent des moyens d’accéder aux métadonnées comme les tables, les index…
Suivant les systèmes on pourra utiliser du SQL — en interrogeant des tables particulières — ou des commandes spécifiques.
Avec SQLite, l’accès aux métadonnées se fait via des commandes PRAGMA.
$ sqlite3 orm-ruby.sqlite
sqlite> .header on -- Affiche les noms de colonnes
sqlite> pragma table_info('color'); -- Infos sur la table `color`
cid|name|type|notnull|dflt_value|pk
0|id|INTEGER|1||1
1|name|TEXT|1||0
Pour chaque colonne table_info
nous donne son identifiant, son nom, son type, si elle est nullable, son éventuelle valeur par défaut et si il s’agit de la clé primaire de la table.
Avec la gem sqlite3, la méthode SQLite3::Database#table_info
fait l’appel à la pragma et renvoie le résultat sous une forme accessible.
Pour ajouter les colonnes disponibles dans chaque table, commençons par définir une classe ColumnDefinition
qui contiendra les informations qui nous intéressent, à savoir le nom et le type de la colonne.
class ColumnDefinition
attr_reader :name, :type
# @param name [String]
# @param type [String]
def initialize(name, type)
@name = name
@type = type
end
end
Ensuite, je dois récupérer les colonnes de chaque table. L’endroit le plus simple est de le faire juste avant de créer les classes de modèles, alors qu’on est déjà en train d’itérer sur chaque modèle, on pourra alors passer la liste des colonnes au template.
Mais pour commencer, il faut se connecter à la base de données. Je vais utiliser une base en local avec un fichier stocké dans le répertoire du projet :
DATABASE = SQLite3::Database.new('orm-ruby.sqlite')
puis :
models_code = ModelDefinition::MODELS_DEFINITIONS.map do |model|
# Liste les colonnes de la table correspondante
columns_definitions = DATABASE.table_info(model.table_name).collect do |column_info|
column_name = column_info['name']
column_type = column_info['type']
ColumnDefinition.new(column_name, column_type)
end
erb.result_with_hash(model: model, columns_definitions: columns_definitions)
end
Dans le template, columns_definitions
contient alors la liste des ColumnDefinition
prête à l’emploi.
Presque prêt à l’emploi car il reste une subtilité : table_info
renvoie les types SQL des colonnes (ou plus précisément le type SQLite) comme TEXT
, pour pouvoir l’utiliser dans le code je dois le transformer en types Ruby comme String
.
Pour se faire, nous allons utiliser une Hash
pour faire la conversion entre les deux.
SQLITE_TYPE_TO_RUBY_CLASS = {
'INTEGER' => 'Integer',
'TEXT' => 'String'
}
Elle ne contient que les types que l’on va rencontrer dans notre exemple, il sera ensuite possible de l’enrichir en fonction des besoins.
En modifiant le code pour utiliser SQLITE_TYPE_TO_RUBY_CLASS
, cela donne :
models_code = ModelDefinition::MODELS_DEFINITIONS.map do |model|
# Liste les colonnes de la table correspondante
columns_definitions = DATABASE.table_info(model.table_name).collect do |column_info|
column_name = column_info['name']
sql_type = column_info['type']
# Transforme le type SQL en type Ruby
ruby_type = SQLITE_TYPE_TO_RUBY_CLASS[sql_type]
ColumnDefinition.new(column_name, ruby_type)
end
erb.result_with_hash(model: model, columns_definitions: columns_definitions)
end
Avec le nom et le type de chaque colonne, je vais pouvoir générer les getters et les setters en itérant sur les ColumnDefinition
dans le template :
class <%= model.name %>
<% columns_definitions.each do |column_definition| %>
<% column_name = column_definition.name %>
<% column_type = column_definition.type %>
# @return [<%= column_type %>]
def <%= column_name %>
@<%= column_name %>
end
# @param <%= column_name %> [<%= column_type%>]
# @return [void]
def <%= column_name %>=(<%= column_name %>)
@<%= column_name %> = <%= column_name %>
end
<% end %>
end
Ce qui donne ce résultat :
class Model
# @return [Integer]
def id
@id
end
# @param id [Integer]
# @return [void]
def id=(id)
@id = id
end
# @return [String]
def name
@name
end
# @param name [String]
# @return [void]
def name=(name)
@name = name
end
end
# …
Ce qui permet d’écrire :
require_relative 'models'
black = Color.new
black.name = 'Black'
On peut voir ici l’intérêt de la génération de code à froid : on peut facilement consulter les méthodes disponibles avec leurs informations de type. Avec un IDE on peut même disposer de l’autocomplétion.
En cas d’évolution d’un modèle, l’évolution sera visible dans les classes générées.
Je ne l’utilise pas dans mon exemple, mais l’information de nullabilité des colonnes peut servir pour renseigner la nullabilité des paramètres ou des retours des méthodes.
L’insertion
Une fois qu’on a la liste des champs et qu’il est possible de leur attribuer des valeurs, il est temps de pouvoir insérer ces données dans la base, en ajoutant une méthode insert
aux modèles.
Pour cela il faut générer ce type de requêtes :
INSERT INTO table_name
(column_name_1, column_name_2)
values (column_value_1, column_value_2)
Pour partager le code entre les modèles, je vais ajouter une classe Model
qui sera parente des classes de modèles.
# @abstract
class Model
end
Je la marque comme abstraite avec @abstract
pour indiquer qu’elle n’est pas utilisable directement mais qu’on doit passer par les classes dérivées.
Pour générer les requêtes d’insertion, je vais avoir besoin du nom de la table et de la liste des colonnes de chaque modèle. Pour cela je vais ajouter des méthodes de classes pour récupérer les valeurs.
Je les déclare dans la classe parente :
# @abstract
class Model
# Méthode à implémenter dans les sous-classes
# @abstract
# @return [String]
def self.table_name
raise NotImplementedError
end
# Méthode à implémenter dans les sous-classes
# @abstract
# @return [Array<String>]
def self.columns
raise NotImplementedError
end
end
Puis je les ajoute au template de modèle, avec la déclaration de l’héritage :
class <%= model.name %> < Model
# @return [String]
def self.table_name
'<%= model.table_name %>'
end
# @return [Array<String>]
def self.columns
<%= columns_definitions.map do |column_definition|
column_definition.name
end %>
end
end
Ce qui donne, après avoir relançé la génération avec la commande rake generate_models
:
class Color < Model
# @return [String]
def self.table_name
'color'
end
# @return [Array<String>]
def self.columns
["id", "name"]
end
# …
end
Avec ces méthodes je peux générer la requête, en ajoutant une connexion à la base pour pouvoir l’exécuter.
Pour la requête je vais utiliser la méthode SQLite3::Database#execute
, qui permet de passer les valeurs des colonnes en paramètre plutôt que de les mettre dans le corps de la requête, ce qui donnera ce genre d’appel :
DATABASE.execute('INSERT INTO color (name) values (?)', ['Black'])
Cette syntaxe permet d’éviter d’avoir à se préoccuper du format à utiliser pour passer les valeurs à la base, cela simplifie le code et évite d’introduire des risques de sécurité en cas de problème d’échappement.
Dans notre cas les valeurs des id
des modèles ne doivent pas être insérées car elles sont gérées par la base, c’est pour cela que les colonnes id
sont déclarées en AUTOINCREMENT
.
Cela simplifie le code et fournit une garantie d’unicité dans le cas d’une base SQL standard.
La manière de s’y prendre n’est pas standardisée et dépend donc de la base de données.
Il y a deux grandes approches : soit les valeurs sont retournées par la requête d’insertion, ou une requête spécifique permet de récupérer les id
des valeurs qu’on vient d’insérer.
SQLite utilise la deuxième solution via last_insert_rowid()
.
require 'sqlite3'
# @abstract
class Model
# Connection à la base pour executer les requêtes
DATABASE = SQLite3::Database.new('orm-ruby.sqlite')
# @return [void]
def insert
# Liste des noms de colonnes sans la colonne id
# car les valeurs des ids sont gérées par la base
columns_names_except_id = self.class.columns.
select { |column| column != 'id' }
# Noms des colonnes échappées pour éviter
# les problèmes avec des guillemets et d'autres symboles
quoted_columns_names_except_id = columns_names_except_id.
map { |column_name| SQLite3::Database.quote(column_name) }
# Valeurs des colonnes à part la colonne 'id'
columns_values_except_id = columns_names_except_id.
map { |column_name| self.send(column_name) }
# Les requêtes vont ressembler à
# INSERT INTO table_name
# (column_name_1, column_name_2)
# VALUES (?, ?)
DATABASE.execute(
"INSERT INTO #{SQLite3::Database.quote(self.class.table_name)} " +
"(#{quoted_columns_names_except_id.join(', ')}) " +
"VALUES (#{Array.new(columns_names_except_id.length, '?').join(', ')})",
columns_values_except_id
)
# Définit la valeur du champ `id` du modèle
# en récupérant la valeur attribuée par la base
self.id = DATABASE.last_insert_row_id
end
end
Les méthodes table_name
et columns
étant implémentées dans chaque classe de modèle, utiliser self.class.table_name
et self.class.columns
dans la classe parente Model
appellera bien la méthode spécifique de chaque modèle plutôt que les méthodes de la classe Model
.
Avec ce code, on peut enfin insérer les données :
require_relative 'model'
require_relative 'models'
black = Color.new
black.name = 'Black'
black.insert
brick = Brick.new
brick.color_id = black.id
brick.name = 'Awesome brick'
brick.description = 'This brick is awesome'
brick.insert
On peut vérifier dans la base que tout s’est bien passé :
$bundle exec ruby script.rb
$ sqlite3 orm-ruby.sqlite
sqlite> select * from color;
1|Black
sqlite> select * from brick;
1|Awesome brick|This brick is awesome|1
La récupération
Maintenant que je peux insérer des données, je vais pouvoir m’intéresser à leur récupération.
Je commence par m’occuper de la récupération de l’intégralité des données d’une table en ajoutant une méthode de classe all
aux modèles.
Cela permettra des appels du type :
Color.all
En SQL cela donne ce type de requêtes :
SELECT column_name_1, column_name_2
FROM table_name
Les noms de la table et des colonnes sont à disposition pour construire la requête.
Une fois les valeurs récupérées, pour chaque ligne trouvée il faut créer une instance de la classe du modèle et attribuer leurs valeurs aux différents champs.
Les noms des attributs étant les mêmes que ceux des colonnes, pour chaque colonne nom_de_colonne
, j’appellerait le setter nom_de_colonne=
via la méthode send
qui permet d’appeler une méthode dynamiquement à partir de son nom.
À l’inverse du cas précédent, il nous faudra également récupérer la valeur de la colonne id
.
class Model
# @return [Array]
def self.all
quoted_columns_names = columns.
map { |column_name| SQLite3::Database.quote(column_name) }
# Les requêtes vont ressembler à
# SELECT column_name_1, column_name_2
# FROM table_name
DATABASE.execute(
"SELECT #{quoted_columns_names.join(', ')} " +
"FROM #{SQLite3::Database.quote(table_name)}"
).map do |result_row|
# Instancie l'objet de la classe du modèle
model_instance = self.new
# Pour chaque colonne
columns.each_with_index do |column, column_index|
# On récupère la valeur
column_value = result_row[column_index]
# On stocke la valeur dans l'attribue correspondant
model_instance.send("#{column}=", colonne_value)
end
model_instance
end
end
end
Je peux alors récupérer des données :
require_relative 'model'
require_relative 'models'
black = Color.new
black.name = 'Black'
black.insert
puts 'Les couleurs'
Color.all.each do |color|
puts " #{color.id} : #{color.name}"
end
brick = Brick.new
brick.color_id = black.id
brick.name = 'Awesome brick'
brick.description = 'This brick is awesome'
brick.insert
puts 'Les briques'
Brick.all.each do |brick|
puts " #{brick.id} : #{brick.name}, #{brick.description}, #{brick.color_id}"
puts brick.id
puts brick.name
puts brick.description
puts brick.color_id
end
$ bundle exec ruby script.rb
Les couleurs
1 : Black
Les briques
1 : Awesome brick, This brick is awesome, 1
Et la suppression
Pour terminer, après l’insertion et la récupération il est temps de supprimer des données.
Dans le standard SQL, il existe une commande TRUNCATE table_name
qui supprime le contenu d’une table.
Malheureusement elle n’est pas disponible dans SQLite, je vais donc devoir utiliser la requête SQL :
DELETE FROM table_name
Je vais tout de même nommer ma méthode truncate
pour qu’elle corresponde à la commande SQL standard, même si l’implémentation SQLite utilise pas cette commande.
On a ici un exemple où l’ORM doit assurer la compatibilité entre les systèmes de bases de données.
Si ce cas est assez simple, il permet de comprendre la manière dont les choses pourraient être mises en œuvre : une méthode de base qui utiliserait la commande truncate
et une classe spécifique à SQLite qui utiliserait la requête delete
.
Le code résultant est assez court et s’inspire des méthodes existantes :
class Model
# @return [void]
def self.truncate
DATABASE.execute("DELETE FROM #{SQLite3::Database.quote(table_name)}")
end
end
On peut alors la tester
require_relative 'model'
require_relative 'models'
Brick.truncate
Color.truncate
puts '# Les couleurs'
Color.all.each do |color|
puts " #{color.id} : #{color.name}"
end
puts 'Les briques'
Brick.all.each do |brick|
puts " #{brick.id} : #{brick.name}, #{brick.description}, #{brick.color_id}"
puts brick.id
puts brick.name
puts brick.description
puts brick.color_id
end
$ bundle exec ruby script.rb
Les couleurs
Les briques
Je ne vais pas les détailler ici mais pour les suppressions de données il faut générer des requêtes DELETE FROM table_name WHERE ID = ?
et leurs passer l’id de l’instance à supprimer et pour les mises à jour s’inspirer des requêtes d’insertion pour obtenir des requêtes du type UPDATE table_name SET column_name_1 = ?, column_name_2 = ? WHERE id = ?
.
Toutes les requêtes vues ici s’appuient sur l’hypothèse d’un identifiant technique présent dans toutes les tables, ce qui est la pratique généralement conseillée en SQL. Prendre en compte les autres types d’identifiants demande de rendre paramétrable cette partie des requêtes.
C’est tout pour le moment, dans l’article suivant je vais enrichir les méthodes de récupération pour pouvoir ajouter des filtres et trier les données.