require 'json' module Alula class FilterBuilder attr_accessor :model_class, :query_interface, :builder def initialize(model_class, query_interface, **args) self.query_interface = query_interface self.model_class = model_class @cache = Hash.new end # # Pseudo-delegator. Merges the current filter into the QueryInterface # then delegates the used method forward instance_eval do %i(list list_all filter_builder limit offset custom_options includes).each do |method| define_method(method) do |*args| query_interface.filter(as_json) query_interface.send(method, *args) end end end # # Takes a hash, one for each key def sort(**args) cleaned_sorts = args.each_pair.each_with_object({}) do |(field, value), collector| field_name = validate_field_sortability! field field_value = validate_sort_value! value collector[field_name] = field_value end query_interface.filter(as_json) query_interface.query_sort(cleaned_sorts) end # # Takes a hash, for each key => value pair returns a strict # hash response for a $where query def where(fields) cleaned = fields.each_pair.each_with_object({}) do |(key, value), collector| field_name = validate_field_filterability! key field_value = simple_value! value collector[field_name] = field_value end update_and_return(cleaned) end # # Takes a hash, for each key => value pair returns a strict # hash response for a $and $where query def and(fields) cleaned = fields.each_pair.each_with_object({}) do |(key, value), collector| field_name = validate_field_filterability! key field_value = simple_value! value collector[field_name] = field_value end update_and_return({ '$and' => cleaned }) end # # Define simple comparison operator methods for these query names # Give each method a hash containing fieldName => fieldValue, # Returns a query object in the form of: # { # "fieldName" => { # "operator_symbol" => "fieldValue" # } # } %i(not ne gt gte lt lte like not_like).each do |simple_operator| define_method(simple_operator) do |fields = {}| cleaned = fields.each_pair.each_with_object({}) do |(key, value), collector| field_name = validate_field_filterability! key field_value = simple_value! value collector[field_name] = { "$#{Util.camelize(simple_operator)}" => field_value } end update_and_return cleaned end end # # Dual operation # If given a block it will yield a fresh instance of FilterBuilder, the contents # of which will be stuffed directly into the $or operator. # If given an array, the 1st value is def or(*args, &block) if block_given? new_builder = yield self.class.new(model_class, query_interface) return update_and_return({ '$or' => new_builder.as_json }) end value = args[0] fields = args[1..-1] new_values = fields.each_with_object('$or'=>{}) do |key, collector| field_name = validate_field_filterability! key collector['$or'][field_name] = value end update_and_return(new_values) end def or_like(**args) new_values = args.each_pair.each_with_object({}) do |(key, value), collector| field_name = validate_field_filterability! key field_value = simple_value! value collector[field_name] = { '$like' => field_value } end update_and_return('$or' => new_values) end # # Generate a $between query for hash, the values of the hash must be # an array with 2 elements that are JSON-encodable def between(**args) new_values = args.each_pair.each_with_object({}) do |(key, range), collector| field_name = validate_field_filterability! key range_start, range_end = validate_range_value! range, field_name collector[field_name] = { '$between' => [range_start, range_end] } end update_and_return(new_values) end # # Generate a $not_between query for hash, the values of the hash must be # an array with 2 elements that are JSON-encodable def not_between(**args) new_values = args.each_pair.each_with_object({}) do |(key, range), collector| field_name = validate_field_filterability! key range_start, range_end = validate_range_value! range, field_name collector[field_name] = { '$notBetween' => [range_start, range_end] } end update_and_return(new_values) end # # Generate an $in query for hash, the values of the hash must be # an array that is JSON-encodable def in(**args) new_values = args.each_pair.each_with_object({}) do |(key, range), collector| field_name = validate_field_filterability! key values = simple_values! range collector[field_name] = { '$in' => values } end update_and_return(new_values) end # # Generate an $in query for hash, the values of the hash must be # an array that is JSON-encodable def not_in(**args) new_values = args.each_pair.each_with_object({}) do |(key, range), collector| field_name = validate_field_filterability! key values = simple_values! range collector[field_name] = { '$notIn' => values } end update_and_return(new_values) end def as_json @cache end private # # Check the field name against known field names. # Ensure that the field is exists, is filterable # Transform the field name into an API-valid lowerCamelCase # format and return it. def validate_field_filterability!(field_name) underscored = Util.underscore(field_name).to_sym camelized = Util.camelize(underscored).to_s unless model_class.get_fields.include?(underscored) error = "Field `#{underscored}` does not exist resource `#{model_class}`" raise Alula::InvalidFilterFieldError.new(error) end unless model_class.filterable_fields.include?(underscored) error = "Field `#{underscored}` is not filterable on resource `#{model_class}`" raise Alula::InvalidFilterFieldError.new(error) end camelized end # # Check that a field name is known, and that it is sortable. # Transform the field name into an API-validated lowerCamelCase # TODO: This doesn't belong here, should be on its own include I think def validate_field_sortability!(field_name) underscored = Util.underscore(field_name).to_sym camelized = Util.camelize(underscored).to_s unless model_class.get_fields.include?(underscored) error = "Field `#{underscored}` does not exist resource `#{model_class}`" raise Alula::InvalidFilterFieldError.new(error) end unless model_class.sortable_fields.include?(underscored) error = "Field `#{underscored}` is not sortable on resource `#{model_class}`" raise Alula::InvalidSortFieldError.new(error) end camelized end def validate_range_value!(range, field_name) unless range.length == 2 error = "Provide an array with 2 values to field `#{field_name}` "\ "perform a range-based query" raise Alula::InvalidFilterFieldError.new(error) end range[0] = range[0].iso8601 if range[0].respond_to?(:iso8601) range[1] = range[1].iso8601 if range[1].respond_to?(:iso8601) [simple_value!(range[0]), simple_value!(range[1])] end # Ensure a value will be a JSON-encodable value. We don't really care what it is, # but what comes out of JSON serialization is what we'll use. def simple_value!(value) JSON.parse(JSON.fast_generate({ purify: value }))['purify'] end def simple_values!(values) values.map{ |v| simple_value!(v) } end def update_cache(new_values) @cache = Util.deep_merge(@cache, new_values) self end def update_and_return(new_values) return new_values if @functional update_cache(new_values) end def validate_sort_value!(value) val = value.to_s.downcase.to_sym unless %i(asc desc).include? val error = "Cannot sort on direction #{val}, please use ASC or DESC only" raise Alula::InvalidSortFieldError.new(error) end val end def update_and_return(new_values) @cache = Util.deep_merge(@cache, new_values) self end end end