{"record":{"id":"5e0d603065d01b3b","repo":"we-promise/sure","slug":"replacement-tag-cannot-be-the-same-as-the-tag-bein","errorCode":null,"errorMessage":"Replacement tag cannot be the same as the tag being destroyed","messagePattern":"Replacement tag cannot be the same as the tag being destroyed","errorType":"validation","errorClass":"ActiveRecord::RecordInvalid","httpStatus":422,"severity":"error","filePath":"app/models/tag.rb","lineNumber":18,"sourceCode":"class Tag < ApplicationRecord\n  belongs_to :family\n  has_many :taggings, dependent: :destroy\n  has_many :transactions, through: :taggings, source: :taggable, source_type: \"Transaction\"\n  has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: \"Import::Mapping\"\n\n  validates :name, presence: true, uniqueness: { scope: :family }\n  validates :color, format: { with: /\\A#[0-9A-Fa-f]{6}\\z/ }, allow_nil: true\n\n  scope :alphabetically, -> { order(:name, :id) }\n\n  COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]\n\n  UNCATEGORIZED_COLOR = \"#737373\"\n\n  def replace_and_destroy!(replacement)\n    transaction do\n      raise ActiveRecord::RecordInvalid, \"Replacement tag cannot be the same as the tag being destroyed\" if replacement == self\n\n      if replacement\n        taggings.update_all tag_id: replacement.id\n      end\n\n      destroy!\n    end\n  end\nend\n","sourceCodeStart":1,"sourceCodeEnd":28,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/tag.rb#L1-L28","documentation":"Raised as ActiveRecord::RecordInvalid by Tag#replace_and_destroy! (app/models/tag.rb:18) when the replacement argument is the very same Tag instance being destroyed. The method exists to move all taggings onto another tag before destroying this one; passing self would make the update_all point taggings at a record that is about to be deleted, silently orphaning every transaction tag. The guard runs inside a transaction, so nothing is modified when it fires.","triggerScenarios":"Calling tag.replace_and_destroy!(tag) directly, or UI/controller code that resolves a 'replacement tag' select-box and passes back the original tag (e.g. the form defaults to the tag being deleted, or an ID mix-up sends params[:id] as params[:replacement_id]). Also fires when both resolve to the same record via find(params[:replacement_id]) with the same id value.","commonSituations":"A tag-deletion dialog where the user picks the tag being deleted as its own replacement; frontend that echoes the destroyed tag's id back as the replacement; test fixtures that reuse the same tag object for both arguments.","solutions":["Fix the caller: resolve the replacement from params[:replacement_id] (not params[:id]) and verify it differs from the destroyed tag's id before calling replace_and_destroy!.","In the UI, filter the tag being destroyed out of the replacement-tag picker so the state is unreachable.","If destroying without merging is intended, pass nil: tag.replace_and_destroy!(nil) destroys the tag and its taggings.","If you hit this in a controller, render a 422 with errors.full_messages instead of letting RecordInvalid bubble up as a 500."],"exampleFix":"// before\ntag.replace_and_destroy!(replacement) # replacement == tag -> RecordInvalid\n\n// after\nreplacement = family.tags.find(params[:replacement_id]) if params[:replacement_id].present?\nif replacement&.id == tag.id\n  return render json: { error: 'Replacement tag must be different' }, status: :unprocessable_entity\nend\ntag.replace_and_destroy!(replacement)","handlingStrategy":"validation","validationCode":"def valid_replacement?(tag, replacement)\n  replacement.nil? || replacement.id != tag.id\nend\n\ntag.replace_and_destroy!(replacement) if valid_replacement?(tag, replacement)","typeGuard":"replacement.nil? || (replacement.is_a?(Tag) && replacement.family_id == tag.family_id && replacement.id != tag.id)","tryCatchPattern":"begin\n  tag.replace_and_destroy!(replacement)\nrescue ActiveRecord::RecordInvalid => e\n  # errors[:base] carries the message; surface as 422\n  render json: { error: e.message }, status: :unprocessable_entity\nend","preventionTips":["Exclude the tag being destroyed from the replacement picker in the UI.","Always resolve the replacement by a separate param (replacement_id), never reuse params[:id].","Pass nil explicitly when no merge is intended — nil is a supported 'just destroy' path."],"tags":["rails","activerecord","tags","validation","self-reference"],"backgroundTag":"argument-validation-failed","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}