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

Replacement tag cannot be the same as the tag being…

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.

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.

Appendix: 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)