Rails - Better approach when appending values to a JsonB field

Viewed 57

I'm working on populating a jsonB field whenever a Candidate record is entered/moved to a new entrypoint.

I'm currently using the ff. approach:

(They can be called multiple times and will just append the new entry point to the jsonB field [entry])

// Method in Candidate model
def log_entry(value)
  self.entry = Array(self.entry) << { val: value, timestamp: Time.now.to_i }
end

def log_entry!(value)
  self.log_entry(value)
  self.save
end

Called on every controller entrypoint

ca = Candidate.new(name: 'John Doe')
ca.log_entry('Point 1')
ca.save

Could also be called like this when moved to a new entry point

ca = Candidate.find id
ca.log_entry!('Point 2')

The record will look like this after calling log_entry twice: Candidate Model

Name: 'John Doe'
Entry: [{ val: 'Point 1', timestamp: '<time>' }, { val: 'Point 2', timestamp: '<time>' }]

So far, the above approach is achieving the desired result. But I have an impression that the code is too cluttered and that there's a better approach for this.

Any ideas will be appreciated.

1 Answers

This probably seems/feels cluttered simply because this is breaking single responsibility principle. You don’t give the context on why you’re keeping what seems to be an access log for each of these Candidate objects, but when you think about it, it should not be the Candidates responsibility to record why and when it was accessed. By coupling these together in this way, whenever two callers to the controller entry point happen at the same time, you will either get a StaleObjectError or simply drop one of the log entries.

Also by the way this is currently coded, to have the entry log recorded, you have to manually call a separate method. This makes it so that anyone that later adds a Candidate.find(x) call somewhere, would need to also call the log_entry method after retrieving the Candidate.

What I would suggest is two things:

  1. Separate this access log storage to it’s own object that it’s a running log so that it can easily be updated by multiple sources at the same time. This would be achievable through breaking the log_entry content storage to it’s own object, and linking it to the Candidate object through a has_many relationship.

  2. Creating a new concern that can by mixed into any models that need this access logging, which overrides any accessor methods like find or find_by so that it inserts a new log_entry before returning the found Candidate. If you want to get elaborate with this, you can use a decorator pattern to allow access logging to easily be attached to any existing method definition through using a decorator to modify the inner workings of the method.

If you need the coupling:

ince you need the coupling, then I would suggest still breaking the logic out into a concern so that you can decorate the appropriate methods that need to update these log entries. And utilize Optimistic Locking and some retry logic on updates to make sure data is not lost during concurrent requests. This will remove the clunkiness of multiple knowledge, confine it to one place, and remove the case of accidental data loss.

Some rough code (just the find method modification) would look something like the following:

# — Candidate Class —
def find(id, reason=“”)
  super(id).tap do |candidate|
    Array(candidate.log_entry) << { ... }
    candidate.save
  rescue ActiveRecord::StaleObjectError => ex
    # do some logging here
    retry
  end
end

The above modification of the find method will find the object requested, record the log entry on it, retry recording in the case of a stale object error, and return the object found. This way there is no need for duplicated code anywhere else in your Rails application.

Related