Usando SQL no Rails

Em primeiro lugar, o artigo não é sobre como eu gosto de trilhos e, em segundo lugar, o artigo não é sobre como eu os odeio. Eles podem ser tratados de maneiras completamente diferentes e só serão melhores se você os alterar. E eles podem piorar apenas se começarem a mudar. Bem, em geral, eu te avisei, mas você me entendeu.


Um dos principais conceitos do ActiveRecord é que o banco de dados é bastante utilitário e pode ser alterado. Bem, você está sentado lá, escrevendo seus modelos usando o MySQL, e de repente você lê em algum lugar que você pode pegar o MySQL e substituí-lo pelo MongoDB. Bem, não é tão radical, mas, digamos, no PostgreSQL, você pode ter razões para substituir o MySQL. Ou vice-versa, não tenho nada contra o MySQL. Aqui, o ActiveRecord alega tornar mais fácil para você, escopos supostamente, filtros e associações antes / depois são abstratos o suficiente para não se preocupar em gerar consultas ao banco de dados e cuidar da lógica do aplicativo. Que, em vez de WHERE is_archived = TRUE você WHERE is_archived = TRUE feliz em escrever where(is_archived: true) e o ActiveRecord fará tudo por você. Todos os exemplos serão traduzidos para o PostgreSQL, não para o MySQL, portanto, os usuários do MySQL terão que inventar sua própria bicicleta.



Mas não importa como! Na prática, verifica-se que essa camada de abstração está completamente cheia de buracos, como uma calha da história do Peixe Dourado. E que muitos recursos básicos não podem ser usados, como comparar datas ou trabalhar com matrizes. E você obtém escopos com forçado where("#{quoted_table_name}.finished_at >= ?", Date.current) ou where("#{quoted_table_name}.other_ids <@ ARRAY[?]", ids) . Para qual ActiveRecord dá uma resposta completamente consciente e lógica: não a use. Em vez de matrizes, use a conexão habtm e, se você precisar comparar datas, viva com ela. Sim, e Deus proíbe que você perca quoted_table_name nesse escopo - a primeira quoted_table_name ou joins colocará tudo em seu lugar. É mais fácil em qualquer lugar e sempre para escrever, para não bater na mão.


Bem, é claro, assim que você decidir interferir com o ActiveRecord aqui, não haverá como voltar atrás. Não que não haja chance, mesmo uma esperança fantasmagórica de uma transição indolor para outro banco de dados. Será muito mais fácil imprimir e gravar esse código fonte. E, é claro, não há outro motivo para não usar os recursos extras do banco de dados em seu aplicativo. Use na saúde e force os outros!


E quando seus escopos na pasta de modelos consistem em mais da metade desses recursos extras, é óbvio que o ActiveRecord é apenas um shell conveniente para integrar um trecho de código às etiquetas com outro trecho de código. E os escopos, como where(is_archived: true).joins(:sprint).merge(Sprint.archived) , funcionarão bem e combiná-los não será mais difícil do que produzir ovos mexidos, certo?



O próximo estágio será a desnormalização. Não, a desnormalização, por assim dizer, nem sempre desapareceu, mas o cuidado dela recaiu sobre os poderosos ombros do Rails e do ActiveRecord, e você sabe que esses dois caras não diferem na rapidez e no ascetismo nos requisitos de recursos. Digamos counter_cache: true é o primeiro passo para a desnormalização, porque COUNT(*) AS sprints_count não permitirá que você crie ActiveRecord (você não deseja alterar select() , certo?). E counter_cache não counter_cache longe de ser perfeito e, em alguns casos, pode haver uma dessincronização do valor real do cache. Não crítico, é claro, mas desagradável. E este é apenas o primeiro candidato a se instalar no banco de dados e não carregar a cabeça já carregada da máquina ruby. Apenas alguns gatilhos e pronto! Em primeiro lugar, ao excluir e adicionar um novo registro à placa A, você precisa contar o número de registros na placa B e é isso, certo? Bem, ao editar, é claro, se foreign_key mudou, porque a UPDATE B SET a_id = $1 WHERE id = $2 quebrará counter_cache para A antigo e novo.


  CREATE OR REPLACE FUNCTION update_#{parent_table}_#{child_table}_counter_on_insert() RETURNS TRIGGER AS $$ BEGIN UPDATE #{parent_table} SET #{counter_column} = COALESCE((SELECT COUNT(id) FROM #{child_table} GROUP BY #{foreign_column} HAVING #{foreign_column} = NEW.#{foreign_column}), 0) WHERE (#{parent_table}.id = NEW.#{foreign_column}); RETURN NULL; END; $$ LANGUAGE plpgsql; 

O próximo caminho do banco de dados estará relacionado à data e hora. E para iniciantes, vamos apenas servir os campos created_at e updated_at no banco de dados, felizmente, isso é muito mais simples. Padrões do primeiro conjunto:


  change_column_default :table_name, :created_at, -> { 'CURRENT_TIMESTAMP' } change_column_default :table_name, :updated_at, -> { 'CURRENT_TIMESTAMP' } 

E para fazê-lo imediatamente em qualquer lugar, você pode organizar um ciclo para todas as placas onde esses campos estão. Além das ar_internal_metadata e ar_internal_metadata , é claro:


  (tables - %w(schema_migrations ar_internal_metadata)).each { ... } 

É isso aí, agora o valor padrão para essas tabelas será exatamente o que precisamos. E agora é a hora de garantir que os trilhos não toquem nesses campos. Isso é feito com dois parafusos nos lugares certos. E sim, existe uma opção na configuração da estrutura responsável por isso:


 Rails.application.config.active_record.record_timestamps = false 

Portanto, a próxima etapa é atualizar o campo updated_at no momento em que o registro é atualizado. É simples:


  CREATE OR REPLACE FUNCTION touch_for_#{table_name}_on_update() RETURNS TRIGGER AS $$ BEGIN SELECT CURRENT_TIMESTAMP INTO NEW.updated_at; RETURN NEW; END; $$ LANGUAGE plpgsql; 

Agora você precisa se livrar completamente do touch: true nos modelos. Essa coisa é muito parecida com o alvo no painel - também completamente vazio. E nem vou explicar o porquê, porque você já conhece todos esses casos. Isso não é muito mais complicado, tudo que você precisa é atualizar updated_at não apenas para você:


  CREATE OR REPLACE FUNCTION touch_for_#{table_name}_on_update() RETURNS TRIGGER AS $$ BEGIN UPDATE foreign_table_name SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.foreign_column_name; SELECT CURRENT_TIMESTAMP INTO NEW.updated_at; RETURN NEW; END; $$ LANGUAGE plpgsql; 

Obviamente, a cadeia de chamadas de tais gatilhos fará uma ação desnecessária, mas nos postgres do mecanismo são, os gatilhos são chamados sem alterar o próprio registro. Você pode tentar executar SET title = title , mas não sai melhor que SET updated_at = CURRENT_TIMESTAMP .


Exatamente o mesmo gatilho estará na inserção, apenas a atualização updated_at não updated_at necessária:


  CREATE OR REPLACE FUNCTION touch_for_#{table_name}_on_insert() RETURNS TRIGGER AS $$ BEGIN UPDATE foreign_table_name SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.foreign_column_name; RETURN NEW; END; $$ LANGUAGE plpgsql; 

Obviamente, você pode tentar escrever isso com uma função, adicionando uma verificação do evento atual dentro do gatilho, semelhante a IF TG_OP = 'UPDATE' THEN , mas é preferível tornar todos os gatilhos o mais simples possível para reduzir a probabilidade de erro.


Você pode de alguma forma automatizar a geração desses gatilhos e provavelmente precisará encontrar todas as relações externas entre a tabela atual e o restante. Você pode fazer isso facilmente com esta consulta:


  SELECT ccu.table_name AS foreign_table_name, kcu.column_name AS column_name FROM information_schema.table_constraints AS tc JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name WHERE constraint_type = 'FOREIGN KEY' AND tc.table_name = '#{table_name}' ORDER BY ccu.table_name; 

Outra dica muito útil. Ativadores de chamada da mesma maneira para poder verificar a presença ou ausência do que é necessário para uma solicitação, por exemplo, essa solicitação encontrará todos os acionadores de inserção por toque:


  SELECT routine_name AS name FROM information_schema.routines WHERE routine_name LIKE 'touch_for_%_on_insert' AND routine_type ='FUNCTION' AND specific_schema='public'; 

E, por último, o pior resta. O fato é que os trilhos não foram projetados para pelo menos algum tipo de banco de dados inteligente e eles realmente não se importaram com o fato de que pelo menos algo diferente do campo de id pudesse mudar no banco de dados, e isso somente quando inserido. Portanto, não existe um mecanismo sensato para adicionar RETURNING updated_at para atualizar solicitações; não há, você precisará mergulhar no interior do Rails para os ouvidos.



O Mankipatch acabou não sendo muito elegante, mas antes de tudo o objetivo era prejudicar o trabalho atual da estrutura o mínimo possível.


Vou trazê-lo completamente
 module ActiveRecord module Persistence # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/persistence.rb#L729-L741 def _create_record(attribute_names = self.attribute_names) attribute_names &= self.class.column_names attributes_values = attributes_with_values_for_create(attribute_names) an_id, *affected_rows = self.class._insert_record(attributes_values).dup self.id ||= an_id if self.class.primary_key Hash[ApplicationRecord.custom_returning_columns(self.class.quoted_table_name, :create).take(affected_rows.size).zip(affected_rows)].each do |column_name, value| public_send("#{column_name}=", self.class.attribute_types[column_name.to_s].deserialize(value)) if value end @new_record = false yield(self) if block_given? id end private :_create_record # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/persistence.rb#L710-L725 def _update_record(attribute_names = self.attribute_names) attribute_names &= self.class.column_names attribute_names = attributes_for_update(attribute_names) if attribute_names.empty? affected_rows = [] @_trigger_update_callback = true else affected_rows = _update_row(attribute_names) @_trigger_update_callback = affected_rows.any? end Hash[ApplicationRecord.custom_returning_columns(self.class.quoted_table_name, :update).take(affected_rows.size).zip(affected_rows)].each do |column_name, value| public_send("#{column_name}=", self.class.attribute_types[column_name.to_s].deserialize(value)) end yield(self) if block_given? affected_rows.none? ? 0 : 1 end private :_update_record end module ConnectionAdapters module PostgreSQL module DatabaseStatements # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb#L93-L96 def exec_update(sql, name = nil, binds = []) execute_and_clear(sql_with_returning(sql), name, binds) { |result| Array.wrap(result.values.first) } end # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L147-L152 def insert(arel, name = nil, pk = nil, _id_value = nil, sequence_name = nil, binds = []) sql, binds = to_sql_and_binds(arel, binds) exec_insert(sql, name, binds, pk, sequence_name).rows.first end alias create insert # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb#L98-L111 def sql_for_insert(sql, pk, id_value, sequence_name, binds) # :nodoc: table_ref = extract_table_ref_from_insert_sql(sql) if pk.nil? # Extract the table from the insert sql. Yuck. pk = primary_key(table_ref) if table_ref end returning_columns = quote_returning_column_names(table_ref, pk, :create) if returning_columns.any? sql = "#{sql} RETURNING #{returning_columns.join(', ')}" end super end # No source in original repo def quote_returning_column_names(table_ref, pk, action) returning_columns = [] returning_columns << pk if suppress_composite_primary_key(pk) returning_columns += ApplicationRecord.custom_returning_columns(table_ref, action) returning_columns.map { |column| quote_column_name(column) } end # No source in original repo def sql_with_returning(sql) table_ref = extract_table_ref_from_update_sql(sql) returning_columns = quote_returning_column_names(table_ref, nil, :update) return sql if returning_columns.blank? "#{sql} RETURNING #{returning_columns.join(', ')}" end # No source in original repo def extract_table_ref_from_update_sql(sql) sql[/update\s("[A-Za-z0-9_."\[\]\s]+"|[A-Za-z0-9_."\[\]]+)\s*set/im] Regexp.last_match(1)&.strip end end end end end 

O mais importante é que aqui nos voltamos para ApplicationRecord.custom_returning_columns para descobrir quais colunas além de id são interessantes para nós. E esse método se parece com isso:


  class << self def custom_returning_columns(table_ref, action) return [] if ['"schema_migrations"', '"ar_internal_metadata"'].include?(table_ref) res = [] res << :created_at if action == :create res << :updated_at res += case table_ref when '"user_applications"' [:api_token] when '"users"' [:session_salt, :password_changed_at] # ... else [] end res end end 



Em vez de conclusões, podemos dizer que a cabeça dolorida do Rails se tornou um pouco menos dolorida. Processos de counter_cache , como counter_cache e touch , afundam no esquecimento e, no próximo artigo, podemos pensar em algo mais global, como remover espaços suspensos, validação de dados, exclusão de dados em cascata ou exclusão paranóica. Se você gostou deste artigo, é claro.

Source: https://habr.com/ru/post/pt421939/


All Articles