we-promise/sure · error · ActiveRecord::RecordInvalid

Replacement tag cannot be the same as the tag being destroye

Error message

Replacement tag cannot be the same as the tag being destroyed

What it means

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.

Source

Thrown at app/models/tag.rb:18

class Tag < ApplicationRecord
  belongs_to :family
  has_many :taggings, dependent: :destroy
  has_many :transactions, through: :taggings, source: :taggable, source_type: "Transaction"
  has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"

  validates :name, presence: true, uniqueness: { scope: :family }
  validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }, allow_nil: true

  scope :alphabetically, -> { order(:name, :id) }

  COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]

  UNCATEGORIZED_COLOR = "#737373"

  def replace_and_destroy!(replacement)
    transaction do
      raise ActiveRecord::RecordInvalid, "Replacement tag cannot be the same as the tag being destroyed" if replacement == self

      if replacement
        taggings.update_all tag_id: replacement.id
      end

      destroy!
    end
  end
end

View on GitHub (pinned to e69894adb9)

Solutions

  1. 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!.
  2. In the UI, filter the tag being destroyed out of the replacement-tag picker so the state is unreachable.
  3. If destroying without merging is intended, pass nil: tag.replace_and_destroy!(nil) destroys the tag and its taggings.
  4. If you hit this in a controller, render a 422 with errors.full_messages instead of letting RecordInvalid bubble up as a 500.

Example fix

// before
tag.replace_and_destroy!(replacement) # replacement == tag -> RecordInvalid

// after
replacement = family.tags.find(params[:replacement_id]) if params[:replacement_id].present?
if replacement&.id == tag.id
  return render json: { error: 'Replacement tag must be different' }, status: :unprocessable_entity
end
tag.replace_and_destroy!(replacement)
Defensive patterns

Strategy: validation

Validate before calling

def valid_replacement?(tag, replacement)
  replacement.nil? || replacement.id != tag.id
end

tag.replace_and_destroy!(replacement) if valid_replacement?(tag, replacement)

Type guard

replacement.nil? || (replacement.is_a?(Tag) && replacement.family_id == tag.family_id && replacement.id != tag.id)

Try / catch

begin
  tag.replace_and_destroy!(replacement)
rescue ActiveRecord::RecordInvalid => e
  # errors[:base] carries the message; surface as 422
  render json: { error: e.message }, status: :unprocessable_entity
end

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21). Data as JSON: /api/errors/5e0d603065d01b3b. Report an issue: GitHub.