Rails uniqueness validation fails for no reason

74 views Asked by At

I have an scope-based uniqueness validation in place: validates :company_standard, uniqueness: { scope: :company_id } on a Rails Model X.

On the attached screenshot I am trying to create a new object of class X for that company via a form (=the standard process), not having the company_standard field or any other boolean field activated.

Still, the saving fails with a uniqueness validation error on company_standard (see screenshot).

A separate object of class X for this company exists with the company_standard=true in the database (I double-checked the form on correct naming). Updating the existing record and setting the boolean field to false seems to also fail with the same validation error.

No clue why this is failing. Looks like unexpected behavior to me.

The validation error on the form

Update after comment from @mechnicov: Model

    class MeanOfPayment < ApplicationRecord
      belongs_to :company
      has_many :in_payments
      has_many :out_payments
      has_many :bank_statements, dependent: :destroy
    
      validates :company_standard, uniqueness: { scope: :company_id }
    
      before_validation :remove_spaces
    
      def balance
        ...
      end
    
      def account_name
        ...
      end
    
      private
    
      def remove_spaces
        ...
      end
end

Controller

class Home::MeanOfPaymentsController < HomeController
  def index
    @title = t('titles.mean_of_payments.index')
    @mean_of_payments = current_company.mean_of_payments
  end

  def show
    @title = t('titles.mean_of_payments.show')
    @mean_of_payment = current_company.mean_of_payments.find(params[:id])
  end

  def new
    @title = t('titles.mean_of_payments.new')
    company_id = Company.find(params[:company_id]).id
    @mean_of_payment = MeanOfPayment.new(company_id: company_id)
  end

  def create
    fix_number(mean_of_payment_params[:balance_at_date])
    @mean_of_payment = (mean_of_payment_params[:type].constantize).new(mean_of_payment_params)
    if @mean_of_payment.save
      redirect_to home_company_mean_of_payments_path(Company.find(params[:company_id])), notice: t('mean_of_payment.notifications.create_successfully')
    else
      render :new
    end
  end

  def edit
    @title = t('titles.mean_of_payments.edit')
    @mean_of_payment = MeanOfPayment.find(params[:id])
  end

  def update
    fix_number(mean_of_payment_params[:balance_at_date])
    @mean_of_payment = MeanOfPayment.find(params[:id])
    if @mean_of_payment.update(mean_of_payment_params)
      redirect_to home_company_mean_of_payments_path(Company.find(params[:company_id])), notice: t('mean_of_payment.notifications.update_successfully')
    else
      render :edit
    end
  end

  def destroy
    mean_of_payment = MeanOfPayment.find(params[:id])
    company_id = mean_of_payment.company.id
    mean_of_payment.update!(marked_deleted: true)
    redirect_to home_company_mean_of_payments_path(company_id: company_id), method: :destroy, notice: t('mean_of_payment.notifications.delete_successfully')
  end

  private

  def mean_of_payment_params
    validation =  if params[:bank_account].present?
                    params.require(:bank_account)
                  elsif params[:cash_counter].present?
                    params.require(:cash_counter)
                  elsif params[:mean_of_payment].present?
                    params.require(:mean_of_payment)
                  else
                    raise "Zahlungsmethode muss im Controller eingeführt werden!"
                  end

    validation.permit(
        :account_name,
        :account_number,
        :balance_at_date,
        :balance_date,
        :bank_name,
        :bank_number,
        :company_id,
        :company_standard,
        :for_out_invoice,
        :owner,
        :primary_debitable,
        :type,
    )

  end
end

View 1 - new

<div class="row">
  <div class="col-8 offset-2">
    <%= link_to t('common.buttons.back'), home_company_mean_of_payments_path %>
    <h1>
      <%= t('mean_of_payment.new.title') %>
    </h1>
    <%= render 'form', path: home_company_mean_of_payments_path(id: current_company.id) %>
  </div>
</div>

View 2 - form

<%= simple_form_for @mean_of_payment, url: path do |f| %>
  <%= f.hidden_field :company_id, value: current_user.accounting_company.id %>
  <div class="row">
    <div class="col-6">
      <div class="ibox float-e-margins">
        <div class="ibox-content">
          <% if @mean_of_payment.errors.any? %>
            <div id="error_explanation">
              <h4><%= t('activerecord.errors.models.mean_of_payment.prohibited_save', count: @mean_of_payment.errors.count) %></h4>
              <ul>
                <% @mean_of_payment.errors.full_messages.each do |message| %>
                  <li><%= message %></li>
                <% end %>
              </ul>
            </div>
          <% end %>
          <div class="form-group">
            <%= f.label :type %>
            <%= f.select :type, options_for_mean_of_payment_general, class: 'form-control' %>
          </div>
          <div class="form-group">
            <%= f.label :bank_name %>
            <%= f.text_field :bank_name, class: 'form-control w-50' %>
          </div>
          <div class="form-group">
            <%= f.label :bank_number %>
            <%= f.text_field :bank_number, class: 'form-control w-50' %>
          </div>
          <div class="form-group">
            <%= f.label :account_name %>
            <%= f.text_field :account_name, class: 'form-control w-50'%>
          </div>
          <div class="form-group">
            <%= f.label :account_number %>
            <%= f.text_field :account_number, class: 'form-control w-50' %>
          </div>
          <div class="form-group">
            <%= f.label :owner %>
            <%= f.text_field :owner, class: 'form-control w-50' %>
          </div>
          <div class="form-group">
            <%= f.label :balance_at_date %>
            <%= f.text_field :balance_at_date, class: 'form-control w-50' %>
          </div>
          <div class="form-group">
            <%= f.label :balance_date %>
            <%= f.input :balance_date, as: :date, html5: true, class: 'form-control w-50', label: false %>
          </div>
          <div class="form-group">
            <%= f.label :company_standard %>
            <%= f.check_box :company_standard, class: 'form-control w-50' %>
          </div>
          <div class="form-group">
            <%= f.label :for_out_invoice %>
            <%= f.check_box :for_out_invoice, class: 'form-control w-50' %>
          </div>
          <div class="form-group">
            <%= f.label :primary_debitable %>
            <%= f.check_box :primary_debitable, class: 'form-control w-50' %>
          </div>
          <div class="form-group">
            <%= f.submit t('common.buttons.create_or_modify'), class: 'btn btn-primary' %>
          </div>
        </div>
      </div>
    </div>
  </div>
<% end %>
3

There are 3 answers

6
smathy On

Updating the existing record and setting the boolean field to false seems to also fail with the same validation error.

This strongly implies that your DB contains another record for that company_id with company_standard=false. Please check this from a rails console with something like:

> MeanOfPayment.where company_id: the_company_id

Please show this by copy/pasting those console commands and responses to the question. If there's only one record then the next obvious suspect here is your remove_spaces call, please add the code for that to your question.

1
Alex On

You're setting company_id from params[:company_id]

company_id = Company.find(params[:company_id]).id
@mean_of_payment = MeanOfPayment.new(company_id: company_id)

only to override it in your form

f.hidden_field :company_id, value: current_user.accounting_company.id

which I assume stays the same, regardless of params.

Also your form url seems a little suspect:

home_company_mean_of_payments_path(id: current_company.id)

Which gives you params[:id]. Doesn't that imply mean_of_payment.id for that controller? home_company_mean_of_payments - plural, so id seems out of place, you're creating you don't have an id yet.

Look at your logs and see what PARAMETERS your're submitting. Hard to tell what company_id you expect to have vs what gets submitted:

params[:id]
params[:company_id]
params[:mean_of_payment][:company_id] # this is what you're using for your model

current_user.accounting_company.id    # are these different from params[:company_id]
current_company.id                    #
0
Julian On

It seems I had a wrong understanding of an uniquness validator (took 2 days to find out...).

WRONG I thought it ensures an attribute is set only once to true (i.e. my thinking: 'uniquely').

CORRECT But it actually checks if the attribute is present in general. So false is also present in this case i.e. not nil (didnt double check the Rails code if it checks for nil though). Sounds stupid looking at it now :-).

So in my case

A company has_many mean_of_payments but just one with boolean attribute company_standard being true.

Uniqueness validation of course didnt work since other mean_of_payments had company_standard set to false.

I needed a validator which checks the max occurences to max 1. Didnt find something standard so I wrote my own custom validator which does the trick:

Custom validation using validates_with on the model

class MeanOfPayment < ApplicationRecord
  belongs_to :company
...
  validates_with UniqueCompanyStandardValidator, if: :company_standard
end

With custom validator class:

class UniqueCompanyStandardValidator < ActiveModel::Validator
  def validate(record)
    mops = MeanOfPayment.where(company_id: record.company_id, company_standard: true).pluck(:id) # looks in DB not at the current record
    if mops.length > 0 # DB shouldnt have a company_standard: true
      record.errors[:base] << I18n.t('activerecord.errors.models.mean_of_payment.attributes.company_standard.taken')
    end
  end
end

Summary

  1. The validator is only called if the attribute is set to true
  2. Checks DB if mean_of_payment.company_standard with Attribute true exist already

This whole thing occured to me today at 11 am after reading all your comments! So many thanks for this! Thanks for your indirect help! :-)