Stocker et accéder à des paramètres JSONB dans PostgreSQL et Rails

PostgreSQL 9.4 et supérieures permettent de stocker des données JSON nativement, et Rails simplifie l’utilisation de ce format.

Tags : Ruby on Rails PostgreSQL JSON

Publié le 17 août 2021

Le format JSONB peut être indexé et cherché, avec des performances sensiblement similaires que du JSON stocké tel quel, c’est donc le format à privilégier.

Je recommande fortement la lecture du billet de Nando Vieira, Using PostgreSQL and jsonb with Ruby on Rails (utiliser PostgreSQL et JSONB avec Ruby on Rails) pour mieux connaître l’impact sur les performances et les différentes manières de requetter la base en utilisant des attributs stockés dans des colonnes JSONB.

La macro store_accessor définie dans le module ActiveRecord::Store::ClassMethods, qui est automatiquement inclus par ActiveRecord.

Cette macro utilise le nom de colonne, les clés, et d’éventuels préfixes ou suffixes pour les méthodes générées. Le code généré permet d’appliquer très facilement des validations ou des callbacks aux colonnes stockées en JSON, ou de suivre les modifications. Grâce à cela, les données stockées dans une colonne JSON/JSONB soint aussi faciles à utiliser que des colonnes normales.

Comme toujours, il vous appartient de peser le pour et le contre avant de décider s’il est pertinent de stocker des données dans une colonne au format JSON au lieu de colonnes dédiées, mais voilà une situation qui me semble particulièrement appropriée : les réglages liés à l’instance.

Pourquoi stocker les réglages d’instance en JSONB

Pour une application liée à un seul utilisateur (tenant), il paraît superflu voire inapproprié de créer une colonne pour chaque paramètre, sachant que cette table ne contiendra qu’une seule ligne.

Et pour une application liée à plusieurs utilisateurs, cela permet de stocker des réglages spécifiques sans devoir ajouter une colonne pour chaque personnalisation demandée.

Dans les deux cas, stocker ces valeurs dans une colonne JSONB permet d’enregistrer tous types de données sans jamais avoir besoin d’opérer de migration, c’est donc simple et particulièrement adapté.

Voici le code lié aux réglages de mon modèle Tenant :

# app/models/tenant.rb
class Tenant < ApplicationRecord
  KEYS = [:name, :blog_url, :contact_email].freeze
  store_accessor :settings, *KEYS

  validates :name, presence: true
  validates :contact_email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i }

  class << self
    def fields
      @fields ||= KEYS.collect { |key| [key, key.match(/(?:.*)_(.*)/) { |type| type[1].to_sym } || :text] }
    end
  end
end

La première ligne contient la liste des champs stockées en JSON. J’en fais une constante puisque c’est une valeur utilisée à plusieurs endroits dans la classe.

Je passe ensuite cette liste de champs à la macro store_accessor pour pouvoir interagir avec ces champs comme s’il s’agissait de colonnes classiques.

Je peux ensuite définir des validations et des callbacks comme on le fait d’habitude.

Pour finir, j’ajoute la méthode de classe #fields qui liste chaque champ avec le type associé. Celui-ci est déduit du suffixe du champ (url, email, etc). Il suffit ensuite d’itérer sur ces différents types pour générer un formulaire permettant de saisir chaque réglage :

# app/views/tenants/_form.html.erb
<%= form_with(model: tenant) do |f| %>
  <% Tenant.fields.each do |key, type| %>
    <div class="field">
      <%= f.label key %>
      <%= f.text_field key, type: type %>
    </div>
  <% end %>

  <%= f.button "Save" %>
<% end %>