Entity validations that depend on a different associated entity [Hanami::Validations]


#1

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 :slight_smile: 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

#2

Hi @karol,
The idea of validators is to check formal validation of inputs. That means, you many only want to check if an information was provided (eg activity_id) and if it follows some rules (eg. is it an integer? is it greater than x?).

If the input passes the formal validations, at this point you’re sure that data is safe to work with, and you can verify it it satisfies your business rules (eg. all day entry?).


My suggestion is to:

  1. Separate validations from rules (remove the rules from the validator)
  2. Use the validator as first step in the workflow
  3. If data is valid, check business rules
# 1
class CreateTimeEntry
  include Hanami::Validations::Form
    
  validations do
    required(:activity_id).filled(:int?)

    optional(:project_id).filled(:int?)
    optional(:hours).maybe(:decimal?)
    optional(:description).maybe(:str?)
  end
end

# 2
result = CreateTimeEntry.new(input).validate
return unless result.success?

# 3
# manually add errors if business rules aren't satisfied.

#3

Hi @jodosha!

Thanks for your suggestion. Your clarification about validators being for formal validation of inputs was really eye-opening. Now I see what I was doing wrong all the time :slight_smile:

Thanks again!
Karol