How to create custom predicates in Trailblazer with form object?

Ihor Shkidchenko
Nov 7 · 2 min read

I’d like to share how to avoid huge code when you have project with Trailblazer and many repeating validations for different models.

For example, you have validation for uniqueness for several models, in our case it could be Category and Book, this is how my code looks before refactoring:

I have Base class and Contract for Book and Category

# /app/concepts/lib/contract/base.rbmodule Lib
module Contract
class Base < Reform::Form
feature Reform::Form::Dry
end
end
end
# /app/concepts/books/contract/update.rbclass Book::Contract::Update < Lib::Contract::Base property :title validation with: { form: true } do
configure do
option :form
def case_insensitive_unique?(title)
!Book.where.not(id: form.model.id).exists?(['LOWER(title) = ?', title.downcase])
end
end
end
required(:title).filled rule(title: [:title]) do |title|
title.filled? > title.case_insensitive_unique?
end
end
# /app/concepts/categories/contract/update.rbclass Category::Contract::Update < Lib::Contract::Base property :name validation with: { form: true } do
configure do
option :form
def case_insensitive_unique?(name)
!Category.where.not(id: form.model.id).exists?(['LOWER(name) = ?', name.downcase])
end
end
end
required(:name).filled rule(name: [:name]) do |name|
name.filled? > name.case_insensitive_unique?
end
end

As we can see validations are almost the same, the only difference is on which model we check uniqueness. It’s obvious that we need to move this predicate method to some class and share it for all Contracts.

If we check dry-validation docs about custom predicates we can see this example

module MyPredicates
include Dry::Logic::Predicates

predicate(:email?) do |value|
!/magical-regex-that-matches-emails/.match(value).nil?
end
end

schema = Dry::Validation.Schema do
configure do
predicates(MyPredicates)
end

required(:email).filled(:str?, :email?)
end

but in our case for uniqueness validation we need form object to get the model id , I didn’t find how to get form object, so I refactored my code in such way:

Create module when we define our custom predicate method

# /app/concepts/lib/contract/custom_predicates.rbmodule Lib
module Contract
module CustomPredicates
include Dry::Logic::Predicates
predicate(:case_insensitive_unique?) do |form, attribute, value|
!form.model.class.where.not(id: form.model.id).exists?(["LOWER(#{attribute}) = ?", value.downcase])
end
end
end
end

and rewrite our Contracts

# /app/concepts/books/contract/update.rbclass Books::Contract::Update < Lib::Contract::Base
property :title
validation do
configure do
predicates(Lib::Contract::CustomPredicates)
end
required(:title).filled rule(title: [:title]) do |title|
title.filled? > title.case_insensitive_unique?(form, :title)
end
end
end
# /app/concepts/categories/contract/update.rbclass Categories::Contract::Update < Lib::Contract::Base
property :name
validation do
configure do
predicates(Lib::Contract::CustomPredicates)
end
required(:name).filled rule(name: [:name]) do |name|
name.filled? > name.case_insensitive_unique?(form, :name)
end
end
end

It seems like nothing complicated, but when I ran into such problem I didn’t find any examples or information how to paste form object to custom predicate method. So, the fact that formis available in rule was found by chance.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade