Nós tínhamos um problema intrigante. Nós sabíamos que as colunas de um modelo em particular precisaria de mudanças ao longo do tempo. Isto era facilmente resolvido com um campo JSON, mas nós também queríamos dar suporte a validações neste campo – validações que são atribuídas ao modelo no tempo de criação. Exemplo: Ontem nós criamos um registro válido para Veículo somente com um campo nome. Hoje, nós adicionamos um outro campo. Todos os novos veículos devem tê-lo. Se reabrirmos o registro de ontem, nós queremos que ele ainda esteja válido.
Este foi um esforço coletivo entre eu, Tom Rothe e Dmitry Parshenko. Depois de nós fazermos brainstorming e sessões de quadro branco, decidimos começar com um modelo Model chamado Vehicle. Ele contem um schema e uma description que define suas características.
O schema
O schema define atributos, nos dizendo seus tipos e valores padrões. Isto nos possibilita ter um conjunto diferente de atributos para cada registro da tabela. Campos obrigatórios também são declarados no schema para validações posteriores.
Nós queremos que cada nova instância de vehicle tenha seu esquema preenchido por um valor padrão declarado na tabela do banco de dados. Vamos criar nossa migration do Vehicle:
1 2 3 4 5 6 7 8 9 |
class CreateVehicles < ActiveRecord::Migration[5.2] def change create_table :vehicles do |t| t.jsonb :schema, null: false, default: { "required": ["name", "brand"], "attributes": { "name": { "type": "string", "default": "" }, "brand": { "type": "string", "default": "" }, "color": { "type": "string", "default": "" }, "wheels": { "type": "integer", "default": 2 } } } t.jsonb :description t.timestamp end end end |
Nós estamos seguindo a estrutura do JSON Schema, mas somente as que são necessárias para o nosso objetivo.
Manipulando dados
Agora, para trabalhar tanto com novas instâncias ou com registros existentes do nosso modelo Vehicle, nós devemos criar nossa classe Vehicle:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Vehicle < ApplicationRecord store_accessor :schema, :required, :attributes serialize :description after_initialize :define_default_values private def define_default_values return unless self.description.blank? self.description = self.schema['attributes'].keys.reduce({}) do |acc, field| acc[field] = self.schema['attributes'][field]['default'] acc end end end |
O store_accessor define os getters e setters do schema. O serialize é necessário porque nós estamos usando JSONB (PostgreSQL). Para cada nova instância, iteramos sobre os atributos do schema e definimos os valores padrões de description.
Lidando com validações
Quando nós tentamos salvar ou atualizar uma instância de nosso Vehicle, nós iteramos sobre os campos obrigatórios que estão listados no schema:
1 2 3 4 5 6 7 8 9 10 11 12 |
class Vehicle < ApplicationRecord ... before_validation :validate_from_schema private def validate_from_schema self.schema['required'].each do |field| errors.add(field, 'is required') if self.description[field].blank? end end end |
Aqui só implementamos uma validação de presença, mas você pode adicionar outros tipos de validação.
Exibindo nossos registros
Para criar nossos formulários no HTML, nós usamos a definição do schema e iteramos sobre ela:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# views/vehicles/new.html.haml %h1 Vehicle creation = form_for @vehicle, url: { action: "create" } do |f| = f.fields_for :description, OpenStruct.new(vehicle.description) do |df| - vehicle.schema['attributes'].each do |field, info| %p = field - if info['type'] == 'boolean' = df.check_box field - else = df.text_field field = vehicle.errors[field] = f.submit |
Repare que nós usamos o OpenStruct para fazer o form do Rails capaz de preencher os valores de registros existentes. Sem ele, nós sempre temos campos vazios quando estamos no formulário de edição.
Lidando com consultas
Como estamos usando PostgreSQL e JSONB, ganhamos o poder de consultas como estas:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# description->sound = 'Pioneer' Vehicle.where('description @> ?', {sound: 'Pioneer'}.to_json) # description->fuels = ['Gasoline', 'Etanol'] Vehicle.where("description -> 'fuels' ? :fuel", fuel: 'Gasoline') # description->alarm AND description->sound Vehicle.where('description ?& array[:keys]', keys: ['alarm', 'sound']) # description->automatic OR description->gps Vehicle.where('description ?| array[:keys]', keys: ['automatic', 'gps']) # description->color = 'Red' AND description->wheels = 4 Vehicle.where('description @> ?', {color: 'Red', wheels: 4}.to_json) |
Lembre que com JSONB, nós temos índices chamados GIN / GiST para ajudar com performance.
Conclusão
Nós agora somos capazes de entregar, em um banco relacional, múltiplos registros da mesma tabela, cada um com um conjunto diferente de atributos. Com a definição do schema e um pouco de mágica do Rails, a gente também habilita validações. Registros antigos continuam válidos e usáveis se o valor padrão do schema mudar, e consultas na descrição funcionam por causa do PostgreSQL a os campos JSONB.
Uma versão completa dessa ideia pode ser vista neste repositório do Github. Lembre de olhar os branches heitor, tom e dmitry, que têm abordagens diferentes para o mesmo problema. Contudo, o branch master combina os anteriores em uma solução mais completa.
Se você tiver quaisquer perguntas, entre em contato.
P.S.: Este código não é viável para produção. Nós nos abstivemos de refatorá-lo para uma gem / DSL, então os exemplos de código ficaram um pouco mais agradáveis.