Home > Software design >  Correct way to add virtual attributes to a rails active_record model
Correct way to add virtual attributes to a rails active_record model

Time:12-01

I want to add virtual fields / attributes to a rails model and have it included in the attributes that get returned by default when I call @my_model

Assuming I have a model called Booking that has a start_at and end_at that are both datetime fields.

@booking = Booking.new(start_at: DateTime.now, end_at: DateTime.now   4.hours)

When I print the booking or say render it as json etc I get the following:

p @booking

# Booking
id => 1,
start_at => Wed, 30 Nov 2022 10:50:20  0800,
end_at => Wed, 30 Nov 2022 14:50:22  0800

All standard so far.

Now I want to add virtual attributes called start_date, start_time, end_date, end_time that will be derived from the start_at and end_at fields

So I add them as attributes to the class:

class Booking < ApplicationRecord
  attr_accessor :start_date, :start_time, :end_date, :end_time 

  def start_time
    self.start_at.strftime('%H:%M')
  end

  ...etc

end

so now I print the model again:


@booking

# Booking
id => 1,
start_at => Wed, 30 Nov 2022 10:50:20  0800,
end_at => Wed, 30 Nov 2022 14:50:22  0800

The virtual attributes are not shown.

However if I add them to the attributes method I can call @booking.attributes:


class Booking < ApplicationRecord

attr_accessor :start_date, :start_time, :end_date, :end_time 

  def start_time
    self.start_at.strftime('%H:%M')
  end


  def attributes
    super.merge({'start_time' => start_ime})
  end

end

@booking.attributes

# Booking
id => 1,
start_at => Wed, 30 Nov 2022 10:50:20  0800,
end_at => Wed, 30 Nov 2022 14:50:22  0800
start_time => '10:50'
...etc

So my question is how can i modify what attributes get returned by default, so I don't have to call .attributes on the model every time? I would like to just do render json: @booking and have all attributes (including the virtual ones) included.

Thanks in advance

CodePudding user response:

You can do it by overriding the as_json method for that model:

class Booking < ApplicationRecord
  def as_json(*)
    super(methods: %i(start_date start_time end_date end_time))
  end
end

CodePudding user response:

You're falling victim to a very common Ruby beginner misconception. attr_accessor doesn't actually declare attributes because Ruby doesn't actually have declared attributes/properties like for example Java does.

Ruby just has instance variables and all that attr_accessor :start_date does is to use metaprogramming to create start_date and start_date= getter and setter methods that expose the instance variable to the outside.

That doesn't actually mean that Ruby is keeping track that the Booking class has a start_date property - the instance variable doesn't even exist until you set it and the setter/getter are just plain old methods. Sometimes these are lazily refered to as "virtual attributes" but its actually far from the truth.

Ruby does however provide the basic building blocks needed to build any kind of attribute system you want. Rails has ActiveModel::Attributes which is the base API and ActiveRecord::Attributes which is specialized implementation for database table backed models.

Attibutes are a Rails constuct - not a part of the language.

class Booking < ApplicationRecord
  attribute :start_at, type: :datetime
end
irb(main):005:0> Booking.new.as_json
=> {"id"=>nil, "created_at"=>nil, "updated_at"=>nil, "start_at"=>nil}    

Unlike attr_accessor this not just creates setters and getters but it also updates a class instance variable which keeps track of the attributes of the class. That's how Rails knows to include "start_at" when you call to_json.

If you want to add a method which is not an attribute when rendering JSON use the methods: option:

class Booking < ApplicationRecord
  attribute :start_at, type: :datetime

  def start_time
    start_at.strftime('%H:%M')
  end
end
irb(main):013:0> Booking.new(start_at: Time.now).as_json(methods: [:start_time])
=> {"id"=>nil, "created_at"=>nil, "updated_at"=>nil, "start_at"=>"2022-11-30T16:12:02.188 01:00", "start_time"=>"16:12"}
render json: @booking, methods: [:start_at]

But, overriding as_json in model or doing complex JSON rendering in the controller are both known anti-patterns. Its often a good idea to move JSON serialization into its own layer.

  • Related