2

My Transaction model generates an array of unique ticket numbers based on quantity (an integer column) with the following controller logic:

@transaction.quantity.times.uniq { @transaction.ticket_numbers << rand(100000..999999) }

However, this only ensures that the numbers WITHIN the array are unique.

I need a database validation that checks ALL Transaction.ticket_numbers arrays to ensure that every value (ticket number) is unique across ALL arrays.

Here is my Transaction table within schema.rb:

  create_table "transactions", force: :cascade do |t|
    t.string "payee"
    t.integer "quantity"
    t.decimal "debt", precision: 8, scale: 2
    t.string "email"
    t.string "ministry"
    t.integer "status"
    t.integer "user_id"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.integer "ticket_numbers", default: [], array: true
  end

Transaction model from transaction.rb:

require 'csv'

class Transaction < ApplicationRecord
  belongs_to :user

  validates :email, :format => { :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/, :on => [:create, :update] }
  validates :payee, presence: true
  validates :quantity, numericality: { greater_than_or_equal_to: 1 }
  validates :debt, numericality: { greater_than_or_equal_to: 0 }

  def self.to_csv
    attributes = %w{payee email ministry quantity debt status ticket_numbers}
    CSV.generate(headers: true) do |csv|
      csv << attributes
      all.each do |transaction|
        csv << attributes.map{ |attr| transaction.send(attr) }
      end
    end
  end
end

Transaction controller:

class TransactionsController < ApplicationController
  before_action :load_transaction, only: [:edit, :update, :destroy]

  def create
    @transaction = Transaction.new(transaction_params)
    if @transaction.save
      @transaction.quantity.times.uniq { @transaction.ticket_numbers << rand(100000..999999) }
    end
    @user = @transaction.user
    @transactions = @user.transactions
    respond_to do |format|
      if @transaction.save && @transaction.status == 1
        UserMailer.payment_confirmation(@transaction).deliver_later
        format.html { redirect_to user_url(@user), notice:'Transaction added & ticket sent' }
        format.json { render json: @user, status: :created, location: @user }
      elsif @transaction.save && @transaction.status != 1
        format.html { redirect_to user_url(@user), notice:'Transaction added' }
        format.json { render json: @user, status: :created, location: @user }
      else
        format.html { render 'users/show' }
        format.json { render json: @transaction.errors, status: :unprocessable_entity }
      end
    end
  end

  def edit
    @user = current_user
  end

  def update
    update_ticket_numbers
    respond_to do |format|
      if @transaction.update_attributes(transaction_params) && @transaction.status == 1
        UserMailer.payment_confirmation(@transaction).deliver_later
        format.html { redirect_to user_url(current_user), notice: 'Transaction info updated & confirmation email sent to payee' }
        format.json { render json: current_user, status: :created, location: current_user }
      elsif @transaction.update_attributes(transaction_params) && @transaction.status != 1
        format.html { redirect_to user_url(current_user), notice: 'Transaction info updated' }
        format.json { render json: current_user, status: :created, location: current_user }
      else
        format.html { render :edit }
        format.json { render json: @transaction.errors, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @transaction.destroy
    redirect_to user_url(current_user), notice: 'Transaction deleted'
  end

  private

  def load_transaction
    @transaction = Transaction.find(params[:id])
  end

  def transaction_params
    params.require(:transaction).permit(:payee, :email, :ministry, :debt,
    :quantity, :status, :user_id, :ticket_numbers)
  end

  def update_ticket_numbers
    if @transaction.update_attributes(transaction_params)
      if @transaction.ticket_numbers.length < @transaction.quantity
        i = @transaction.quantity - @transaction.ticket_numbers.length
        i.times.uniq { @transaction.ticket_numbers << rand(100000..999999) }
      elsif @transaction.ticket_numbers.length > @transaction.quantity
        i = @transaction.ticket_numbers.length - @transaction.quantity
        i.times { @transaction.ticket_numbers.pop }
      end
    end
  end

end

7
  • 2
    Would it be easier to work with ticket numbers if they were in their own separate model/table instead of an array? Commented Aug 16, 2017 at 20:03
  • I can see what you're getting at, but if I use a separate model it would be purely for generating ticket numbers which can be added & removed when Transaction.quantity is edited. I also use ActionMailer to automate emails when transactions are marked complete on the form. Wouldn't a ticket number model be excessive for this? Thanks! Commented Aug 16, 2017 at 21:12
  • I'm no expert but can't you try intersections of all the arrays and check if the intersection is empty to find uniqueness? Commented Aug 16, 2017 at 21:19
  • 1
    @D_Cheeles I think Brad's suggestion will ultimately be less excessive than the path you are currently on. Databases are good at unique constraints. You should take advantage of that if you can. Commented Aug 17, 2017 at 0:25
  • 3
    @DCheeles, there's a joke that goes "Boss, I wrote a program that does the wrong thing but does it really fast". Make it right first, worry about performance later when it becomes an issue. Like other have said, I think you should create a separate model for the Ticket and implement the constraint with a unique index. Commented Aug 17, 2017 at 4:57

1 Answer 1

1

You can add a custom ticket number validation to your model. You can read more about them here

class Transaction < ApplicationRecord
  validate :ticket_number_uniqueness

  def ticket_number_uniqueness
    # First check all new ticket numbers are unique from each other
    errors.add(:ticket_number, "is not unique") unless ticket_numbers == ticket_numbers.uniq
    
    # Next check against other ticket numbers
    ticket_numbers.each do |ticket|
      if ::Transaction.where("ticket_numbers @> '{?}'", ticket).exists?
        errors.add(:ticket_number, "is not unique")
      end
    end
  end
end

Note: this is taking advantage of Postgres contains array method @>. You can learn more about array methods here

Sign up to request clarification or add additional context in comments.

5 Comments

This looks like this should work without a separate Ticket model. One thing I would change is the ticket_numbers == ticket_numbers.uniq comparison. There is no guarantee that the order will be consistent against the original after the call to uniq. Sorting should be applied or alternatively a set comparison should be made instead.
What makes you think the order isn't guaranteed? apidock.com/ruby/Array/uniq. I would suggest a simple length comparison ticket_numbers.size == ticket_numbers.uniq.size as an alternative, which may give a performance improvement as well, but wanted to keep things simple in my answer.
Strictly speaking, the order is not guaranteed. The documentation there says nothing about the order of the returned array. Practically speaking, you're probably right though.
why do you use double colon operator before Transaction model?
Habit ¯_(ツ)_/¯. It's not necessary, but it is explicit.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.