ruby-on-rails

Implement a derived attribute in Rails 7


I am trying to implement a derived field in Rails 7. I have a Tag model. A tag has a prefix, a loop number, and an optional suffix. (Yes, I'm an instrument engineer :-) I keep these attributes separately in the database, because it is required to sort and search on them individually, but most of the time the tag is referenced and displayed as the combination of these fields. I defined an attribute accessor, and a setter function in private, to join the fields, and I set the after_find helper to trigger the setter. But they don't seem to work. In the console, Tag.first.full_tag results in nil, and nothing is displayed in views.

I would have thought this requirement is relatively common, but haven't found anything useful in my searches. Of course, I could probably persist the derived attribute into the database, but that would contravene the basic rule of "one source of information". I would greatly appreciate if someone can point out where I am going wrong.

# tag.rb:
class Tag < ApplicationRecord
  belongs_to :project
  belongs_to :discipline, foreign_key: :discipline, primary_key: :code

  attr_reader :full_tag
  after_find :set_full_tag

...

 private

    def set_full_tag
      full_tag = prefix + "-" + loop.to_s.rjust(4, '0')
      if !suffix.empty?
        full_tag += "." + suffix
      end
    end
end

Solution

  • So, let's build an example here:

    Proposed Code:

    class Tag < ApplicationRecord
      # attr_accessor for the derived full_tag
      attr_accessor :full_tag
      
      # Set the full_tag after finding the record
      after_find :set_full_tag
    
      private
    
      def set_full_tag
        # Set the full_tag on the instance using self.full_tag
        self.full_tag = "#{prefix}-#{self.loop_number.to_s.rjust(4, '0')}"
        
        # Add suffix if it's present
        self.full_tag += ".#{suffix}" if suffix.present?
      end
    end
    

    The main issue (as I see it) with the original code is that it does not correctly set the full_tag value. To fix this, we first replace attr_reader with attr_accessor. Why? attr_reader only lets you read the value, while attr_accessor lets you both read and write it. As I understand it, you want to change the full_tag value after finding the record.

    Next, full_tag = ... creates a temporary variable, which doesn't store the combined tag in the model. We can use self.full_tag = ..., to tell Rails to use the setter method generated by attr_accessor, so that the full_tag value is properly updated and saved in the object.

    I hope this points you in the right direction.

    Test Framework:

    rails new tag_proj
    cd tag_proj
    rails generate model Tag prefix:string loop_number:integer suffix:string
    rails db:migrate
    
    echo "
    class Tag < ApplicationRecord
      attr_accessor :full_tag
      after_find :set_full_tag
    
      private
    
      def set_full_tag
        self.full_tag = \"#{prefix}-#{self.loop_number.to_s.rjust(4, '0')}\"
        self.full_tag += \".#{suffix}\" if suffix.present?
      end
    end
    " > app/models/tag.rb
    
    echo "
    Tag.create(prefix: 'XX', loop_number: 123, suffix: 'A')
    Tag.create(prefix: 'YY', loop_number: 45, suffix: 'B')
    Tag.create(prefix: 'ZZ', loop_number: 67, suffix: nil)
    " >> db/seeds.rb
    
    rails db:seed
    
    echo "
    # lib/tasks/tag_full_tag.rake
    namespace :tag do
      task full_tag: :environment do
        puts Tag.first.full_tag   
        puts Tag.second.full_tag
        puts Tag.third.full_tag
      end
    end
    " > lib/tasks/tag_full_tag.rake
    
    rake tag:full_tag
    

    Output

    > rake tag:full_tag
    XX-0123.A
    YY-0045.B
    ZZ-0067