Le blog d'Archiloque

Écrire un ORM en Ruby partie 3 : récupération des champs, insertion et sélection

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 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 -- Active l'affichage des noms de colonnes dans les requêtes
sqlite> pragma table_info('color'); -- Donne des informations 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.

generator.rb
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 :

generator.rb
DATABASE = SQLite3::Database.new('orm-ruby.sqlite')

puis :

generator.rb
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.

generator.rb
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 :

generator.rb
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 :

models.erb.rb
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 :

models.rb
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.

model.rb
# @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 :

model.rb
# @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 :

models.erb.rb
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 :

models.rb
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 de l'`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().

model.rb
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 :

script.rb
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.

model.rb
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 :

script.rb
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 :

model.rb
class Model
  # @return [void]
  def self.truncate
    DATABASE.execute("DELETE FROM #{SQLite3::Database.quote(table_name)}")
  end
end

On peut alors la tester

script.rb
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.