Premièrement, l'article n'est pas sur la façon dont j'aime les rails, et deuxièmement, l'article n'est pas sur la façon dont je les déteste. Ils peuvent être traités de manières complètement différentes et ils ne s'amélioreront que si vous les changez. Et ils ne peuvent empirer que s'ils commencent à changer. Eh bien, en général, je vous ai prévenu, mais vous m'avez compris.
L'un des principaux concepts d'ActiveRecord est que la base de données est assez utilitaire et peut être modifiée. Eh bien, vous êtes assis là, en train d'écrire vos modèles en utilisant MySQL, et soudain vous lisez quelque part que vous pouvez prendre MySQL à la place et le remplacer par MongoDB. Eh bien, pas si radical, mais, disons, sur PostgreSQL, vous pourriez avoir des raisons de remplacer MySQL. Ou vice versa, je n'ai rien contre MySQL. Ici, ActiveRecord prétend vous faciliter la tâche, soi-disant étendues, les filtres avant / après et les associations sont suffisamment abstraits pour ne pas vous soucier de générer des requêtes de base de données et de prendre soin de la logique de l'application. Au lieu de WHERE is_archived = TRUE
vous êtes heureux d'écrire where(is_archived: true)
et ActiveRecord fera tout pour vous. Tous les exemples seront traduits pour PostgreSQL, pas pour MySQL, donc les utilisateurs de MySQL devront inventer leur propre vélo.

Mais peu importe comment! En pratique, il s'avère que cette couche d'abstraction est complètement remplie de trous, comme un creux de l'histoire du poisson d'or. Et que de nombreuses fonctionnalités de base ne peuvent pas être utilisées, telles que la comparaison de dates ou l'utilisation de tableaux. Et vous obtenez des étendues avec forcé where("#{quoted_table_name}.finished_at >= ?", Date.current)
ou where("#{quoted_table_name}.other_ids <@ ARRAY[?]", ids)
. À quoi ActiveRecord donne une réponse complètement consciente et logique: ne l'utilisez pas. Au lieu de tableaux, utilisez la connexion habtm, et si vous avez besoin de comparer des dates, vivez avec. Oui, et Dieu vous interdit de manquer quoted_table_name
dans une telle portée - le premier includes
ou joins
mettra tout à sa place. Il est plus facile partout et toujours d'écrire, pour ne pas renverser la main.
Eh bien, bien sûr, dès que vous décidez d'interférer avec ActiveRecord ici, il n'y aura plus de retour en arrière. Non pas qu'il n'y ait aucune chance, même un espoir fantomatique pour une transition sans douleur vers une autre base de données. Il sera beaucoup plus facile d'imprimer et de graver ce code source. Et bien sûr, il n'y a aucune autre raison de ne pas utiliser les fonctionnalités de base de données supplémentaires dans votre application. Utilisez sur la santé et forcez les autres!
Et lorsqu'il s'avère que vos étendues dans le dossier des modèles comprennent plus de la moitié de ces fonctionnalités supplémentaires, il est évident qu'ActiveRecord n'est qu'un shell pratique pour intégrer un morceau de code avec des étiquettes avec un autre morceau de code. Et les étendues, comme where(is_archived: true).joins(:sprint).merge(Sprint.archived)
, fonctionneront bien et les combiner ne sera pas plus difficile que de faire des œufs brouillés, non?

La prochaine étape sera la dénormalisation. Non, la dénormalisation, pour ainsi dire, n'a toujours pas disparu, mais le soin en incombait aux puissantes épaules de Rails et d'ActiveRecord, et vous savez que ces deux gars ne différaient pas en rapidité et en ascétisme des besoins en ressources. Disons counter_cache: true
est la première étape vers la dénormalisation, car COUNT(*) AS sprints_count
ne vous permettra pas de faire ActiveRecord (vous ne voulez pas changer select()
, non?). Et counter_cache
pas loin d'être parfait, et dans certains cas, il peut y avoir une désynchronisation du montant réel de celui mis en cache. Non critique, bien sûr, mais désagréable. Et ce n'est que le premier candidat à s'installer dans la base de données et à ne pas charger la tête déjà chargée de la machine à rubis. Juste quelques déclencheurs et vous avez terminé! Tout d'abord, lors de la suppression et de l'ajout d'un nouvel enregistrement à la plaque A, vous devez compter le nombre d'enregistrements dans la plaque B et c'est tout, non? Eh bien, lors de l'édition, bien sûr, si foreign_key
changé, car la UPDATE B SET a_id = $1 WHERE id = $2
cassera counter_cache pour l'ancien A et le nouveau.
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;
Le prochain chemin d'accès à la base de données sera lié à la date-heure. Et pour commencer, servons simplement les champs created_at
et updated_at
dans la base de données, heureusement, c'est beaucoup plus simple. Paramètres par défaut du premier ensemble:
change_column_default :table_name, :created_at, -> { 'CURRENT_TIMESTAMP' } change_column_default :table_name, :updated_at, -> { 'CURRENT_TIMESTAMP' }
Et pour le faire immédiatement partout, vous pouvez organiser un cycle pour toutes les plaques où se trouvent ces champs. Outre les ar_internal_metadata
schema_migrations
et ar_internal_metadata
, bien sûr:
(tables -
Voilà, maintenant la valeur par défaut de ces tables sera exactement ce dont nous avons besoin. Et il est maintenant temps de s'assurer que les rails ne touchent pas ces champs. Cela se fait avec deux boulons aux bons endroits. Et oui, il y a une option dans la mise en place du cadre qui en est responsable:
Rails.application.config.active_record.record_timestamps = false
Par conséquent, l'étape suivante consiste à mettre à jour le champ updated_at
au moment où l'enregistrement est mis à jour. C'est 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;
Vous devez maintenant vous débarrasser complètement du touch: true
dans les modèles. Cette chose est très similaire à la cible dans le tiret - également complètement trouée. Et je n’expliquerai même pas pourquoi, car vous connaissez déjà tous ces cas. Ce n'est pas beaucoup plus compliqué, il vous suffit de mettre à jour update_at non seulement pour vous-même:
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;
Bien sûr, la chaîne d'appels de ces déclencheurs fera une action inutile, mais dans les postgres du mécanisme sain, les déclencheurs sont invoqués sans changer l'enregistrement lui-même. Vous pouvez essayer de faire SET title = title
, mais cela ne sort pas mieux que SET updated_at = CURRENT_TIMESTAMP
.
Exactement le même déclencheur sera sur l'insert, seule la mise à jour updated_at
pas nécessaire:
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;
Bien sûr, vous pouvez essayer d'écrire cela avec une seule fonction, en ajoutant une vérification de l'événement actuel juste à l'intérieur du déclencheur, similaire à IF TG_OP = 'UPDATE' THEN
, mais il est préférable de rendre tous les déclencheurs aussi simples que possible pour réduire la probabilité d'une erreur.
Vous voudrez peut-être en quelque sorte automatiser la génération de ces déclencheurs, puis vous devrez probablement trouver toutes les relations étrangères entre la table actuelle et les autres. Vous pouvez facilement le faire avec cette requête:
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;
Un autre conseil très utile. Appelez les déclencheurs de la même manière pour pouvoir vérifier la présence ou l'absence de ce qui est requis par une demande, par exemple, cette demande trouvera tous les déclencheurs à insertion tactile:
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';
Et enfin, le pire reste. Le fait est que les rails ne sont pas conçus pour au moins une sorte de base de données intelligente, et ils se moquaient vraiment du fait qu'au moins quelque chose d'autre que le champ id
pouvait changer dans la base de données, et cela uniquement lorsqu'ils étaient insérés. Par conséquent, il n'y a pas de mécanisme sain pour ajouter RETURNING updated_at
pour mettre à jour les demandes, il n'y en a pas, vous devrez plonger à l'intérieur de Rails jusqu'aux oreilles.

Mankipatch s'est avéré ne pas être très soigné, mais tout d'abord l'objectif était d'endommager le moins possible le travail actuel du cadre.
Je vais l'amener complètement 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
La chose la plus importante est qu'ici nous nous tournons vers ApplicationRecord.custom_returning_columns
pour découvrir quelles colonnes en plus d'id sont intéressantes pour nous. Et cette méthode ressemble à ceci:
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
Au lieu de conclusions, nous pouvons dire que la tête douloureuse de Rails est devenue un peu moins douloureuse. counter_cache
processus de counter_cache
tels que counter_cache
et touch
counter_cache
dans l'oubli, et dans le prochain article, nous pouvons penser à quelque chose de plus global, comme la suppression des espaces suspendus, la validation des données, la suppression des données en cascade ou la suppression paranoïaque. Si vous avez aimé cet article, bien sûr.