app/modules/filter/validate.rb
# frozen_string_literal: true
# use include, not extend in host classes
require 'active_support/concern'
# Provides common validations for composing queries.
module Filter
module Validate
extend ActiveSupport::Concern
# using Arel https://github.com/rails/arel
# http://robots.thoughtbot.com/using-arel-to-compose-sql-queries
# http://jpospisil.com/2014/06/16/the-definitive-guide-to-arel-the-sql-manager-for-ruby.html
private
# Validate sorting values.
# @param [Symbol] order_by
# @param [Array<Symbol>] valid_fields
# @param [Symbol] direction
# @raise [FilterArgumentError] if order_by is not valid
# @return [void]
def validate_sorting(order_by, valid_fields, direction)
if !order_by.blank? && !direction.blank?
# allow both to be nil, but if one is nil and the other is not, that is an error.
raise CustomErrors::FilterArgumentError, 'Order by must not be null' if order_by.blank?
raise CustomErrors::FilterArgumentError, 'Direction must not be null' if direction.blank?
raise CustomErrors::FilterArgumentError, 'Valid Fields must not be null' if valid_fields.blank?
direction_sym = direction.to_sym
order_by_sym = order_by.to_sym
valid_fields_sym = valid_fields.map(&:to_sym)
unless valid_fields_sym.include?(order_by_sym)
raise CustomErrors::FilterArgumentError, "Order by must be in #{valid_fields_sym}, got #{order_by_sym}"
end
unless [:desc, :asc].include?(direction_sym)
raise CustomErrors::FilterArgumentError, "Direction must be asc or desc, got #{direction_sym}"
end
end
end
# Validate paging values.
# @param [Integer] offset
# @param [Integer] limit
# @return [void]
def validate_paging(offset, limit)
validate_integer(offset, 0)
validate_integer(limit, 1)
end
# Check that value is an integer between min and max.
# @param [Integer] value
# @param [Integer] min
# @param [Integer] max
# @return [void]
def validate_integer(value, min = nil, max = nil)
raise CustomErrors::FilterArgumentError, 'Value must not be blank' if value.blank?
if value.blank? || value != value.to_i
raise CustomErrors::FilterArgumentError, "Value must be an integer, got #{value}"
end
value_i = value.to_i
if !min.blank? && value_i < min
raise CustomErrors::FilterArgumentError, "Value must be #{min} or greater, got #{value_i}"
end
if !max.blank? && value_i > max
raise CustomErrors::FilterArgumentError, "Value must be #{max} or less, got #{value_i}"
end
end
# Check that value is a string.
# @param [String] value
# @return [void]
def validate_string(value)
raise CustomErrors::FilterArgumentError, 'Value must be a string' unless value.is_a?(String)
end
# Validate query, table, and column values.
# @param [Arel::Query] query
# @param [Arel::Table] table
# @param [Symbol] column_name
# @param [Array<Symbol>] allowed
# @return [void]
def validate_query_table_column(query, table, column_name, allowed)
validate_query(query)
validate_table(table)
validate_name(column_name, allowed)
end
# Validate table and column values.
# @param [Arel::Table] table
# @param [Symbol] column_name
# @param [Array<Symbol>] allowed
# @return [void]
def validate_table_column(table, column_name, allowed)
validate_table(table)
validate_name(column_name, allowed)
end
def validate_association(model, models_allowed)
validate_model(model)
unless models_allowed.is_a?(Array)
raise CustomErrors::FilterArgumentError, "Models allowed must be an Array, got #{models_allowed}"
end
unless models_allowed.include?(model)
raise CustomErrors::FilterArgumentError, "Model must be in #{models_allowed}, got #{model}"
end
end
# Validate query and hash values.
# @param [ActiveRecord::Relation] query
# @param [Hash] hash
# @return [void]
def validate_query_hash(query, hash)
validate_query(query)
validate_hash(hash)
end
# Validate table value.
# @param [Arel::Table] table
# @raise [FilterArgumentError] if table is not an Arel::Table
# @return [void]
def validate_table(table)
unless table.is_a?(Arel::Table)
raise CustomErrors::FilterArgumentError, "Table must be Arel::Table, got #{table.class}"
end
end
# Validate table value.
# @param [ActiveRecord::Relation] query
# @raise [FilterArgumentError] if query is not an Arel::Query
# @return [void]
def validate_query(query)
unless query.is_a?(ActiveRecord::Relation)
raise CustomErrors::FilterArgumentError, "Query must be ActiveRecord::Relation, got #{query.class}"
end
end
# Validate condition value.
# @param [Arel::Nodes::Node] condition
# @raise [FilterArgumentError] if condition is not an Arel::Nodes::Node
# @return [void]
def validate_condition(condition)
if !condition.is_a?(Arel::Nodes::Node) && !condition.is_a?(String)
raise CustomErrors::FilterArgumentError, "Condition must be Arel::Nodes::Node or String, got #{condition}"
end
end
# Validate projection value.
# @param [Arel::Attributes::Attribute] projection
# @raise [FilterArgumentError] if projection is not an Arel::Attributes::Attribute
# @return [void]
def validate_projection(projection)
if projection.is_a?(Hash)
validate_hash_key(projection, :projection, [Arel::Nodes::Node, Arel::Attributes::Attribute])
validate_hash_key(projection, :joins, Array)
validate_table(projection[:base_table])
projection[:joins].each do |join|
validate_hash(join)
validate_table(join[:arel_table])
validate_hash_key(join, :type, Arel::Nodes::Join)
validate_hash_key(join, :on, Arel::Nodes::Equality)
end
return
end
validate_node_or_attribute(projection)
end
def validate_node_or_attribute(value)
check = value.is_a?(Arel::Nodes::Node) || value.is_a?(String) || value.is_a?(Arel::Attributes::Attribute) || value.is_a?(::Arel::Nodes::SqlLiteral)
unless check
raise CustomErrors::FilterArgumentError,
"Value must be Arel::Nodes::Node or String or Arel::Attributes::Attribute, got #{value}"
end
end
# Validate name value.
# @param [Symbol] name
# @param [Array<Symbol>] allowed
# @raise [FilterArgumentError] if name is not a symbol in allowed
# @return [void]
def validate_name(name, allowed)
raise CustomErrors::FilterArgumentError, "Name must not be null, got #{name}" if name.blank?
raise CustomErrors::FilterArgumentError, "Name must be a symbol, got #{name}" unless name.is_a?(Symbol)
raise CustomErrors::FilterArgumentError, "Allowed must be an Array, got #{allowed}" unless allowed.is_a?(Array)
raise CustomErrors::FilterArgumentError, "Name must be in #{allowed}, got #{name}" unless allowed.include?(name)
end
# Validate model value.
# @param [ActiveRecord::Base] model
# @raise [FilterArgumentError] if model is not an ActiveRecord::Base
# @return [void]
def validate_model(model)
unless model < ActiveRecord::Base
raise CustomErrors::FilterArgumentError, "Model must be an ActiveRecord::Base, got #{model.base_class}"
end
end
# Validate an array.
# @param [Array, Arel::SelectManager] value
# @raise [FilterArgumentError] if value is not a valid Array.
# @return [void]
def validate_array(value)
raise CustomErrors::FilterArgumentError, "Value must not be null, got #{value}" if value.nil?
unless value.is_a?(Array) || value.is_a?(Arel::SelectManager)
raise CustomErrors::FilterArgumentError, "Value must be an Array or Arel::SelectManager, got #{value.class}"
end
end
# Validate array items. Do not validate if value is not an Array.
# @param [Array] value
# @raise [FilterArgumentError] if Array contents are not valid.
# @return [void]
def validate_array_items(value)
# must be a collection of items
if !value.respond_to?(:each) || !value.respond_to?(:all?) || !value.respond_to?(:any?) || !value.respond_to?(:count)
raise CustomErrors::FilterArgumentError, "Must be a collection of items, got #{value.class}."
end
# if there are no items, let it through
if value.count.positive?
# all items must be the same type. Assume the first item is the correct type.
type_compare_item = value[0].class
type_compare = value.all? { |item| item.is_a?(type_compare_item) }
raise CustomErrors::FilterArgumentError, 'Array values must be a single consistent type.' unless type_compare
# restrict length of strings
if type_compare_item.is_a?(String)
max_string_length = 120
string_length = value.all? { |item| item.size <= max_string_length }
unless string_length
raise CustomErrors::FilterArgumentError,
"Array values that are strings must be #{max_string_length} characters or less."
end
end
# array contents cannot be Arrays or Hashes
array_check = value.any? { |item| item.is_a?(Array) }
raise CustomErrors::FilterArgumentError, 'Array values cannot be arrays.' if array_check
hash_check = value.any? { |item| item.is_a?(Hash) }
raise CustomErrors::FilterArgumentError, 'Array values cannot be hashes.' if hash_check
end
end
# Validate a hash.
# @param [Array] value
# @raise [FilterArgumentError] if value is not a valid Hash.
# @return [void]
def validate_hash(value)
raise CustomErrors::FilterArgumentError, "Value must not be null, got #{value}" if value.blank?
raise CustomErrors::FilterArgumentError, "value must be a Hash, got #{value}" unless value.is_a?(Hash)
end
# Validate Extract field for timestamp, time, interval, date.
# @param [String] value
# @raise [FilterArgumentError] if value is not a valid field value.
# @return [void]
def validate_projection_extract(value)
valid = [
:century, :day, :decade, :dow, :epoch, :hour,
:isodow, :isoyear, :microseconds, :millennium,
:milliseconds, :minute, :month, :quarter,
:second, :timezone, :timezone_hour, :timezone_minute,
:week, :year
]
raise CustomErrors::FilterArgumentError, 'Value for extract must not be null' if value.blank?
unless valid.include?(value.downcase.to_sym)
raise CustomErrors::FilterArgumentError, "Value for extract must be in #{valid}, got #{value}"
end
end
# Escape wildcards in like value..
# @param [String] value
# @return [String] sanitized value
def sanitize_like_value(value)
value.gsub(/[\\_%|]/) { |x| "\\#{x}" }
end
# Escape meta-characters in SIMILAR TO value.
# see http://www.postgresql.org/docs/9.3/static/functions-matching.html
# @param [String] value
# @return [String] sanitized value
def sanitize_similar_to_value(value)
value.gsub(/[\\_%|*+?{}()\[\]]/) { |x| "\\#{x}" }
end
# Remove all except 0-9, a-z, _ from projection alias
# @param [String] value
# @return [String] sanitized value
def sanitize_projection_alias(value)
value.gsub(/[^0-9a-zA-Z_]/)
end
# Check that value is a float.
# @param [Object] value
# @raise [FilterArgumentError] if value is not a float
# @return [void]
def validate_float(value)
raise CustomErrors::FilterArgumentError, 'Must have a value, got blank' if value.blank?
filtered = value.to_s.tr('^0-9.', '')
raise CustomErrors::FilterArgumentError, "Value must be a float, got #{filtered}" if filtered != value
if filtered != value.to_f
raise CustomErrors::FilterArgumentError, "Value must be a float after conversion, got #{filtered}"
end
value_f = filtered.to_f
raise CustomErrors::FilterArgumentError, "Value must be greater than 0, got #{value_f}" if value_f <= 0
end
# Check that value is a 'basic class'.
# @param [Object] value
# @raise [FilterArgumentError] if value is not a 'basic class'
# @return [void]
def validate_basic_class(node, value)
return if value.is_a?(NilClass) || value.is_a?(Integer) || value.is_a?(String) || value.is_a?(Float) ||
value.is_a?(TrueClass) || value.is_a?(FalseClass)
node_name = node.respond_to?(:name) ? node.name : '(custom item)'
# allow treating a hash as a basic value if it serialized into json/jsonb
# in the database
if value.is_a?(Hash) && !json_column?(node)
raise CustomErrors::FilterArgumentError,
"The value for #{node_name} must not be a hash (unless its underlying type is a hash)"
end
raise CustomErrors::FilterArgumentError, "The value for #{node_name} must not be an array" if value.is_a?(Array)
raise CustomErrors::FilterArgumentError, "The value for #{node_name} must not be a set" if value.is_a?(Set)
raise CustomErrors::FilterArgumentError, "The value for #{node_name} must not be a range" if value.is_a?(Range)
end
# Check that a hash contains a key with expected type of value.
# @param [Hash] hash
# @param [Object] key
# @param [Array<Object>, Object] value_types
# @raise [FilterArgumentError] if hash does not contain expected key
# @raise [FilterArgumentError] if hash key does not have expected type
# @return [void]
def validate_hash_key(hash, key, value_types)
raise CustomErrors::FilterArgumentError, "Hash must include key #{key}." unless hash.include?(key)
value_types_normalised = [value_types].flatten
value = hash[key]
is_class = value.class === Class
is_valid = value_types_normalised.any? { |value_type| is_class ? value < value_type : value.is_a?(value_type) }
unless is_valid
raise CustomErrors::FilterArgumentError,
"Hash key must be one of #{value_types_normalised}, got #{hash[key].class}."
end
end
def validate_closure(value, parameters = [])
unless value.is_a?(Proc)
raise CustomErrors::FilterArgumentError, "Value must be a lambda or proc, got #{value.class}."
end
parameters_normalized = value
.parameters
.map(&:last)
.map { |name| name.to_s.ltrim('_').to_sym }
unless parameters_normalized == parameters
raise CustomErrors::FilterArgumentError,
"Lambda or proc must have parameters matching #{parameters}, got #{parameters_normalized}."
end
end
# Validate the filter_settings for a model.
def validate_filter_settings(value)
validate_hash(value)
# Common filter settings
validate_array(value[:valid_fields])
validate_array_items(value[:valid_fields])
validate_array(value[:render_fields])
validate_array_items(value[:render_fields])
validate_array(value[:text_fields]) if value.include?(:text_fields)
validate_array_items(value[:text_fields]) if value.include?(:text_fields)
unless value[:controller].is_a?(Symbol)
raise CustomErrors::FilterArgumentError, 'Controller name must be a symbol.'
end
raise CustomErrors::FilterArgumentError, 'Action name must be a symbol.' unless value[:action].is_a?(Symbol)
validate_hash(value[:defaults])
unless value[:defaults][:order_by].is_a?(Symbol)
raise CustomErrors::FilterArgumentError, 'Order by must be a symbol.'
end
unless value[:defaults][:direction].is_a?(Symbol)
raise CustomErrors::FilterArgumentError, 'Direction must be a symbol.'
end
# advanced filter settings
raise 'Filters using `field_mappings` are deprecated' if value.include?(:field_mappings)
if value.include?(:capabilities)
validate_hash(value[:capabilities])
value[:capabilities].values do |capability|
validate_hash(capability)
validate_hash_key(capability, :can_list, [NilClass, Proc])
validate_hash_key(capability, :can_item, [NilClass, Proc])
validate_closure(capability[:can_list], [:klass]) if capability[:can_list]
validate_closure(capability[:can_item], [:item]) if capability[:can_list]
validate_hash_key(capability, :details, [Proc])
validate_closure(capability[:details], [:can, item, klass]) if capability[:details]
end
end
validate_closure(value[:custom_fields], [:item, :user]) if value.include?(:custom_fields)
if value.include?(:custom_fields2)
validate_hash(value[:custom_fields2])
value[:custom_fields2].each_value do |custom_definition|
validate_hash(custom_definition)
validate_array(custom_definition[:query_attributes])
if custom_definition[:query_attributes].length.positive?
validate_closure(custom_definition[:transform], [:item])
end
validate_hash_key(custom_definition, :arel,
[NilClass, Arel::Nodes::Node, Arel::Nodes::SqlLiteral, Arel::Attributes::Attribute])
validate_hash_key(custom_definition, :type, [Symbol]) unless custom_definition[:arel].nil?
end
end
validate_closure(value[:new_spec_fields], [:user]) if value.include?(:new_spec_fields)
validate_hash_key(value, :base_association, ActiveRecord::Relation) if value.include?(:base_association)
validate_hash_key(value, :base_association_key, Symbol) if value.include?(:base_association)
validate_filter_associations(value[:valid_associations]) if value.include?(:valid_associations)
end
def validate_filter_associations(value)
value.each do |association|
validate_hash_key(association, :join, [ActiveRecord::Base, Arel::Table])
validate_hash_key(association, :on, Arel::Nodes::Node)
validate_hash_key(association, :available, [TrueClass, FalseClass])
validate_filter_associations(association[:associations]) unless association[:associations].blank?
end
end
end
end