Hi everybody!
I am creating small Time Tracking app (similar to Toggle or Ronin).
I am struggling with a validation of an entity (TimeEntry
) which behavior depends on another associated entity (Activity
). To be more specific, TimeEntry
‘s validations depend on Activity
properties’ values. For example, the user has to fill the description
property of a TimeEntry
only if associated Activity
entity has description_required
property set to true
.
You can see how I dealt with the problem in the attached code. There are a lot of custom predicates, and each one of them accesses the ActivityReporsitory
instance to read the value of certain Activity
property (so that it can be used inside a rule below). I do not like this solution: first, it’s not DRY. Second, it calls the database 5 times while it could call it only once.
The question is: Is there a better way to write validations that depend on an associated entity? How to make this code more DRY?
I know that with dry-validations
I could for example fetch activity_id
from the input params before validation, load the Activity
entity from the database, and then inject activity
object as an external dependency (like this: scheme.with(activity: activity).call(input)
). This way I wouldn’t have to access any repository inside the schema.
But First of all, I cannot find similar feature (injecting external dependencies) in Hanami::Validations. Second of all, it does not look like a good practice to “extract” certain params from the input before validation. After all, the extracted parameter would also have to be validated somewhere.
Here’s the code:
class CreateTimeEntry
include Hanami::Validations::Form
predicate :project_required? do |activity_id|
ActivityRepository.new.find(activity_id).project_required
end
predicate :project_not_required? do |activity_id|
! ActivityRepository.new.find(activity_id).project_required
end
predicate :all_day_entry? do |activity_id|
ActivityRepository.new.find(activity_id).is_all_day
end
predicate :hourly_entry? do |activity_id|
! ActivityRepository.new.find(activity_id).is_all_day
end
predicate :description_required? do |activity_id|
ActivityRepository.new.find(activity_id).description_required
end
validations do
required(:activity_id).filled(:int?)
optional(:project_id).filled(:int?)
optional(:hours).maybe(:decimal?)
optional(:description).maybe(:str?)
rule(project_presence: [:activity_id, :project_id]) do |activity_id, project_id|
(activity_id.project_required? > project_id.filled?) &
(activity_id.project_not_required? > project_id.none?)
end
rule(hours_presence: [:activity_id, :hours]) do |activity_id, hours|
(activity_id.all_day_entry? > hours.none?) &
(activity_id.hourly_entry? > hours.filled?)
end
rule(description_presence: [:activity_id, :description]) do |activity_id, description|
activity_id.description_required? > description.filled?
end
end
end
class TimeEntry
attr_accessor :project_id, :hours, :description
end
class Activity # 'Work', 'R&D', 'Paid Leave', 'Unpaid Leave'
attr_accessor :name, :project_required, :description_required, :is_all_day
end
class TimeEntryRepository
associations do
belongs_to :project
belongs_to :activity
end
end