Attribute normalizer for Rails.
If you know the obvious format of an input, why not normalize it instead of raise an validation error to your use? Make the follow email  [email protected]  valid like [email protected] with no need to override acessors methods.
Add the following code on your Gemfile and run bundle install:
gem 'normalizy'So generates an initializer for future custom configurations:
rails g normalizy:installIt will generates a file config/initializers/normalizy.rb where you can configure you own normalizer and choose some defaults one.
On your model, just add normalizy callback with the attribute you want to normalize and the filter to be used:
class User < ApplicationRecord
  normalizy :name, with: :downcase
endNow some email like [email protected] will be saved as [email protected].
We have a couple of built-in filters.
Transform a value to date format.
normalizy :birthday, with: :date
'1984-10-23'
# Tue, 23 Oct 1984 00:00:00 UTC +00:00By default, the date is treat as %F format and as UTC time.
You can change the format using the format options:
normalizy :birthday, with: { date: { format: '%y/%m/%d' } }
'84/10/23'
# Tue, 23 Oct 1984 00:00:00 UTC +00:00To convert the date on your time zone, just provide the time_zone option:
normalizy :birthday, with: { date: { time_zone: Time.zone } }
'1984-10-23'
# Tue, 23 Oct 1984 00:00:00 EDT -04:00If an invalid date is provided, Normalizy will add an error on attribute of the related object. You can customize the error via I18n config:
en:
  normalizy:
    errors:
      date:
        user:
          birthday: '%{value} is an invalid date.'If no configuration is provided, the default message will be '%{value} is an invalid date..
If your model receive a Time or DateTime, you can provide adjust options to change you time to begin o the day:
normalizy :birthday, with: { date: { adjust: :begin } }
Tue, 23 Oct 1984 11:30:00 EDT -04:00
# Tue, 23 Oct 1984 00:00:00 EDT -04:00Or to the end of the day:
normalizy :birthday, with: { date: { adjust: :end } }
Tue, 23 Oct 1984 00:00:00 EDT -04:00
# Tue, 23 Oct 1984 11:59:59 EDT -04:00Transform a value to money format.
normalizy :amount, with: :money
'$ 42.00'
# '42.00'The separator will be keeped on value to be possible cast the right integer value.
You can change this separator:
normalizy :amount, with: { money: { separator: ',' } }
'R$ 42,00'
# '42,00'If you do not want pass it as options, Normalizy will fetch your I18n config:
en:
  number:
    currency:
      format:
        separator: '.'And if it does not exists, . will be used as default.
You can retrieve the value in cents format, use the type options as cents:
normalizy :amount, with: { money: { type: :cents } }
'$ 42.00'
# '4200'As you could see on the last example, when using type: :cents is important the number of decimal digits.
So, you can configure it to avoid the following error:
normalizy :amount, with: { money: { type: :cents } }
'$ 42.0'
# 420When you parse it back, the value need to be divided by 100 to be presented, but it will result in a value you do not want: 4.2 instead of the original 42.0. Just provide a precision:
normalizy :amount, with: { money: { precision: 2 } }
'$ 42.0'
# 42.00normalizy :amount, with: { money: { precision: 2, type: :cents } }
'$ 42.0'
# 4200If you do not want pass it as options, Normalizy will fetch your I18n config:
en:
  number:
    currency:
      format:
        precision: 2And if it does not exists, 2 will be used as default.
If you need get a number over a normalized string in a number style, provide cast option with desired cast method:
normalizy :amount, with: { money: { cast: :to_i } }
'$ 42.00'
# 4200Just pay attention to avoid to use type: :cents together cast with float parses.
Since type runs first, you will add decimal in a number that already is represented with decimal, but as integer:
normalizy :amount, with: { money: { cast: :to_f, type: :cents } }
'$ 42.00'
# 4200.0Transform text to valid number.
normalizy :age, with: :number
' 32x'
# '32'If you want cast the value, provide cast option with desired cast method:
normalizy :age, with: { number: { cast: :to_i } }
' 32x'
# 32Transform a value to a valid percent format.
normalizy :amount, with: :percent
'42.00 %'
# '42.00'The separator will be keeped on value to be possible cast the right integer value.
You can change this separator:
normalizy :amount, with: { percent: { separator: ',' } }
'42,00 %'
# '42,00'If you do not want pass it as options, Normalizy will fetch your I18n config:
en:
  number:
    percentage:
      format:
        separator: '.'And if it does not exists, . will be used as default.
You can retrieve the value in integer format, use the type options as integer:
normalizy :amount, with: { percent: { type: :integer } }
'42.00 %'
# '4200'As you could see on the last example, when using type: :integer is important the number of decimal digits.
So, you can configure it to avoid the following error:
normalizy :amount, with: { percent: { type: :integer } }
'42.0 %'
# 420When you parse it back, the value need to be divided by 100 to be presented, but it will result in a value you do not want: 4.2 instead of the original 42.0. Just provide a precision:
normalizy :amount, with: { percent: { precision: 2 } }
'42.0 %'
# 42.00normalizy :amount, with: { percent: { precision: 2, type: :integer } }
'42.0 %'
# 4200If you do not want pass it as options, Normalizy will fetch your I18n config:
en:
  number:
    percentage:
      format:
        separator: 2And if it does not exists, 2 will be used as default.
If you need get a number over a normalized string in a number style, provide cast option with desired cast method:
normalizy :amount, with: { percent: { cast: :to_i } }
'42.00 %'
# 4200Just pay attention to avoid to use type: :integer together cast with float parses.
Since type runs first, you will add decimal in a number that already is represented with decimal, but as integer:
normalizy :amount, with: { percent: { cast: :to_f, type: :integer } }
'42.00 %'
# 4200.0Convert texto to slug.
normalizy :slug, with: :slug
'Washington é Botelho'
# 'washington-e-botelho'You can slug a field based on other just sending the result value.
normalizy :title, with: { slug: { to: :slug } }
model.title = 'Washington é Botelho'
model.slug
# 'washington-e-botelho'Cleans edge spaces.
Options:
- side:- :left,- :rightor- :both. Default:- :both
normalizy :name, with: :strip
'  Washington  Botelho  '
# 'Washington  Botelho'normalizy :name, with: { strip: { side: :left } }
'  Washington  Botelho  '
# 'Washington  Botelho  'normalizy :name, with: { strip: { side: :right } }
'  Washington  Botelho  '
# '  Washington  Botelho'normalizy :name, with: { strip: { side: :both } }
'  Washington  Botelho  '
# 'Washington  Botelho'As you can see, the rules can be passed as Symbol/String or as Hash if it has options.
Remove excedent string part from a gived limit.
normalizy :description, with: { truncate: { limit: 10 } }
'Once upon a time in a world far far away'
# 'Once upon 'You can normalize with a couple of filters at once:
normalizy :name, with: { %i[squish titleize] }
'  washington  botelho  '
# 'Washington Botelho'You can normalize more than one attribute at once too, with one or multiple filters:
normalizy :email, :username, with: :downcaseOf course you can declare multiple attribute and multiple filters, either.
It is possible to make sequential normalizy calls, but take care!
Since we use prepend module the last line will run first then others:
normalizy :username, with: :downcase
normalizy :username, with: :titleize
'BoteLho'
# 'bote lho'As you can see, titleize runs first then downcase.
Each line will be evaluated from the bottom to the top.
If it is hard to you accept, use Muiltiple Filters
You can configure some default filters to be runned.
Edit initializer at config/initializers/normalizy.rb:
Normalizy.configure do |config|
  config.default_filters = [:squish]
endNow, all normalization will include squish, even when no rule is declared.
normalizy :name
"  Washington  \n  Botelho  "
# 'Washington Botelho'If you declare some filter, the default filter squish will be runned together:
normalizy :name, with: :downcase
'  washington  botelho  '
# 'Washington Botelho'You can create a custom filter that implements call method with an input as argument and an optional options:
module Normalizy
  module Filters
    module Blacklist
      def self.call(input)
        input.gsub 'Fuck', replacement: '***'
      end
    end
  end
endNormalizy.configure do |config|
  config.add :blacklist, Normalizy::Filters::Blacklist
endNow you can use your custom filter:
normalizy :name, with: :blacklist
'Washington Fuck Botelho'
# 'Washington *** Botelho'If you want to pass options to your filter, just call it as a hash and the value will be send to the custom filter:
module Normalizy
  module Filters
    module Blacklist
      def self.call(input, options: {})
        input.gsub 'Fuck', replacement: options[:replacement]
      end
    end
  end
endnormalizy :name, with: { blacklist: { replacement: '---' } }
'Washington Fuck Botelho'
# 'Washington --- Botelho'By default, Modules and instance methods of class will receveis the following attributes on options argument:
- object: The object that Normalizy is acting;
- attribute: The attribute of the object that Normalizy is acting.
You can pass a block and it will be received on filter:
module Normalizy
  module Filters
    module Blacklist
      def self.call(input, options: {})
        value = input.gsub('Fuck', 'filtered')
        value = yield(value) if block_given?
        value
      end
    end
  end
endnormalizy :name, with: { :blacklist, &->(value) { value.sub('filtered', '(filtered 2x)') } }
'Washington Fuck Botelho'
# 'Washington (filtered 2x) Botelho'If a built-in filter is not found, Normalizy will try to find a method in the current class.
normalizy :birthday, with: :parse_date
def parse_date(input)
  Time.zone.parse(input).strftime '%Y/%m/%d'
end
'1984-10-23'
# '1984/10/23'If you gives an option, it will be passed to the function:
normalizy :birthday, with: { parse_date: { format: '%Y/%m/%d' }
def parse_date(input, options = {})
  Time.zone.parse(input).strftime options[:format]
end
'1984-10-23'
# '1984/10/23'Block methods works here either.
After the missing built-in and class method, the fallback will be the value of native methods.
normalizy :name, with: :reverse
'Washington Botelho'
# "ohletoB notgnihsaW"Maybe you want to declare an inline filter, in this case, just use a Lambda or Proc:
normalizy :age, with: ->(input) { input.to_i.abs }
-32
# 32You can use it on filters declaration too:
Normalizy.configure do |config|
  config.add :age, ->(input) { input.to_i.abs }
endSometimes you want to give a better name to your filter, just to keep the things semantic. Duplicates the code, as you know, is not a good idea, so, create an alias:
Normalizy.configure do |config|
  config.alias :age, :number
endNow, age will delegate to number filter.
And now, the aliased filter will work fine:
normalizy :age, with: :age
'= 42'
# 42If you need to alias multiple filters, just provide an array of them:
Normalizy.configure do |config|
  config.alias :username, %i[squish downcase]
endAlias accepts options parameters too:
Normalizy.configure do |config|
  config.alias :left_trim, trim: { side: :left }
endIf you use RSpec, we did built-in matchers for you.
Add the following code to your rails_helper.rb
RSpec.configure do |config|
 config.include Normalizy::RSpec
endAnd now you can use some of the matchers:
it { is_expected.to normalizy(:email).from(' [email protected]  ').to '[email protected]' }It will match the given filter literally:
it { is_expected.to normalizy(:email).with :downcase }it { is_expected.to normalizy(:email).with %i[downcase squish] }it { is_expected.to normalizy(:email).with(trim: { side: :left }) }