segunda-feira, 18 de junho de 2012

Rails: Auxílio a migrações complexas

Oi,
Penso que estamos de acordo quanto ao Rails ser uma ferramenta poderosa, flexível (a partir da versão 3.0) e Pesada. Se não estamos, podemos mandar vir umas cervejas e discutir o assunto. Mas uma das facetas que tenho descoberto e apreciado mais desde a reformulação que atingiram com a versão 3.0 é como a dissociação dos vários módulos que introduzem comportamento específico em determinadas componentes permite que esses mesmos módulos sejam individualmente ferramentas poderosas para efectuar tarefas que reflectem esse comportamento específico, mas fora do contexto onde estes módulos são normalmente utilizados. Soou um pouco confuso, mas passo a explicar com um exemplo específico.

Há cerca de 8 meses, no projecto em que correntemente trabalho, começámos a desenvolver uma aplicação web que interagiria com os dados da aplicação principal que estávamos (ainda estamos) a manter. A decisão tomada na altura foi a de desenvolver a segunda aplicação em cima da primeira, com acesso à mesma base de dados, achando que iriamos poupar tempo com isso, sendo os dados unicamente referentes à segunda aplicação guardados em tabelas com o âmbito explicitamente definido no nome (nome-tipo: aplicacao2_users, aplicacao2_profiles, etc...), o código-fonte referente à segunda aplicação protegido dentro do mesmo âmbito (namespace), e sendo criado um encaminhamento (routing) específico para esse âmbito sobre um hóspede (host) diferente. Deste modo, conseguimos criar uma coisa inaudita, um verdadeiro "pague 2 compre 1", uma salganhada tão grande que agora estamos a sofrer as consequências dessa decisão. O que estava errado aqui? Pois, quis-se desenvolver 2 aplicações completamente diferentes, com lógica completamente diferente, uma em cima da outra, em vez de se dissociar os ambientes e posteriormente providenciar um mecanismo de sincronização para os dados comuns relevantes. Interagir com os mesmos dados não significa necessariamente interagir com a mesma representação dos mesmos. Ao invés, aumentámos uma base de código já de si gigantesca, misturámos dados de âmbitos diferentes nas mesmas tabelas, e criámos ainda mais confusão na camada de apresentação (CSS) e do scripting do lado do cliente (o qual não vou abordar em pormenor agora).

Depois de assumido o erro, chegou-se à conclusão que era necessário separar as aplicações. Qualquer coisa de herculeano para uma aplicação que já está em produção faz 6 meses. Fiquei encarregue do primeiro passo nessa direcção: criar uma segunda base de dados e migrar os dados relevantes para segunda aplicação para ela. Algumas migrações eram fáceis: somente copiar uma tabela de um lado para o outro e zás. Outras envolviam alguma dor de cabeça: uma inserção aqui, outra deleção ali, uma actualização acolá... e tudo testadinho, que era para a barra não cair quando fosse para produção.

Não sei quanto a vocês, mas o meu uso de migrações em Rails sempre foi geralmente limitado a criação de tabelas, remoção/inserção de colunas, com o ocasional preenchimento de colunas utilizando directamente o comando execute. Então o que aconteceu quando comecei a escrever as migrações? O meu conhecimento estava limitado ao execute (e também a alguns métodos de selecção), então bombei no execute para tudo o que era peixe. Saiu o maior mufufu. Aqui fica um exemplo:



# pesquisar atributos
attrs = select_all("SELECT * FROM assets i WHERE i.owner_id = 1 AND i.image_owner_type = 'User::Lawyer'")
# actualizar para a nova tabela
attrs['origin'] = "app1"

attributes, values = attrs.to_a.transpose
values.map!{|attr| attr.nil? ? "NULL" : attr.kind_of?(Time) ? "\"#{attr.to_s(:db)}\"" : attr}
execute "INSERT INTO images (#{attributes.join(",")}) VALUES (#{values.join(',')});"
image_id = Integer(select_value("SELECT id FROM images ORDER BY id DESC LIMIT 1;"))
# usar o image_id em outras operações...


É, o resultado foi o previsto. Migrações que funcionavam, testadinhas e bonitinhas, mas que ninguém conseguia ler (e com algumas limitações, se quiserem investigar esse pedaço de código mais a fundo). Surgiu mais tarde o caso em que tinha que introduzir mais algumas operações no meio desta barafunda, e foi aí que eu pensei que estava na altura de descobrir como é que realmente se fazia aquilo a que eu me propunha, e como é que o Rails fazia para gerir toda esta comunicação com a base de dados, pois seguramente que não usavam o execute. Resolvi então começar a estudar alguma documentação, e comecei... pelo execute.

E foi aí que entrei no maravilhoso mundo da comunicação com a base de dados auxiliada por Rails. Como alguns de vocês  conhecem, as migrações são uma sub-componente de uma sub-componente do Rails, o nosso conhecido ActiveRecord. Graças ao esforço de dissociação empregue na versão 3 do AR, tudo o que envolve a comunicação com a base de dados foi extraído para um módulo à parte chamado ConnectionAdapters (http://apidock.com/rails/ActiveRecord/ConnectionAdapters). No domínio deste âmbito encontram-se definidos adaptadores para vários tipos possíveis de base de dados que herdam de um adaptador abstracto (AbstractAdapter). Nesse adaptador abstracto é incluído um módulo chamado DatabaseStatements, que era exactamente o que estava à procura. Neste módulo estão definidos todos os métodos com os quais se podem enviar declarações para a base de dados, cada um deles reflectindo o comportamento-base que a base de dados tem para com cada uma destas declarações. Logo, existe um método chamado insert, que retorna o id da última entrada inserida; o método update, que retorna o número de linhas afectadas na tabela utilizada, entre outras funções úteis... ah, o execute também tá definido aqui! (http://apidock.com/rails/v3.2.1/ActiveRecord/ConnectionAdapters/DatabaseStatements)
Tornou-se claro para mim que este módulo tinha que ser incluído nas migrações, dado que o execute andava por ali à mão de semear. Dei uma olhada no código-fonte da classe ActiveRecord::Migration, e dei conta que as migrações são corridas no contexto da classe ActiveRecord::Base. Tive a minha confirmação. Daí à reescrita das migrações foi um passo:

  


# pesquisar atributos
attrs = select_all("SELECT * FROM assets i WHERE i.owner_id = 1 AND i.image_owner_type = 'User::Lawyer'")
# actualizar para a nova tabela
attrs['origin'] = "app1"

attributes, values = attrs.to_a.transpose
attributes.map!{|a|quote_column_name(a)}.join(",")
values.map!{|a|quote(a)}.join(",")
  image_id = insert "INSERT INTO images (#{attributes}) VALUES (#{values});"
# usar o image_id em outras operações...


Poupei duas linhas inconsequentes e encurtei outras tantas. Mas continuo ali com aquela declaração SQL explícita que me dá comichão na garganta... mas, enquanto o pessoal do Rails não encontrar um modo de "ARelizar" essas chamadas aos métodos do DatabaseStatements, penso que isto é o melhor que se consegue (corrijam-me se estiver enganado). Ah, ali aquelas chamadas a quote_column_name e quote são naturalmente funções auxiliares que colocam as aspas de segurança respectivas ao redor dos atributos e valores. Outra das boas lições desta viagem pelo código-fonte do Rails.

Como a minha tarefa seguinte seria criar um módulo que migrasse dados de uma aplicação para a outra, e como esta migração teria que se passar directamente entre as duas bases de dados (o objectivo final é ter 2 aplicações), decidi implementar o conhecimento adquirido. Criei então um Mixin que tratava da sincronização (criação, actualização e deleção) de uma entidade na base de dados 1 com a base de dados 2. Para isso, criei uma classezinha que encapsulava todas as rotinas de uma forma bonitinha e que podia ser chamada aplicando uma variante similar ao ARel, um DSLzinho limitado a esse âmbito. O resultado foi a classe seguinte:

 

      class BaseStatements
        attr_reader :connection

        def initialize(connection)
          @connection = connection
        end

        def select_all(*fields)
          conditions = fields.extract_options!
          table_name = fields.pop
          @connection.select_all("SELECT #{fields.empty? ? '*' : quote_keys(fields).join(",")} FROM #{table_name}
                                 #{' WHERE ' unless conditions.blank?}#{parse_conditions(conditions)}")
        end

        def select_value(field, table_name, conditions)
          @connection.select_value("SELECT #{field} FROM #{table_name} WHERE #{parse_conditions(conditions)}")
        end

        def insert(table_name, attributes)
          keys, values = attributes.to_a.transpose
          @connection.insert("INSERT INTO #{table_name} (#{quote_keys(keys).join(",")}) VALUES (#{quote(values).join(",")})")
        end

        def update(table_name, attributes, conditions)
          @connection.update("UPDATE #{table_name} SET #{quote_keys(attributes)}=#{quote(v)}"}.join(",")} 
                                                   WHERE #{parse_conditions(conditions)}")
        end

        def delete(table_name, conditions)
          @connection.delete("DELETE FROM #{table_name} WHERE #{parse_conditions(conditions)}")
        end

        private
        def quote_keys(keys)
          keys.map{|a|@connection.quote_column_name(a)}  
        end
        
        def quote(values)
          values.map{|a|@connection.quote(a)}
        end 
 
        def parse_conditions(cond)
          return @connection.quote(cond) unless cond.kind_of?(Hash)
          cond.map do |k, v|
            case k
                when :not then "NOT #{parse_conditions(v)}"
                when :in then "IN (#{v.map{|el|@connection.quote(el)}.join(",")})"
                else "#{@connection.quote_column_name(k)}#{v.kind_of?(Hash) ? ' ' : v.nil? ? ' IS ' : ' = '}#{parse_conditions(v)}"
            end
          end.join(" AND ")
        end
      end

Instanciando um objecto desta classe, podia então realizar declarações SQL com algum nível de complexidade numa linguagem mais descritiva e legível. Alguns exemplos:

handler = BaseStatements.new(ActiveRecord::Base.connection)
 
handler.select_all(:id, :name, :users, :email => "donald@ducks.com", :city => "Buxumburra")
# "SELECT id, name FROM users WHERE email = 'donald@ducks.com' AND city = 'Buxumburra';"  
handler.insert({:email => "donald@ducks.com", :city => "Buxumburra"}, :users)
# "INSERT INTO users (email,city) VALUES('donald@ducks.com','Buxumburra');" 
handler.update({:city => "Cidade do Cabo"}, :users, {:email => "donald@ducks.com})
# "UPDATE users SET city='Cidade do Cabo' WHERE email='donald@ducks.com';" 
handler.update({:email => "donalda@ducks.com", :users, :city => {:not => {:in => ["Buxumburra","Kinshasa"]}} 
# "UPDATE users SET email='donalda@ducks.com' WHERE city NOT IN ('Buxumburra','Kinshasa');" 
handler.delete(:users, :id => 1) 
# "DELETE FROM users WHERE id=1;"
 
 
Como disse, é um DSLzinho, não cobre todas as possibilidades, foi uma solução para um contexto específico. Claro que existem outras questões que se colocam em relação ao tipo de solução que eu encontrei para este tipo de problema. Não estaria mais correcto fazer tudo na base de dados através de um stored procedure? Sim. Não seria mais moderno e elegante no contexto web comunicar com a aplicação 2 através de um serviço remoto, por exemplo uma web API? Possivelmente. Em todo o caso, valeu a pena descobrir e explorar este pedacinho do mundo do Rails.

Sem comentários:

Enviar um comentário