Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#105 Allow Procs for dynamic value usage #135

Merged
merged 21 commits into from
Oct 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ test/dummy/storage/
test/dummy/tmp/
test/dummy/.byebug_history
*.gem
/.idea
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This gems doing it for you. Just use `attached: true` or `content_type: 'image/p
* validates number of uploaded files (min/max required)
* validates aspect ratio (if square, portrait, landscape, is_16_9, ...)
* custom error messages
* allow procs for dynamic determination of values

## Usage

Expand Down Expand Up @@ -124,6 +125,18 @@ class User < ApplicationRecord
end
```

- Proc Usage:

Procs can be used instead of values in all the above examples. They will be called on every validation.
```ruby
class User < ApplicationRecord
has_many_attached :proc_files

validates :proc_files, limit: { max: -> (record) { record.admin? ? 100 : 10 } }
end

```

## Internationalization (I18n)

Active Storage Validations uses I18n for error messages. For this, add these keys in your translation file:
Expand Down
1 change: 1 addition & 0 deletions lib/active_storage_validations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require 'active_storage_validations/railtie'
require 'active_storage_validations/engine'
require 'active_storage_validations/option_proc_unfolding'
require 'active_storage_validations/attached_validator'
require 'active_storage_validations/content_type_validator'
require 'active_storage_validations/size_validator'
Expand Down
32 changes: 15 additions & 17 deletions lib/active_storage_validations/aspect_ratio_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,14 @@

module ActiveStorageValidations
class AspectRatioValidator < ActiveModel::EachValidator # :nodoc
include OptionProcUnfolding
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we reuse the code and logic from active model? Since we depend on it anyway, why not use that a bit more. For example the https://github.com/rails/rails/blob/d695972025146713fae9a089dcaf2239ede354d4/activemodel/lib/active_model/validations/resolve_value.rb#L6 method might be useful.

Copy link
Collaborator

@gr8bit gr8bit Sep 14, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That method doesn't descend into Hashes and Arrays and tries to call Symbols as methods. That's not what we need for our "options-processor". (Also it isn't yet part of any rails stable version.)
If it wasn't for Rails 5.2 compatibility I would have used deep_transform_values (https://apidock.com/rails/v6.0.0/Hash/deep_transform_values), but that was added in Rails 6. :(


AVAILABLE_CHECKS = %i[with].freeze
PRECISION = 3

def initialize(options)
super(options)
end


def check_validity!
return true if AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }
raise ArgumentError, 'You must pass "aspect_ratio: :OPTION" option to the validator'
raise ArgumentError, 'You must pass :with to the validator'
end

if Rails.gem_version >= Gem::Version.new('6.0.0')
Expand Down Expand Up @@ -55,44 +52,45 @@ def validate_each(record, attribute, _value)


def is_valid?(record, attribute, metadata)
flat_options = unfold_procs(record, self.options, AVAILABLE_CHECKS)
if metadata[:width].to_i <= 0 || metadata[:height].to_i <= 0
add_error(record, attribute, options[:message].presence || :image_metadata_missing)
add_error(record, attribute, :image_metadata_missing, flat_options[:with])
return false
end

case options[:with]
case flat_options[:with]
when :square
return true if metadata[:width] == metadata[:height]
add_error(record, attribute, :aspect_ratio_not_square)
add_error(record, attribute, :aspect_ratio_not_square, flat_options[:with])

when :portrait
return true if metadata[:height] > metadata[:width]
add_error(record, attribute, :aspect_ratio_not_portrait)
add_error(record, attribute, :aspect_ratio_not_portrait, flat_options[:with])

when :landscape
return true if metadata[:width] > metadata[:height]
add_error(record, attribute, :aspect_ratio_not_landscape)
add_error(record, attribute, :aspect_ratio_not_landscape, flat_options[:with])

else
if options[:with] =~ /is\_(\d*)\_(\d*)/
if flat_options[:with] =~ /is_(\d*)_(\d*)/
x = $1.to_i
y = $2.to_i

return true if (x.to_f / y).round(PRECISION) == (metadata[:width].to_f / metadata[:height]).round(PRECISION)

add_error(record, attribute, :aspect_ratio_is_not, "#{x}x#{y}")
else
add_error(record, attribute, :aspect_ratio_unknown)
add_error(record, attribute, :aspect_ratio_unknown, flat_options[:with])
end
end
false
end


def add_error(record, attribute, type, interpolate = options[:with])
key = options[:message].presence || type
return if record.errors.added?(attribute, key)
record.errors.add(attribute, key, aspect_ratio: interpolate)
def add_error(record, attribute, default_message, interpolate)
message = options[:message].presence || default_message
return if record.errors.added?(attribute, message)
record.errors.add(attribute, message, aspect_ratio: interpolate)
end

end
Expand Down
24 changes: 15 additions & 9 deletions lib/active_storage_validations/content_type_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,33 @@

module ActiveStorageValidations
class ContentTypeValidator < ActiveModel::EachValidator # :nodoc:
include OptionProcUnfolding

AVAILABLE_CHECKS = %i[with in].freeze

def validate_each(record, attribute, _value)
return true if !record.send(attribute).attached? || types.empty?
return true unless record.send(attribute).attached?

types = authorized_types(record)
return true if types.empty?

files = Array.wrap(record.send(attribute))

errors_options = { authorized_types: types_to_human_format }
errors_options = { authorized_types: types_to_human_format(types) }
errors_options[:message] = options[:message] if options[:message].present?

files.each do |file|
next if is_valid?(file)
next if is_valid?(file, types)

errors_options[:content_type] = content_type(file)
record.errors.add(attribute, :content_type_invalid, **errors_options)
break
end
end

def types
return @types if defined? @types

@types = (Array.wrap(options[:with]) + Array.wrap(options[:in])).compact.map do |type|
def authorized_types(record)
flat_options = unfold_procs(record, self.options, AVAILABLE_CHECKS)
(Array.wrap(flat_options[:with]) + Array.wrap(flat_options[:in])).compact.map do |type|
if type.is_a?(Regexp)
type
else
Expand All @@ -31,7 +37,7 @@ def types
end
end

def types_to_human_format
def types_to_human_format(types)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codegeek319 probably naming should be changed, or code refactored

because we have method "types", and we have parameter types and local variables.

this is confusing. Do we need to pass types everywhere?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@igorkasyanchuk sorry for the late answer and tackling of this issue!
For the dynamic proc evaluation, we need to check the options for procs on every validation, call them, then continue the validations with the new options. As the options are used in the types method to determine the authorized types, we now have to pass the new options to it and the resulting authorized types can be different on each validation - that's why we then pass the types on to the methods which need them.

I renamed the types method to authorized_types to avoid confusion with variables and parameters. Looks clearer now, I agree. :)

types
.map { |type| type.to_s.split('/').last.upcase }
.join(', ')
Expand All @@ -41,7 +47,7 @@ def content_type(file)
file.blob.present? && file.blob.content_type
end

def is_valid?(file)
def is_valid?(file, types)
file_type = content_type(file)
types.any? do |type|
type == file_type || (type.is_a?(Regexp) && type.match?(file_type.to_s))
Expand Down
70 changes: 38 additions & 32 deletions lib/active_storage_validations/dimension_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,30 @@

module ActiveStorageValidations
class DimensionValidator < ActiveModel::EachValidator # :nodoc
include OptionProcUnfolding

AVAILABLE_CHECKS = %i[width height min max].freeze

def initialize(options)
def process_options(record)
flat_options = unfold_procs(record, self.options, AVAILABLE_CHECKS)

[:width, :height].each do |length|
if options[length] and options[length].is_a?(Hash)
if range = options[length][:in]
if flat_options[length] and flat_options[length].is_a?(Hash)
if (range = flat_options[length][:in])
raise ArgumentError, ":in must be a Range" unless range.is_a?(Range)
options[length][:min], options[length][:max] = range.min, range.max
flat_options[length][:min], flat_options[length][:max] = range.min, range.max
end
end
end
[:min, :max].each do |dim|
if range = options[dim]
if (range = flat_options[dim])
raise ArgumentError, ":#{dim} must be a Range (width..height)" unless range.is_a?(Range)
options[:width] = { dim => range.first }
options[:height] = { dim => range.last }
flat_options[:width] = { dim => range.first }
flat_options[:height] = { dim => range.last }
end
end
super

flat_options
end


Expand Down Expand Up @@ -64,51 +69,52 @@ def validate_each(record, attribute, _value)


def is_valid?(record, attribute, file_metadata)
flat_options = process_options(record)
# Validation fails unless file metadata contains valid width and height.
if file_metadata[:width].to_i <= 0 || file_metadata[:height].to_i <= 0
add_error(record, attribute, options[:message].presence || :image_metadata_missing)
add_error(record, attribute, :image_metadata_missing)
return false
end

# Validation based on checks :min and :max (:min, :max has higher priority to :width, :height).
if options[:min] || options[:max]
if options[:min] && (
(options[:width][:min] && file_metadata[:width] < options[:width][:min]) ||
(options[:height][:min] && file_metadata[:height] < options[:height][:min])
if flat_options[:min] || flat_options[:max]
if flat_options[:min] && (
(flat_options[:width][:min] && file_metadata[:width] < flat_options[:width][:min]) ||
(flat_options[:height][:min] && file_metadata[:height] < flat_options[:height][:min])
)
add_error(record, attribute, options[:message].presence || :"dimension_min_inclusion", width: options[:width][:min], height: options[:height][:min])
add_error(record, attribute, :dimension_min_inclusion, width: flat_options[:width][:min], height: flat_options[:height][:min])
return false
end
if options[:max] && (
(options[:width][:max] && file_metadata[:width] > options[:width][:max]) ||
(options[:height][:max] && file_metadata[:height] > options[:height][:max])
if flat_options[:max] && (
(flat_options[:width][:max] && file_metadata[:width] > flat_options[:width][:max]) ||
(flat_options[:height][:max] && file_metadata[:height] > flat_options[:height][:max])
)
add_error(record, attribute, options[:message].presence || :"dimension_max_inclusion", width: options[:width][:max], height: options[:height][:max])
add_error(record, attribute, :dimension_max_inclusion, width: flat_options[:width][:max], height: flat_options[:height][:max])
return false
end

# Validation based on checks :width and :height.
else
width_or_height_invalid = false
[:width, :height].each do |length|
next unless options[length]
if options[length].is_a?(Hash)
if options[length][:in] && (file_metadata[length] < options[length][:min] || file_metadata[length] > options[length][:max])
add_error(record, attribute, options[:message].presence || :"dimension_#{length}_inclusion", min: options[length][:min], max: options[length][:max])
next unless flat_options[length]
if flat_options[length].is_a?(Hash)
if flat_options[length][:in] && (file_metadata[length] < flat_options[length][:min] || file_metadata[length] > flat_options[length][:max])
add_error(record, attribute, :"dimension_#{length}_inclusion", min: flat_options[length][:min], max: flat_options[length][:max])
width_or_height_invalid = true
else
if options[length][:min] && file_metadata[length] < options[length][:min]
add_error(record, attribute, options[:message].presence || :"dimension_#{length}_greater_than_or_equal_to", length: options[length][:min])
if flat_options[length][:min] && file_metadata[length] < flat_options[length][:min]
add_error(record, attribute, :"dimension_#{length}_greater_than_or_equal_to", length: flat_options[length][:min])
width_or_height_invalid = true
end
if options[length][:max] && file_metadata[length] > options[length][:max]
add_error(record, attribute, options[:message].presence || :"dimension_#{length}_less_than_or_equal_to", length: options[length][:max])
if flat_options[length][:max] && file_metadata[length] > flat_options[length][:max]
add_error(record, attribute, :"dimension_#{length}_less_than_or_equal_to", length: flat_options[length][:max])
width_or_height_invalid = true
end
end
else
if file_metadata[length] != options[length]
add_error(record, attribute, options[:message].presence || :"dimension_#{length}_equal_to", length: options[length])
if file_metadata[length] != flat_options[length]
add_error(record, attribute, :"dimension_#{length}_equal_to", length: flat_options[length])
width_or_height_invalid = true
end
end
Expand All @@ -120,10 +126,10 @@ def is_valid?(record, attribute, file_metadata)
true # valid file
end

def add_error(record, attribute, type, **attrs)
key = options[:message].presence || type
return if record.errors.added?(attribute, key)
record.errors.add(attribute, key, **attrs)
def add_error(record, attribute, default_message, **attrs)
message = options[:message].presence || default_message
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe an interesting possibility is to allow a proc for the message as well. But lets do that in a separate PR if people would like that idea.

return if record.errors.added?(attribute, message)
record.errors.add(attribute, message, **attrs)
end

end
Expand Down
22 changes: 12 additions & 10 deletions lib/active_storage_validations/limit_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,33 @@

module ActiveStorageValidations
class LimitValidator < ActiveModel::EachValidator # :nodoc:
include OptionProcUnfolding

AVAILABLE_CHECKS = %i[max min].freeze

def check_validity!
return true if AVAILABLE_CHECKS.any? { |argument| options.key?(argument) }

raise ArgumentError, 'You must pass either :max or :min to the validator'
end

def validate_each(record, attribute, _)
return true unless record.send(attribute).attached?

files = Array.wrap(record.send(attribute)).compact.uniq
errors_options = { min: options[:min], max: options[:max] }
flat_options = unfold_procs(record, self.options, AVAILABLE_CHECKS)
errors_options = { min: flat_options[:min], max: flat_options[:max] }

return true if files_count_valid?(files.count)
return true if files_count_valid?(files.count, flat_options)
record.errors.add(attribute, options[:message].presence || :limit_out_of_range, **errors_options)
end

def files_count_valid?(count)
if options[:max].present? && options[:min].present?
count >= options[:min] && count <= options[:max]
elsif options[:max].present?
count <= options[:max]
elsif options[:min].present?
count >= options[:min]
def files_count_valid?(count, flat_options)
if flat_options[:max].present? && flat_options[:min].present?
count >= flat_options[:min] && count <= flat_options[:max]
elsif flat_options[:max].present?
count <= flat_options[:max]
elsif flat_options[:min].present?
count >= flat_options[:min]
end
end
end
Expand Down
16 changes: 16 additions & 0 deletions lib/active_storage_validations/option_proc_unfolding.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module ActiveStorageValidations
module OptionProcUnfolding

def unfold_procs(record, object, only_keys = nil)
case object
when Hash
object.merge(object) { |key, value| only_keys&.exclude?(key) ? unfold_procs(record, value, []) : unfold_procs(record, value) }
when Array
object.map { |o| unfold_procs(record, o, only_keys) }
else
object.is_a?(Proc) ? object.call(record) : object
end
end

end
end
Loading