Usando SQL en Rails

En primer lugar, el artículo no trata sobre cómo me gustan los rieles, y en segundo lugar, no trata sobre cómo los odio. Se pueden tratar de formas completamente diferentes y mejorarán solo si los cambia. Y pueden empeorar solo si comienzan a cambiar. Bueno, en general, te lo advertí, pero me entendiste.


Uno de los conceptos principales de ActiveRecord es que la base de datos es bastante utilitaria y se puede cambiar. Bueno, estás sentado allí, escribiendo tus modelos usando MySQL, y de repente lees en algún lugar que puedes tomar MySQL y reemplazarlo con MongoDB. Bueno, no tan radical, pero, digamos, en PostgreSQL, podría tener razones para reemplazar MySQL. O viceversa, no tengo nada en contra de MySQL. Aquí ActiveRecord pretende facilitarlo, supuestamente los ámbitos, los filtros y asociaciones antes / después son lo suficientemente abstractos como para no preocuparse por generar consultas a la base de datos y cuidar la lógica de la aplicación. Que en lugar de WHERE is_archived = TRUE te complace escribir where(is_archived: true) y ActiveRecord hará todo por ti. Todos los ejemplos se traducirán para PostgreSQL, no para MySQL, por lo que los usuarios de MySQL tendrán que inventar su propia bicicleta.



¡Pero no importa cómo! En la práctica, resulta que esta capa de abstracción está completamente llena de agujeros, como un canal de la historia del Pez Dorado. Y que muchas características básicas no se pueden usar, como comparar fechas o trabajar con matrices. Y obtienes ámbitos con forzado where("#{quoted_table_name}.finished_at >= ?", Date.current) o where("#{quoted_table_name}.other_ids <@ ARRAY[?]", ids) . A lo que ActiveRecord da una respuesta completamente consciente y lógica: no lo use. En lugar de matrices, use la conexión habtm, y si necesita comparar fechas, viva con ella. Sí, y Dios le prohíbe que se pierda quoted_table_name en ese ámbito: el primero includes o joins pondrá todo en su lugar. Es más fácil en todas partes y siempre escribir, para no golpear la mano.


Bueno, por supuesto, tan pronto como decidas interferir con ActiveRecord aquí, no habrá vuelta atrás. No es que no haya posibilidad, incluso una esperanza fantasmal para una transición indolora a otra base de datos. Será mucho más fácil imprimir y grabar este código fuente. Y, por supuesto, no hay otra razón para no usar las características adicionales de la base de datos en su aplicación. ¡Úselo en salud y fuerce a otros!


Y cuando resulta que sus alcances en la carpeta de modelos consisten en más de la mitad de estas características adicionales, es bastante obvio que ActiveRecord es solo un shell conveniente para integrar una pieza de código con etiquetas con otra pieza de código. Y los ámbitos, como where(is_archived: true).joins(:sprint).merge(Sprint.archived) , funcionarán bien y combinarlos no será más difícil que hacer huevos revueltos, ¿verdad?



La próxima etapa será la desnormalización. No, la desnormalización, por así decirlo, no siempre desapareció, pero su cuidado recayó en los poderosos hombros de Rails y ActiveRecord, y sabes que estos dos tipos no diferían en rapidez y ascetismo en los requisitos de recursos. Digamos counter_cache: true es el primer paso hacia la desnormalización, porque COUNT(*) AS sprints_count no le permitirá hacer ActiveRecord (no desea cambiar select() , ¿verdad?). Y counter_cache no counter_cache lejos de ser perfecto, y en algunos casos puede haber una desincronización de la cantidad real del caché. No crítico, por supuesto, pero desagradable. Y este es solo el primer candidato que se instala en la base de datos y no carga el cabezal ya cargado de la máquina de rubíes. ¡Solo un par de disparadores y listo! En primer lugar, al eliminar y agregar un nuevo registro a la placa A, debe contar el número de registros en la placa B y eso es todo, ¿verdad? Bueno, al editar, por supuesto, si foreign_key cambiado, porque la UPDATE B SET a_id = $1 WHERE id = $2 romperá counter_cache tanto para A antiguo como para nuevo.


  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; 

La siguiente ruta de la base de datos estará relacionada con la fecha y la hora. Y para empezar, solo sirvamos los campos created_at y updated_at en la base de datos, afortunadamente, esto es mucho más simple. Primer conjunto de valores predeterminados:


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

Y para hacerlo inmediatamente en todas partes, puede organizar un ciclo para todas las placas donde se encuentran estos campos. Además de las ar_internal_metadata schema_migrations y ar_internal_metadata , por supuesto:


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

Eso es todo, ahora el valor predeterminado para estas tablas será exactamente lo que necesitamos. Y ahora es el momento de asegurarse de que los rieles no toquen estos campos. Esto se hace con dos tornillos en los lugares correctos. Y sí, hay una opción para configurar el marco responsable de esto:


 Rails.application.config.active_record.record_timestamps = false 

Entonces, el siguiente paso es actualizar el campo updated_at en el momento en que se actualiza el registro. Es simple:


  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; 

Ahora necesita deshacerse por completo del touch: true en los modelos. Esto es muy similar al objetivo en el tablero, también completamente holey. Y ni siquiera explicaré por qué, porque ya conoces todos estos casos. Esto no es mucho más complicado, todo lo que necesita es actualizar updated_at no solo para usted:


  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; 

Por supuesto, la cadena de llamadas de tales desencadenantes hará una acción innecesaria, pero en los postgres del mecanismo sano, los desencadenantes se invocan sin cambiar el registro en sí. Puede intentar hacer SET title = title , pero no sale mejor que SET updated_at = CURRENT_TIMESTAMP .


Exactamente el mismo desencadenador estará en la inserción, solo no updated_at necesario actualizar updated_at :


  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; 

Por supuesto, podría intentar escribir esto con una función, agregando una verificación para el evento actual justo dentro del disparador, similar a IF TG_OP = 'UPDATE' THEN , pero es preferible hacer que todos los disparadores sean lo más simples posible para reducir la probabilidad de un error.


Es posible que desee automatizar de alguna manera la generación de tales desencadenantes, y luego probablemente necesite encontrar todas las relaciones exteriores entre la tabla actual y el resto. Puede hacer esto fácilmente con 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; 

Otro consejo muy útil. Llame a los activadores de la misma manera para poder verificar la presencia o ausencia de lo que necesita una solicitud, por ejemplo, esta solicitud encontrará todos los activadores de inserción táctil:


  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'; 

Y por último, queda lo peor. El hecho es que los rieles no están diseñados para al menos algún tipo de base de datos inteligente, y realmente no les importaba el hecho de que al menos algo más que el campo id podría cambiar en la base de datos, y eso solo cuando se inserta. Por lo tanto, no existe un mecanismo sensato para agregar RETURNING updated_at para actualizar las solicitudes.



Mankipatch resultó no ser muy ordenado, pero antes que nada el objetivo era dañar el trabajo actual del marco lo menos posible.


Lo traeré por completo
 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 

Lo más importante es que aquí recurrimos a ApplicationRecord.custom_returning_columns para descubrir qué columnas además de id son interesantes para nosotros. Y este método se parece a esto:


  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 



En lugar de conclusiones, podemos decir que el dolor de cabeza de Rails se ha vuelto un poco menos doloroso. counter_cache procesos de counter_cache como counter_cache y touch se hundirán en el olvido, y en el siguiente artículo podemos pensar en algo más global, como eliminar espacios colgantes, validación de datos, eliminación de datos en cascada o eliminación paranoica. Si te gustó este artículo, por supuesto.

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


All Articles