diff --git a/.ruby-version b/.ruby-version index 005119b..3f5987a 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.4.1 +2.4.9 diff --git a/.travis.yml b/.travis.yml index 37864eb..2671429 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ sudo: false language: ruby rvm: - - 2.4.1 + - 2.4.9 before_install: - gem install bundler -v 1.15.4 - export TZ=America/New_York diff --git a/CHANGELOG b/CHANGELOG index 29f6cfe..ea935e7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,61 @@ +v 0.9.2 + - allow for form-friendly filter parameters + ``` + { + '0': { + attribute: :foo, + operator: :EQ, + query: 'query' + }, + '1': { + attribute: :bar, + operator: :GT, + query: 'value' + } + } + + { foo: 'query', bar(GT): 'value' } + ``` + - allow for form-friendly sort parameters + ``` + { + '0': { + attribute: :foo, + direction: :desc + }, + '1': { + attribute: :bar, + direction: :asc + } + } + + { foo: 'desc', bar: 'asc' } + ``` + +v 0.9.1 + - ensure that our RubyGems build is stable, since we had a TravisCI build problem in the last version +v 0.9.0 + - Add Enumerable support for the base filtering operations + + EQ, NOT_EQ, EQ_ANY, EQ_ALL, NOT_EQ_ANY, NOT_EQ_ALL + + IN, NOT_IN, IN_ANY, IN_ALL, NOT_IN_ANY, NOT_IN_ALL + + MATCHES, DOES_NOT_MATCH, MATCHES_ANY, MATCHES_ALL, DOES_NOT_MATCH_ANY, DOES_NOT_MATCH_ALL + + LT, LTEQ, LT_ANY, LT_ALL, LTEQ_ANY, LTEQ_ALL + + GT, GTEQ, GT_ANY, GT_ALL, GTEQ_ANY, GTEQ_ALL + + BETWEEN, NOT_BETWEEN + - Add unary predicate filtering operators, for both ActiveRecord and Enumerable + + IS_TRUE, IS_FALSE + + IS_NULL, NOT_NULL + + IS_PRESENT, IS_BLANK + - Add computed matcher filtering operators, for both ActiveRecord and Enumerable + + MATCH_START, MATCH_START_ANY, MATCH_START_ALL, MATCH_NOT_START, MATCH_NOT_START_ANY, MATCH_NOT_START_ALL + + MATCH_END, MATCH_END_ANY, MATCH_END_ALL, MATCH_NOT_END, MATCH_NOT_END_ANY, MATCH_NOT_END_ALL + + MATCH_CONTAIN, MATCH_CONTAIN_ANY, MATCH_CONTAIN_ALL, MATCH_NOT_CONTAIN, MATCH_NOT_CONTAIN_ANY, MATCH_NOT_CONTAIN_ALL + - Add view helpers for the range of records shown on the current page + - Use ActiveRecord's data dictionary to look up database column types when converting filter params to filter instructions + - Allow app to define type hints for filter attributes when converting filter params to filter instructions + - Allow sort params to be passed in short or long form + + e.g. { attribute: x, direction: x } or { attribute: direction } + - Fix enumerable intersection filtering when working across associations v 0.8.2 - add `ActiveSet.configuration.on_asc_sort_nils_come` configuration v 0.8.1 diff --git a/Gemfile.lock b/Gemfile.lock index b4cea8e..390726a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - actionset (0.8.2) + actionset (0.9.2) activesupport (>= 4.0.2) railties diff --git a/Rakefile b/Rakefile index 82bb534..7cc2370 100644 --- a/Rakefile +++ b/Rakefile @@ -5,4 +5,11 @@ require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) -task default: :spec +task :full_spec do + ENV['COVERAGE'] = 'true' + ENV['INSPECT_FAILURE'] = 'true' + ENV['LOGICALLY_EXHAUSTIVE_REQUEST_SPECS'] = 'true' + Rake::Task['spec'].invoke +end + +task default: :full_spec diff --git a/actionset.gemspec b/actionset.gemspec index 412412d..62024bc 100644 --- a/actionset.gemspec +++ b/actionset.gemspec @@ -6,7 +6,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) Gem::Specification.new do |spec| spec.platform = Gem::Platform::RUBY spec.name = 'actionset' - spec.version = '0.8.2' + spec.version = '0.9.2' spec.authors = ['Stephen Margheim'] spec.email = ['stephen.margheim@gmail.com'] diff --git a/lib/action_set.rb b/lib/action_set.rb index df0d3f1..5ed81bd 100644 --- a/lib/action_set.rb +++ b/lib/action_set.rb @@ -5,7 +5,8 @@ require 'active_support/lazy_load_hooks' require 'active_set' -require_relative './action_set/attribute_value' +require_relative './action_set/filter_instructions' +require_relative './action_set/sort_instructions' require_relative './action_set/helpers/helper_methods' module ActionSet @@ -24,13 +25,13 @@ def process_set(set) def filter_set(set) active_set = ensure_active_set(set) - active_set = active_set.filter(filter_instructions_for(set)) if filter_params.any? + active_set = active_set.filter(FilterInstructions.new(filter_params, set, self).get) if filter_params.any? active_set end def sort_set(set) active_set = ensure_active_set(set) - active_set = active_set.sort(sort_params) if sort_params.any? + active_set = active_set.sort(SortInstructions.new(sort_params, set, self).get) if sort_params.any? active_set end @@ -52,30 +53,11 @@ def export_set(set) private - def filter_instructions_for(set) - flatten_keys_of(filter_params).reject { |_, v| v.try(:empty?) }.each_with_object({}) do |(keypath, value), memo| - typecast_value = if value.respond_to?(:each) - value.map { |v| filter_typecasted_value_for(keypath, v, set) } - else - filter_typecasted_value_for(keypath, value, set) - end - - memo[keypath] = typecast_value - end - end - - def filter_typecasted_value_for(keypath, value, set) - instruction = ActiveSet::AttributeInstruction.new(keypath, value) - item_with_value = set.find { |i| !instruction.value_for(item: i).nil? } - item_value = instruction.value_for(item: item_with_value) - ActionSet::AttributeValue.new(value) - .cast(to: item_value.class) - end - def paginate_instructions paginate_params.transform_values(&:to_i) end + # rubocop:disable Metrics/AbcSize def export_instructions {}.tap do |struct| struct[:format] = export_params[:format] || request.format.symbol @@ -93,10 +75,13 @@ def export_instructions elsif respond_to?(:export_set_columns, true) send(:export_set_columns) else + # :nocov: [{}] + # :nocov: end end end + # rubocop:enable Metrics/AbcSize def filter_params params.fetch(:filter, {}).to_unsafe_hash diff --git a/lib/action_set/attribute_value.rb b/lib/action_set/attribute_value.rb index 62aa42d..c6d8e54 100644 --- a/lib/action_set/attribute_value.rb +++ b/lib/action_set/attribute_value.rb @@ -11,7 +11,7 @@ def initialize(value) def cast(to:) adapters.reduce(nil) do |_, adapter| mayble_value_or_nil = adapter.new(@raw, to).process - next if mayble_value_or_nil.nil? + next nil if mayble_value_or_nil.nil? return mayble_value_or_nil end @@ -62,7 +62,9 @@ class ActiveModelAdapter begin require 'active_model/type' rescue LoadError + # :nocov: require 'active_record/type' + # :nocov: end def initialize(raw, target) @@ -106,13 +108,17 @@ def can_typecast?(const_name) def init_typecaster(const_name) type_class.const_get(const_name).new rescue StandardError + # :nocov: nil + # :nocov: end def type_class ActiveModel::Type rescue NameError + # :nocov: ActiveRecord::Type + # :nocov: end end diff --git a/lib/action_set/filter_instructions.rb b/lib/action_set/filter_instructions.rb new file mode 100644 index 0000000..9502393 --- /dev/null +++ b/lib/action_set/filter_instructions.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative './attribute_value' + +module ActionSet + class FilterInstructions + def initialize(params, set, controller) + @params = params + @set = set + @controller = controller + end + + def get + instructions_hash = if form_friendly_complex_params? + form_friendly_complex_params_to_hash + elsif form_friendly_simple_params? + form_friendly_simple_params_to_hash + else + @params + end + flattened_instructions = flatten_keys_of(instructions_hash).reject { |_, v| v.try(:empty?) } + flattened_instructions.each_with_object({}) do |(keypath, value), memo| + memo[keypath] = if value.respond_to?(:map) + value.map { |v| AttributeValue.new(v).cast(to: klass_for_keypath(keypath, v, @set)) } + else + AttributeValue.new(value).cast(to: klass_for_keypath(keypath, value, @set)) + end + end + end + + private + + def form_friendly_complex_params? + @params.key?(:'0') + end + + def form_friendly_simple_params? + @params.key?(:attribute) && + @params.key?(:operator) && + @params.key?(:query) + end + + def form_friendly_complex_params_to_hash + ordered_instructions = @params.sort_by(&:first) + array_of_instructions = ordered_instructions.map { |_, h| ["#{h[:attribute]}(#{h[:operator]})", h[:query]] } + Hash[array_of_instructions] + end + + def form_friendly_simple_params_to_hash + { "#{@params[:attribute]}(#{@params[:operator]})" => @params[:query] } + end + + def klass_for_keypath(keypath, value, set) + if @controller.respond_to?(:filter_set_types, true) + type_declarations = @controller.public_send(:filter_set_types) + types = type_declarations['types'] || type_declarations[:types] + klass = types[keypath.join('.')] + return klass if klass + end + + if set.is_a?(ActiveRecord::Relation) || set.view.is_a?(ActiveRecord::Relation) + klass_type = set.model.columns_hash.fetch(keypath, nil)&.type + return klass_type.class if klass_type + end + + instruction = ActiveSet::AttributeInstruction.new(keypath, value) + item_with_value = set.find { |i| !instruction.value_for(item: i).nil? } + item_value = instruction.value_for(item: item_with_value) + item_value.class + end + end +end diff --git a/lib/action_set/helpers/helper_methods.rb b/lib/action_set/helpers/helper_methods.rb index ea53af2..a303db3 100644 --- a/lib/action_set/helpers/helper_methods.rb +++ b/lib/action_set/helpers/helper_methods.rb @@ -2,7 +2,7 @@ require_relative './sort/link_for_helper' require_relative './pagination/links_for_helper' -require_relative './pagination/path_for_helper' +require_relative './pagination/record_description_for_helper' require_relative './params/form_for_object_helper' require_relative './export/path_for_helper' @@ -11,6 +11,7 @@ module Helpers module HelperMethods include Sort::LinkForHelper include Pagination::LinksForHelper + include Pagination::RecordDescriptionForHelper include Params::FormForObjectHelper include Export::PathForHelper end diff --git a/lib/action_set/helpers/pagination/record_description_for_helper.rb b/lib/action_set/helpers/pagination/record_description_for_helper.rb new file mode 100644 index 0000000..2a2ac89 --- /dev/null +++ b/lib/action_set/helpers/pagination/record_description_for_helper.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative './record_range_for_helper' +require_relative './record_size_for_helper' + +module Pagination + module RecordDescriptionForHelper + include Pagination::RecordRangeForHelper + include Pagination::RecordSizeForHelper + + def pagination_record_description_for(set) + [ + pagination_record_range_for(set), + 'of', + "#{pagination_record_size_for(set)}", + 'records' + ].join(' ').html_safe + end + end +end diff --git a/lib/action_set/helpers/pagination/record_first_for_helper.rb b/lib/action_set/helpers/pagination/record_first_for_helper.rb new file mode 100644 index 0000000..363e39e --- /dev/null +++ b/lib/action_set/helpers/pagination/record_first_for_helper.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative './current_page_for_helper' +require_relative './page_size_for_helper' + +module Pagination + module RecordFirstForHelper + include Pagination::CurrentPageForHelper + include Pagination::PageSizeForHelper + + def pagination_record_first_for(set) + current_page = pagination_current_page_for(set) + page_size = pagination_page_size_for(set) + + return 1 if current_page == 1 + + ((current_page - 1) * page_size) + 1 + end + end +end diff --git a/lib/action_set/helpers/pagination/record_last_for_helper.rb b/lib/action_set/helpers/pagination/record_last_for_helper.rb new file mode 100644 index 0000000..8dd7b30 --- /dev/null +++ b/lib/action_set/helpers/pagination/record_last_for_helper.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative './current_page_for_helper' +require_relative './record_size_for_helper' +require_relative './page_size_for_helper' +require_relative './total_pages_for_helper' + +module Pagination + module RecordLastForHelper + include Pagination::RecordSizeForHelper + include Pagination::CurrentPageForHelper + include Pagination::PageSizeForHelper + include Pagination::TotalPagesForHelper + + def pagination_record_last_for(set) + record_size = pagination_record_size_for(set) + current_page = pagination_current_page_for(set) + page_size = pagination_page_size_for(set) + total_pages = pagination_total_pages_for(set) + + return record_size if current_page >= total_pages + + current_page * page_size + end + end +end diff --git a/lib/action_set/helpers/pagination/record_range_for_helper.rb b/lib/action_set/helpers/pagination/record_range_for_helper.rb new file mode 100644 index 0000000..be6435d --- /dev/null +++ b/lib/action_set/helpers/pagination/record_range_for_helper.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative './record_first_for_helper' +require_relative './record_last_for_helper' + +module Pagination + module RecordRangeForHelper + include Pagination::CurrentPageForHelper + include Pagination::TotalPagesForHelper + include Pagination::RecordFirstForHelper + include Pagination::RecordLastForHelper + + def pagination_record_range_for(set) + current_page = pagination_current_page_for(set) + total_pages = pagination_total_pages_for(set) + return 'None' if current_page > total_pages + + [ + pagination_record_first_for(set), + '–', + pagination_record_last_for(set) + ].join(' ').html_safe + end + end +end diff --git a/lib/action_set/helpers/pagination/record_size_for_helper.rb b/lib/action_set/helpers/pagination/record_size_for_helper.rb new file mode 100644 index 0000000..85ba182 --- /dev/null +++ b/lib/action_set/helpers/pagination/record_size_for_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Pagination + module RecordSizeForHelper + def pagination_record_size_for(set) + set.instructions.dig(:paginate, :count) + end + end +end diff --git a/lib/action_set/helpers/pagination/total_pages_for_helper.rb b/lib/action_set/helpers/pagination/total_pages_for_helper.rb index 4a977e3..658b867 100644 --- a/lib/action_set/helpers/pagination/total_pages_for_helper.rb +++ b/lib/action_set/helpers/pagination/total_pages_for_helper.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true +require_relative './record_size_for_helper' require_relative './page_size_for_helper' module Pagination module TotalPagesForHelper + include Pagination::RecordSizeForHelper include Pagination::PageSizeForHelper def pagination_total_pages_for(set) - total_set_size = set.instructions.dig(:paginate, :count) + total_set_size = pagination_record_size_for(set) return 1 if total_set_size.zero? (total_set_size.to_f / pagination_page_size_for(set)).ceil diff --git a/lib/action_set/sort_instructions.rb b/lib/action_set/sort_instructions.rb new file mode 100644 index 0000000..eee06ab --- /dev/null +++ b/lib/action_set/sort_instructions.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ActionSet + class SortInstructions + def initialize(params, set, controller) + @params = params + @set = set + @controller = controller + end + + def get + instructions_hash = if form_friendly_complex_params? + form_friendly_complex_params_to_hash + elsif form_friendly_simple_params? + form_friendly_simple_params_to_hash + else + @params + end + + instructions_hash.transform_values { |v| v.remove('ending') } + end + + private + + def form_friendly_complex_params? + @params.key?(:'0') + end + + def form_friendly_simple_params? + @params.key?(:attribute) && + @params.key?(:direction) + end + + def form_friendly_complex_params_to_hash + ordered_instructions = @params.sort_by(&:first) + array_of_instructions = ordered_instructions.map { |_, h| [h[:attribute], h[:direction]] } + Hash[array_of_instructions] + end + + def form_friendly_simple_params_to_hash + { @params[:attribute] => @params[:direction] } + end + end +end diff --git a/lib/active_set/active_record_set_instruction.rb b/lib/active_set/active_record_set_instruction.rb index ee4ab37..635338d 100644 --- a/lib/active_set/active_record_set_instruction.rb +++ b/lib/active_set/active_record_set_instruction.rb @@ -9,15 +9,37 @@ def initialize(attribute_instruction, set) end def initial_relation - return @set if @attribute_instruction.associations_array.empty? + return @initial_relation if defined? @initial_relation - @set.eager_load(@attribute_instruction.associations_hash) + @initial_relation = if @attribute_instruction.associations_array.empty? + @set + else + @set.eager_load(@attribute_instruction.associations_hash) + end end - def arel_type - attribute_model - .columns_hash[@attribute_instruction.attribute] - .type + def arel_column + return @arel_column if defined? @arel_column + + arel_column = arel_table[@attribute_instruction.attribute] + arel_column = arel_column.lower if case_insensitive_operation? + + @arel_column = arel_column + end + + def arel_column_name + arel_table[@attribute_instruction.attribute].name + end + + def attribute_model + return @set.klass if @attribute_instruction.associations_array.empty? + return @attribute_model if defined? @attribute_model + + @attribute_model = @attribute_instruction + .associations_array + .reduce(@set) do |obj, assoc| + obj.reflections[assoc.to_s]&.klass + end end def arel_table @@ -31,41 +53,16 @@ def arel_table end end - # rubocop:disable Lint/UnderscorePrefixedVariableName - def arel_column - _arel_column = arel_table[@attribute_instruction.attribute] - return _arel_column.lower if case_insensitive_operation? + private - _arel_column - end - # rubocop:enable Lint/UnderscorePrefixedVariableName - - def arel_operator - @attribute_instruction.operator(default: :eq) - end - - # rubocop:disable Lint/UnderscorePrefixedVariableName - def arel_value - _arel_value = @attribute_instruction.value - return _arel_value.downcase if case_insensitive_operation? - - _arel_value + def arel_type + attribute_model + &.columns_hash[@attribute_instruction.attribute] + &.type end - # rubocop:enable Lint/UnderscorePrefixedVariableName def case_insensitive_operation? @attribute_instruction.case_insensitive? && arel_type.presence_in(%i[string text]) end - - def attribute_model - return @set.klass if @attribute_instruction.associations_array.empty? - return @attribute_model if defined? @attribute_model - - @attribute_model = @attribute_instruction - .associations_array - .reduce(@set) do |obj, assoc| - obj.reflections[assoc.to_s]&.klass - end - end end end diff --git a/lib/active_set/attribute_instruction.rb b/lib/active_set/attribute_instruction.rb index 72e9fd1..21cf6e2 100644 --- a/lib/active_set/attribute_instruction.rb +++ b/lib/active_set/attribute_instruction.rb @@ -32,11 +32,11 @@ def attribute @attribute = attribute end - def operator(default: '==') + def operator return @operator if defined? @operator attribute_instruction = @keypath.last - @operator = (attribute_instruction[operator_regex, 1] || default).to_sym + @operator = attribute_instruction[operator_regex, 1]&.to_sym end def options diff --git a/lib/active_set/enumerable_set_instruction.rb b/lib/active_set/enumerable_set_instruction.rb index 56ad0ff..30ac506 100644 --- a/lib/active_set/enumerable_set_instruction.rb +++ b/lib/active_set/enumerable_set_instruction.rb @@ -9,40 +9,27 @@ def initialize(attribute_instruction, set) end def attribute_value_for(item) - item_value = @attribute_instruction - .value_for(item: item) - item_value = item_value.downcase if case_insensitive_operation_for?(item_value) - item_value + @item_values ||= Hash.new do |h, key| + item_value = @attribute_instruction.value_for(item: key) + item_value = item_value.downcase if case_insensitive_operation_for?(item_value) + h[key] = item_value + end + + @item_values[item] end - # rubocop:disable Lint/UnderscorePrefixedVariableName - def attribute_value - _attribute_value = @attribute_instruction.value - _attribute_value = _attribute_value.downcase if case_insensitive_operation_for?(_attribute_value) - _attribute_value + def instruction_value + return @instruction_value if defined? @instruction_value + + instruction_value = @attribute_instruction.value + instruction_value = instruction_value.downcase if case_insensitive_operation_for?(instruction_value) + @instruction_value = instruction_value end - # rubocop:enable Lint/UnderscorePrefixedVariableName def case_insensitive_operation_for?(value) return false unless @attribute_instruction.case_insensitive? value.is_a?(String) || value.is_a?(Symbol) end - - def attribute_instance - set_item = @set.find(&:present?) - return set_item if @attribute_instruction.associations_array.empty? - return @attribute_model if defined? @attribute_model - - @attribute_model = @attribute_instruction - .associations_array - .reduce(set_item) do |obj, assoc| - obj.public_send(assoc) - end - end - - def attribute_class - attribute_instance&.class - end end end diff --git a/lib/active_set/filtering/active_record/operators.rb b/lib/active_set/filtering/active_record/operators.rb new file mode 100644 index 0000000..c1954fb --- /dev/null +++ b/lib/active_set/filtering/active_record/operators.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +require_relative '../constants' + +class ActiveSet + module Filtering + module ActiveRecord + # rubocop:disable Metrics/ModuleLength + module Operators + RANGE_TRANSFORMER = proc do |raw:, sql:, type:| + if type.presence_in %i[boolean] + Range.new(*sql.sort) + else + Range.new(*raw.sort) + end + end + BLANK_TRANSFORMER = proc do |type:, **_ctx| + if type.presence_in %i[date float integer] + [nil] + else + Constants::BLANK_VALUES + end + end + MATCHER_TRANSFORMER = proc do |sql:, type:, **ctx| + next sql.map { |str| MATCHER_TRANSFORMER.call(sql: str, type: type, **ctx) } if sql.respond_to?(:map) + + next sql if type != :decimal + next sql[0..-3] if sql.ends_with?('.0') + next sql[0..-4] + '%' if sql.ends_with?('.0%') + + sql + end + START_MATCHER_TRANSFORMER = proc do |sql:, type:, **ctx| + next sql.map { |str| START_MATCHER_TRANSFORMER.call(sql: str, type: type, **ctx) } if sql.respond_to?(:map) + + str = MATCHER_TRANSFORMER.call(sql: sql, type: type, **ctx) + + str + '%' + end + END_MATCHER_TRANSFORMER = proc do |sql:, type:, **ctx| + next sql.map { |str| END_MATCHER_TRANSFORMER.call(sql: str, type: type, **ctx) } if sql.respond_to?(:map) + + str = MATCHER_TRANSFORMER.call(sql: sql, type: type, **ctx) + + '%' + str + end + CONTAIN_MATCHER_TRANSFORMER = proc do |sql:, type:, **ctx| + next sql.map { |str| CONTAIN_MATCHER_TRANSFORMER.call(sql: str, type: type, **ctx) } if sql.respond_to?(:map) + + str = MATCHER_TRANSFORMER.call(sql: sql, type: type, **ctx) + + '%' + str + '%' + end + + PREDICATES = { + EQ: { + operator: :eq + }, + NOT_EQ: { + operator: :not_eq + }, + EQ_ANY: { + operator: :eq_any + }, + EQ_ALL: { + operator: :eq_all + }, + NOT_EQ_ANY: { + operator: :not_eq_any + }, + NOT_EQ_ALL: { + operator: :not_eq_all + }, + + IN: { + operator: :in + }, + NOT_IN: { + operator: :not_in + }, + IN_ANY: { + operator: :in_any + }, + IN_ALL: { + operator: :in_all + }, + NOT_IN_ANY: { + operator: :not_in_any + }, + NOT_IN_ALL: { + operator: :not_in_all + }, + + MATCHES: { + operator: :matches, + query_attribute_transformer: MATCHER_TRANSFORMER + }, + DOES_NOT_MATCH: { + operator: :does_not_match, + query_attribute_transformer: MATCHER_TRANSFORMER + }, + MATCHES_ANY: { + operator: :matches_any, + query_attribute_transformer: MATCHER_TRANSFORMER + }, + MATCHES_ALL: { + operator: :matches_all, + query_attribute_transformer: MATCHER_TRANSFORMER + }, + DOES_NOT_MATCH_ANY: { + operator: :does_not_match_any, + query_attribute_transformer: MATCHER_TRANSFORMER + }, + DOES_NOT_MATCH_ALL: { + operator: :does_not_match_all, + query_attribute_transformer: MATCHER_TRANSFORMER + }, + + LT: { + operator: :lt + }, + LTEQ: { + operator: :lteq + }, + LT_ANY: { + operator: :lt_any + }, + LT_ALL: { + operator: :lt_all + }, + LTEQ_ANY: { + operator: :lteq_any + }, + LTEQ_ALL: { + operator: :lteq_all + }, + + GT: { + operator: :gt + }, + GTEQ: { + operator: :gteq + }, + GT_ANY: { + operator: :gt_any + }, + GT_ALL: { + operator: :gt_all + }, + GTEQ_ANY: { + operator: :gteq_any + }, + GTEQ_ALL: { + operator: :gteq_all + }, + + BETWEEN: { + operator: :between, + query_attribute_transformer: RANGE_TRANSFORMER + }, + NOT_BETWEEN: { + operator: :not_between, + query_attribute_transformer: RANGE_TRANSFORMER + }, + + IS_TRUE: { + operator: :eq, + query_attribute_transformer: proc { |_| true } + }, + IS_FALSE: { + operator: :eq, + query_attribute_transformer: proc { |_| false } + }, + + IS_NULL: { + operator: :eq + }, + NOT_NULL: { + operator: :not_eq + }, + + IS_PRESENT: { + operator: :not_eq_all, + query_attribute_transformer: BLANK_TRANSFORMER + }, + IS_BLANK: { + operator: :eq_any, + query_attribute_transformer: BLANK_TRANSFORMER + }, + + MATCH_START: { + operator: :matches, + query_attribute_transformer: START_MATCHER_TRANSFORMER + }, + MATCH_START_ANY: { + operator: :matches_any, + query_attribute_transformer: START_MATCHER_TRANSFORMER + }, + MATCH_START_ALL: { + operator: :matches_all, + query_attribute_transformer: START_MATCHER_TRANSFORMER + }, + MATCH_NOT_START: { + operator: :does_not_match, + query_attribute_transformer: START_MATCHER_TRANSFORMER + }, + MATCH_NOT_START_ANY: { + operator: :does_not_match_any, + query_attribute_transformer: START_MATCHER_TRANSFORMER + }, + MATCH_NOT_START_ALL: { + operator: :does_not_match_all, + query_attribute_transformer: START_MATCHER_TRANSFORMER + }, + MATCH_END: { + operator: :matches, + query_attribute_transformer: END_MATCHER_TRANSFORMER + }, + MATCH_END_ANY: { + operator: :matches_any, + query_attribute_transformer: END_MATCHER_TRANSFORMER + }, + MATCH_END_ALL: { + operator: :matches_all, + query_attribute_transformer: END_MATCHER_TRANSFORMER + }, + MATCH_NOT_END: { + operator: :does_not_match, + query_attribute_transformer: END_MATCHER_TRANSFORMER + }, + MATCH_NOT_END_ANY: { + operator: :does_not_match_any, + query_attribute_transformer: END_MATCHER_TRANSFORMER + }, + MATCH_NOT_END_ALL: { + operator: :does_not_match_all, + query_attribute_transformer: END_MATCHER_TRANSFORMER + }, + MATCH_CONTAIN: { + operator: :matches, + query_attribute_transformer: CONTAIN_MATCHER_TRANSFORMER + }, + MATCH_CONTAIN_ANY: { + operator: :matches_any, + query_attribute_transformer: CONTAIN_MATCHER_TRANSFORMER + }, + MATCH_CONTAIN_ALL: { + operator: :matches_all, + query_attribute_transformer: CONTAIN_MATCHER_TRANSFORMER + }, + MATCH_NOT_CONTAIN: { + operator: :does_not_match, + query_attribute_transformer: CONTAIN_MATCHER_TRANSFORMER + }, + MATCH_NOT_CONTAIN_ANY: { + operator: :does_not_match_any, + query_attribute_transformer: CONTAIN_MATCHER_TRANSFORMER + }, + MATCH_NOT_CONTAIN_ALL: { + operator: :does_not_match_all, + query_attribute_transformer: CONTAIN_MATCHER_TRANSFORMER + } + }.freeze + + def self.get(operator_name) + operator_key = operator_name.to_s.upcase.to_sym + + base_operator_hash = Constants::PREDICATES.fetch(operator_key, {}) + this_operator_hash = Operators::PREDICATES.fetch(operator_key, {}) + + base_operator_hash.merge(this_operator_hash) + end + end + # rubocop:enable Metrics/ModuleLength + end + end +end diff --git a/lib/active_set/filtering/active_record/query_column.rb b/lib/active_set/filtering/active_record/query_column.rb new file mode 100644 index 0000000..c5a8666 --- /dev/null +++ b/lib/active_set/filtering/active_record/query_column.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class ActiveSet + module Filtering + module ActiveRecord + module QueryColumn + def query_column + return @query_column if defined? @query_column + + @query_column = if must_cast_numerical_column? + column_cast_as_char + else + arel_column + end + end + + private + + def column_cast_as_char + # In order to use LIKE, we must CAST the numeric column as a CHAR column. + # NOTE: this is can be quite inefficient, as it forces the DB engine to perform that cast on all rows. + # https://www.ryadel.com/en/like-operator-equivalent-integer-numeric-columns-sql-t-sql-database/ + Arel::Nodes::NamedFunction.new('CAST', [arel_column.as('CHAR')]) + end + + def must_cast_numerical_column? + # The LIKE operator can't be used if the column hosts numeric types. + return false unless arel_type.presence_in(%i[integer float]) + + arel_operator.to_s.downcase.include?('match') + end + end + end + end +end diff --git a/lib/active_set/filtering/active_record/query_value.rb b/lib/active_set/filtering/active_record/query_value.rb new file mode 100644 index 0000000..b125301 --- /dev/null +++ b/lib/active_set/filtering/active_record/query_value.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class ActiveSet + module Filtering + module ActiveRecord + module QueryValue + def query_value + return @query_value if defined? @query_value + + query_value = @attribute_instruction.value + query_value = query_attribute_for(query_value) + query_value = query_value.downcase if case_insensitive_operation? + + @query_value = query_value + end + + private + + def query_attribute_for(value) + return value unless operator_hash.key?(:query_attribute_transformer) + + context = { + raw: value, + sql: to_sql_str(value), + type: arel_type + } + + operator_hash[:query_attribute_transformer].call(context) + end + + def to_sql_str(value) + return value.map { |a| to_sql_str(a) } if value.respond_to?(:map) + + arel_node = Arel::Nodes::Casted.new(value, arel_column) + sql_value = arel_node.to_sql + unwrap_sql_str(sql_value) + end + + def unwrap_sql_str(sql_str) + return sql_str unless sql_str[0] == "'" && sql_str[-1] == "'" + + sql_str[1..-2] + end + end + end + end +end diff --git a/lib/active_set/filtering/active_record/set_instruction.rb b/lib/active_set/filtering/active_record/set_instruction.rb new file mode 100644 index 0000000..c94eb6f --- /dev/null +++ b/lib/active_set/filtering/active_record/set_instruction.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative '../../active_record_set_instruction' +require_relative './operators' +require_relative './query_value' +require_relative './query_column' + +class ActiveSet + module Filtering + module ActiveRecord + class SetInstruction < ActiveRecordSetInstruction + include QueryValue + include QueryColumn + + def arel_operator + operator_hash.fetch(:operator, :eq) + end + + private + + def operator_hash + instruction_operator = @attribute_instruction.operator + + Operators.get(instruction_operator) + end + end + end + end +end diff --git a/lib/active_set/filtering/active_record/strategy.rb b/lib/active_set/filtering/active_record/strategy.rb new file mode 100644 index 0000000..56044da --- /dev/null +++ b/lib/active_set/filtering/active_record/strategy.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require_relative './set_instruction' +require 'active_support/core_ext/module/delegation' + +class ActiveSet + module Filtering + module ActiveRecord + class Strategy + delegate :attribute_model, + :query_column, + :arel_operator, + :query_value, + :arel_type, + :initial_relation, + :attribute, + to: :@set_instruction + + def initialize(set, attribute_instruction) + @set = set + @attribute_instruction = attribute_instruction + @set_instruction = SetInstruction.new(attribute_instruction, set) + end + + def execute + return false unless @set.respond_to? :to_sql + + if execute_filter_operation? + statement = filter_operation + elsif execute_intersect_operation? + begin + statement = intersect_operation + rescue ArgumentError # thrown if merging a non-ActiveRecord::Relation + return false + end + else + return false + end + + statement + end + + private + + def execute_filter_operation? + return false unless attribute_model + return false unless attribute_model.respond_to?(:attribute_names) + return false unless attribute_model.attribute_names.include?(attribute) + + true + end + + def execute_intersect_operation? + return false unless attribute_model + return false unless attribute_model.respond_to?(attribute) + return false if attribute_model.method(attribute).arity.zero? + + true + end + + def filter_operation + initial_relation + .where( + query_column.send( + arel_operator, + query_value + ) + ) + end + + def intersect_operation + # NOTE: If merging relations that contain duplicate column conditions, + # the second condition will replace the first. + # e.g. Thing.where(id: [1,2]).merge(Thing.where(id: [2,3])) + # => [Thing<2>, Thing<3>] NOT [Thing<2>] + initial_relation + .merge( + attribute_model.public_send( + attribute, + query_value + ) + ) + end + end + end + end +end diff --git a/lib/active_set/filtering/active_record_strategy.rb b/lib/active_set/filtering/active_record_strategy.rb deleted file mode 100644 index cb69872..0000000 --- a/lib/active_set/filtering/active_record_strategy.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -require_relative '../active_record_set_instruction' -require 'active_support/core_ext/module/delegation' - -class ActiveSet - module Filtering - class ActiveRecordStrategy - delegate :attribute_model, - :arel_column, - :arel_operator, - :arel_value, - :arel_type, - :initial_relation, - :attribute, - to: :@set_instruction - - def initialize(set, attribute_instruction) - @set = set - @attribute_instruction = attribute_instruction - @set_instruction = ActiveRecordSetInstruction.new(attribute_instruction, set) - end - - def execute - return false unless @set.respond_to? :to_sql - - if execute_filter_operation? - statement = filter_operation - elsif execute_intersect_operation? - begin - statement = intersect_operation - rescue ArgumentError # thrown if merging a non-ActiveRecord::Relation - return false - end - else - return false - end - - statement - end - - private - - def execute_filter_operation? - return false unless attribute_model - return false unless attribute_model.respond_to?(:attribute_names) - return false unless attribute_model.attribute_names.include?(attribute) - - true - end - - def execute_intersect_operation? - return false unless attribute_model - return false unless attribute_model.respond_to?(attribute) - return false if attribute_model.method(attribute).arity.zero? - - true - end - - def filter_operation - initial_relation - .where( - arel_column.send( - arel_operator, - arel_value - ) - ) - end - - def intersect_operation - # NOTE: If merging relations that contain duplicate column conditions, - # the second condition will replace the first. - # e.g. Thing.where(id: [1,2]).merge(Thing.where(id: [2,3])) - # => [Thing<2>, Thing<3>] NOT [Thing<2>] - initial_relation - .merge( - attribute_model.public_send( - attribute, - arel_value - ) - ) - end - end - end -end diff --git a/lib/active_set/filtering/constants.rb b/lib/active_set/filtering/constants.rb new file mode 100644 index 0000000..77b5037 --- /dev/null +++ b/lib/active_set/filtering/constants.rb @@ -0,0 +1,349 @@ +# frozen_string_literal: true + +class ActiveSet + module Filtering + # rubocop:disable Metrics/ModuleLength + module Constants + BLANK_VALUES = [nil, ''].freeze + + PREDICATES = { + EQ: { + type: :binary, + compound: false, + behavior: :inclusive, + shorthand: :'==' + }, + NOT_EQ: { + type: :binary, + compound: false, + behavior: :exclusive, + shorthand: :'!=' + }, + EQ_ANY: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'E==' + }, + EQ_ALL: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'A==' + }, + NOT_EQ_ANY: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'E!=' + }, + NOT_EQ_ALL: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'A!=' + }, + + IN: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'<<' + }, + NOT_IN: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'!<' + }, + IN_ANY: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'E<<' + }, + IN_ALL: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'A<<' + }, + NOT_IN_ANY: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'E!<' + }, + NOT_IN_ALL: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'A!<' + }, + + MATCHES: { + type: :binary, + compound: false, + behavior: :inclusive, + shorthand: :'=~' + }, + DOES_NOT_MATCH: { + type: :binary, + compound: false, + behavior: :exclusive, + shorthand: :'!~' + }, + MATCHES_ANY: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'E=~' + }, + MATCHES_ALL: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'A=~' + }, + DOES_NOT_MATCH_ANY: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'E!~' + }, + DOES_NOT_MATCH_ALL: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'A!~' + }, + + LT: { + type: :binary, + compound: false, + behavior: :exclusive, + shorthand: :'<' + }, + LTEQ: { + type: :binary, + compound: false, + behavior: :inclusive, + shorthand: :'<=' + }, + LT_ANY: { + type: :binary, + compound: true, + behavior: :inconclusive, + shorthand: :'E<' + }, + LT_ALL: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'A<' + }, + LTEQ_ANY: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'E<=' + }, + LTEQ_ALL: { + type: :binary, + compound: true, + behavior: :inconclusive, + shorthand: :'A<=' + }, + + GT: { + type: :binary, + compound: false, + behavior: :exclusive, + shorthand: :'>' + }, + GTEQ: { + type: :binary, + compound: false, + behavior: :inclusive, + shorthand: :'>=' + }, + GT_ANY: { + type: :binary, + compound: true, + behavior: :inconclusive, + shorthand: :'E>' + }, + GT_ALL: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'A>' + }, + GTEQ_ANY: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'E>=' + }, + GTEQ_ALL: { + type: :binary, + compound: true, + behavior: :inconclusive, + shorthand: :'A>=' + }, + + BETWEEN: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'..' + }, + NOT_BETWEEN: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'!.' + }, + + IS_TRUE: { + type: :unary, + operator: :eq, + compound: false, + behavior: :inconclusive + }, + IS_FALSE: { + type: :unary, + operator: :eq, + compound: false, + behavior: :inconclusive + }, + + IS_NULL: { + type: :unary, + operator: :eq, + compound: false, + behavior: :exclusive, + query_attribute_transformer: proc { |_| nil } + }, + NOT_NULL: { + type: :unary, + operator: :not_eq, + compound: false, + behavior: :inclusive, + query_attribute_transformer: proc { |_| nil } + }, + + IS_PRESENT: { + type: :unary, + operator: :not_eq_all, + compound: false, + behavior: :inclusive + }, + IS_BLANK: { + type: :unary, + operator: :eq_any, + compound: false, + behavior: :exclusive + }, + + MATCH_START: { + type: :binary, + compound: false, + behavior: :inclusive + }, + MATCH_START_ANY: { + type: :binary, + compound: true, + behavior: :inclusive + }, + MATCH_START_ALL: { + type: :binary, + compound: true, + behavior: :exclusive + }, + MATCH_NOT_START: { + type: :binary, + compound: false, + behavior: :exclusive + }, + MATCH_NOT_START_ANY: { + type: :binary, + compound: true, + behavior: :inclusive + }, + MATCH_NOT_START_ALL: { + type: :binary, + compound: true, + behavior: :exclusive + }, + MATCH_END: { + type: :binary, + compound: false, + behavior: :inclusive + }, + MATCH_END_ANY: { + type: :binary, + compound: true, + behavior: :inclusive + }, + MATCH_END_ALL: { + type: :binary, + compound: true, + behavior: :exclusive + }, + MATCH_NOT_END: { + type: :binary, + compound: false, + behavior: :exclusive + }, + MATCH_NOT_END_ANY: { + type: :binary, + compound: true, + behavior: :inclusive + }, + MATCH_NOT_END_ALL: { + type: :binary, + compound: true, + behavior: :exclusive + }, + MATCH_CONTAIN: { + type: :binary, + compound: false, + behavior: :inclusive, + shorthand: :'~*' + }, + MATCH_CONTAIN_ANY: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'E~*' + }, + MATCH_CONTAIN_ALL: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'A~*' + }, + MATCH_NOT_CONTAIN: { + type: :binary, + compound: false, + behavior: :exclusive, + shorthand: :'!*' + }, + MATCH_NOT_CONTAIN_ANY: { + type: :binary, + compound: true, + behavior: :inclusive, + shorthand: :'E!*' + }, + MATCH_NOT_CONTAIN_ALL: { + type: :binary, + compound: true, + behavior: :exclusive, + shorthand: :'A!*' + } + }.freeze + end + # rubocop:enable Metrics/ModuleLength + end +end diff --git a/lib/active_set/filtering/enumerable/operators.rb b/lib/active_set/filtering/enumerable/operators.rb new file mode 100644 index 0000000..fe0ade1 --- /dev/null +++ b/lib/active_set/filtering/enumerable/operators.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +require_relative '../constants' + +class ActiveSet + module Filtering + module Enumerable + # rubocop:disable Metrics/ModuleLength + module Operators + NOT_TRANSFORMER = ->(result) { !result } + RANGE_TRANSFORMER = ->(value) { Range.new(*value.sort) } + REGEXP_TRANSFORMER = ->(value) { /#{Regexp.quote(value.to_s)}/ } + STRING_TRANSFORMER = ->(value) { value.to_s } + + PREDICATES = { + EQ: { + operator: :'==' + }, + NOT_EQ: { + operator: :'!=' + }, + EQ_ANY: { + operator: :'==', + reducer: :any? + }, + EQ_ALL: { + operator: :'==', + reducer: :all? + }, + NOT_EQ_ANY: { + operator: :'!=', + reducer: :any? + }, + NOT_EQ_ALL: { + operator: :'!=', + reducer: :all? + }, + + IN: { + operator: :presence_in + }, + NOT_IN: { + operator: :presence_in, + result_transformer: NOT_TRANSFORMER + }, + IN_ANY: { + operator: :presence_in, + reducer: :any? + }, + IN_ALL: { + operator: :presence_in, + reducer: :all? + }, + NOT_IN_ANY: { + operator: :presence_in, + reducer: :any?, + result_transformer: NOT_TRANSFORMER + }, + NOT_IN_ALL: { + operator: :presence_in, + reducer: :all?, + result_transformer: NOT_TRANSFORMER + }, + + MATCHES: { + operator: :'=~', + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: REGEXP_TRANSFORMER + }, + DOES_NOT_MATCH: { + operator: :'!~', + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: REGEXP_TRANSFORMER + }, + MATCHES_ANY: { + operator: :'=~', + reducer: :any?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: REGEXP_TRANSFORMER + }, + MATCHES_ALL: { + operator: :'=~', + reducer: :all?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: REGEXP_TRANSFORMER + }, + DOES_NOT_MATCH_ANY: { + operator: :'!~', + reducer: :any?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: REGEXP_TRANSFORMER + }, + DOES_NOT_MATCH_ALL: { + operator: :'!~', + reducer: :all?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: REGEXP_TRANSFORMER + }, + + LT: { + operator: :'<' + }, + LTEQ: { + operator: :'<=' + }, + LT_ANY: { + operator: :'<', + reducer: :any? + }, + LT_ALL: { + operator: :'<', + reducer: :all? + }, + LTEQ_ANY: { + operator: :'<=', + reducer: :any? + }, + LTEQ_ALL: { + operator: :'<=', + reducer: :all? + }, + + GT: { + operator: :'>' + }, + GTEQ: { + operator: :'>=' + }, + GT_ANY: { + operator: :'>', + reducer: :any? + }, + GT_ALL: { + operator: :'>', + reducer: :all? + }, + GTEQ_ANY: { + operator: :'>=', + reducer: :any? + }, + GTEQ_ALL: { + operator: :'>=', + reducer: :all? + }, + + BETWEEN: { + operator: :cover?, + query_attribute_transformer: RANGE_TRANSFORMER + }, + NOT_BETWEEN: { + operator: :cover?, + query_attribute_transformer: RANGE_TRANSFORMER, + result_transformer: NOT_TRANSFORMER + }, + + IS_TRUE: { + operator: :'==', + query_attribute_transformer: proc { |_| 1 } + }, + IS_FALSE: { + operator: :'==', + query_attribute_transformer: proc { |_| 0 } + }, + + IS_NULL: { + operator: :'==' + }, + NOT_NULL: { + operator: :'!=' + }, + + IS_PRESENT: { + operator: :'!=', + reducer: :all?, + query_attribute_transformer: proc { |_| Constants::BLANK_VALUES } + }, + IS_BLANK: { + operator: :'==', + reducer: :any?, + query_attribute_transformer: proc { |_| Constants::BLANK_VALUES } + }, + + MATCH_START: { + operator: :'start_with?', + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER + }, + MATCH_START_ANY: { + operator: :'start_with?', + reducer: :any?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER + }, + MATCH_START_ALL: { + operator: :'start_with?', + reducer: :all?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER + }, + MATCH_NOT_START: { + operator: :'start_with?', + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER, + result_transformer: NOT_TRANSFORMER + }, + MATCH_NOT_START_ANY: { + operator: :'start_with?', + reducer: :any?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER, + result_transformer: NOT_TRANSFORMER + }, + MATCH_NOT_START_ALL: { + operator: :'start_with?', + reducer: :all?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER, + result_transformer: NOT_TRANSFORMER + }, + MATCH_END: { + operator: :'end_with?', + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER + }, + MATCH_END_ANY: { + operator: :'end_with?', + reducer: :any?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER + }, + MATCH_END_ALL: { + operator: :'end_with?', + reducer: :all?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER + }, + MATCH_NOT_END: { + operator: :'end_with?', + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER, + result_transformer: NOT_TRANSFORMER + }, + MATCH_NOT_END_ANY: { + operator: :'end_with?', + reducer: :any?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER, + result_transformer: NOT_TRANSFORMER + }, + MATCH_NOT_END_ALL: { + operator: :'end_with?', + reducer: :all?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER, + result_transformer: NOT_TRANSFORMER + }, + MATCH_CONTAIN: { + operator: :'include?', + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER + }, + MATCH_CONTAIN_ANY: { + operator: :'include?', + reducer: :any?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER + }, + MATCH_CONTAIN_ALL: { + operator: :'include?', + reducer: :all?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER + }, + MATCH_NOT_CONTAIN: { + operator: :'include?', + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER, + result_transformer: NOT_TRANSFORMER + }, + MATCH_NOT_CONTAIN_ANY: { + operator: :'include?', + reducer: :any?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER, + result_transformer: NOT_TRANSFORMER + }, + MATCH_NOT_CONTAIN_ALL: { + operator: :'include?', + reducer: :all?, + object_attribute_transformer: STRING_TRANSFORMER, + query_attribute_transformer: STRING_TRANSFORMER, + result_transformer: NOT_TRANSFORMER + } + }.freeze + + def self.get(operator_name) + operator_key = operator_name.to_s.upcase.to_sym + + base_operator_hash = Constants::PREDICATES.fetch(operator_key, {}) + this_operator_hash = Operators::PREDICATES.fetch(operator_key, {}) + + base_operator_hash.merge(this_operator_hash) + end + end + # rubocop:enable Metrics/ModuleLength + end + end +end diff --git a/lib/active_set/filtering/enumerable/set_instruction.rb b/lib/active_set/filtering/enumerable/set_instruction.rb new file mode 100644 index 0000000..3bd91cc --- /dev/null +++ b/lib/active_set/filtering/enumerable/set_instruction.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'active_support/core_ext/array/wrap' +require_relative '../../enumerable_set_instruction' +require_relative './operators' + +class ActiveSet + module Filtering + module Enumerable + class SetInstruction < EnumerableSetInstruction + def item_matches_query?(item) + return query_result_for(item, query_attribute_for(instruction_value)) unless operator_hash.key?(:reducer) + + Array.wrap(instruction_value).public_send(operator_hash[:reducer]) do |value| + query_result_for(item, query_attribute_for(value)) + end + end + + def set_item + return @set_item if defined? @set_item + + @set_item = @set.find(&:present?) + end + + def attribute_instance + return set_item if @attribute_instruction.associations_array.empty? + return @attribute_model if defined? @attribute_model + + @attribute_model = @attribute_instruction + .associations_array + .reduce(set_item) do |obj, assoc| + obj.public_send(assoc) + end + end + + def attribute_class + attribute_instance&.class + end + + private + + def query_result_for(item, value) + result = if operator_method == :cover? && value.is_a?(Range) + value + .public_send( + operator_method, + object_attribute_for(item) + ) + else + object_attribute_for(item) + .public_send( + operator_method, + value + ) + end + + return result unless operator_hash.key?(:result_transformer) + + operator_hash[:result_transformer].call(result) + end + + def object_attribute_for(item) + attribute = guarantee_attribute_type(attribute_value_for(item)) + return attribute unless operator_hash.key?(:object_attribute_transformer) + + operator_hash[:object_attribute_transformer].call(attribute) + end + + def query_attribute_for(value) + attribute = guarantee_attribute_type(value) + return attribute unless operator_hash.key?(:query_attribute_transformer) + + operator_hash[:query_attribute_transformer].call(attribute) + end + + def operator_method + operator_hash.dig(:operator) || :'==' + end + + def operator_hash + instruction_operator = @attribute_instruction.operator + + Operators.get(instruction_operator) + end + + def guarantee_attribute_type(attribute) + # Booleans don't respond to many operator methods, + # so we cast them to integers + return 1 if attribute == true + return 0 if attribute == false + return attribute.map { |a| guarantee_attribute_type(a) } if attribute.respond_to?(:each) + + attribute + end + end + end + end +end diff --git a/lib/active_set/filtering/enumerable/strategy.rb b/lib/active_set/filtering/enumerable/strategy.rb new file mode 100644 index 0000000..d668846 --- /dev/null +++ b/lib/active_set/filtering/enumerable/strategy.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require_relative './set_instruction' +require 'active_support/core_ext/module/delegation' + +class ActiveSet + module Filtering + module Enumerable + class Strategy + delegate :attribute_instance, + :attribute_class, + :instruction_value, + :attribute_value_for, + :operator, + :attribute, + :set_item, + :resource_for, + to: :@set_instruction + + def initialize(set, attribute_instruction) + @set = set + @attribute_instruction = attribute_instruction + @set_instruction = SetInstruction.new(attribute_instruction, set) + end + + def execute + return false unless @set.respond_to? :select + + if execute_filter_operation? + set = filter_operation + elsif execute_intersect_operation? + begin + set = intersect_operation + rescue TypeError # thrown if intersecting with a non-Array + return false + end + else + return false + end + + set + end + + private + + def execute_filter_operation? + return false unless attribute_instance + return false unless attribute_instance.respond_to?(attribute) + return false if attribute_instance.method(attribute).arity.positive? + + true + end + + def execute_intersect_operation? + return false unless attribute_class + return false unless attribute_class.respond_to?(attribute) + return false if attribute_class.method(attribute).arity.zero? + + true + end + + def filter_operation + @set.select do |item| + @set_instruction.item_matches_query?(item) + end + end + + def intersect_operation + @set & other_set + end + + def other_set + other_set = attribute_class.public_send( + attribute, + instruction_value + ) + if attribute_class != set_item.class + other_set = begin + @set.select { |item| resource_for(item: item)&.presence_in other_set } + rescue ArgumentError # thrown if other_set is doesn't respond to #include?, like when nil + nil + end + end + + other_set + end + end + end + end +end diff --git a/lib/active_set/filtering/enumerable_strategy.rb b/lib/active_set/filtering/enumerable_strategy.rb deleted file mode 100644 index 5bc50bc..0000000 --- a/lib/active_set/filtering/enumerable_strategy.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -require_relative '../enumerable_set_instruction' -require 'active_support/core_ext/module/delegation' - -class ActiveSet - module Filtering - class EnumerableStrategy - delegate :attribute_instance, - :attribute_class, - :attribute_value, - :attribute_value_for, - :operator, - :attribute, - to: :@set_instruction - - def initialize(set, attribute_instruction) - @set = set - @attribute_instruction = attribute_instruction - @set_instruction = EnumerableSetInstruction.new(attribute_instruction, set) - end - - def execute - return false unless @set.respond_to? :select - - if execute_filter_operation? - set = filter_operation - elsif execute_intersect_operation? - begin - set = intersect_operation - rescue TypeError # thrown if intersecting with a non-Array - return false - end - else - return false - end - - set - end - - private - - def execute_filter_operation? - return false unless attribute_instance - return false unless attribute_instance.respond_to?(attribute) - return false if attribute_instance.method(attribute).arity.positive? - - true - end - - def execute_intersect_operation? - return false unless attribute_class - return false unless attribute_class.respond_to?(attribute) - return false if attribute_class.method(attribute).arity.zero? - - true - end - - def filter_operation - @set.select do |item| - attribute_value_for(item) - .public_send( - operator, - attribute_value - ) - end - end - - def intersect_operation - other_set = attribute_class - .public_send( - attribute, - attribute_value - ) - @set & other_set - end - end - end -end diff --git a/lib/active_set/filtering/operation.rb b/lib/active_set/filtering/operation.rb index dfdc580..e524f97 100644 --- a/lib/active_set/filtering/operation.rb +++ b/lib/active_set/filtering/operation.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true require_relative '../attribute_instruction' -require_relative './enumerable_strategy' -require_relative './active_record_strategy' +require_relative './enumerable/strategy' +require_relative './active_record/strategy' class ActiveSet module Filtering @@ -17,7 +17,7 @@ def execute .map { |k, v| AttributeInstruction.new(k, v) } activerecord_filtered_set = attribute_instructions.reduce(@set) do |set, attribute_instruction| - maybe_set_or_false = ActiveRecordStrategy.new(set, attribute_instruction).execute + maybe_set_or_false = ActiveRecord::Strategy.new(set, attribute_instruction).execute next set unless maybe_set_or_false attribute_instruction.processed = true @@ -27,7 +27,7 @@ def execute return activerecord_filtered_set if attribute_instructions.all?(&:processed?) attribute_instructions.reject(&:processed?).reduce(activerecord_filtered_set) do |set, attribute_instruction| - maybe_set_or_false = EnumerableStrategy.new(set, attribute_instruction).execute + maybe_set_or_false = Enumerable::Strategy.new(set, attribute_instruction).execute next set unless maybe_set_or_false attribute_instruction.processed = true diff --git a/lib/active_set/sorting/active_record_strategy.rb b/lib/active_set/sorting/active_record_strategy.rb index 90aceff..d1d4ad7 100644 --- a/lib/active_set/sorting/active_record_strategy.rb +++ b/lib/active_set/sorting/active_record_strategy.rb @@ -54,7 +54,9 @@ def order_operation_for(set_instruction) arel_column = set_instruction.arel_column arel_direction = direction_operator(set_instruction.value) - attribute_model.order(nil_sorter_for(arel_column, arel_direction)) + attribute_model.order(nil_sorter_for(set_instruction.arel_table, + set_instruction.arel_column_name, + arel_direction)) .order(arel_column.send(arel_direction)) end @@ -64,14 +66,22 @@ def direction_operator(direction) :asc end - def nil_sorter_for(column, direction) - nil_sorter_operator = if ActiveSet.configuration.on_asc_sort_nils_come == :last - direction == :asc ? :eq : :not_eq - else - direction == :asc ? :not_eq : :eq - end + def nil_sorter_for(model, column, direction) + "CASE WHEN #{model.table_name}.#{column} IS NULL #{nil_sorter_then_statement(direction)}" + end + + def nil_sorter_then_statement(direction) + first = 'THEN 0 ELSE 1 END' + last = 'THEN 1 ELSE 0 END' + if ActiveSet.configuration.on_asc_sort_nils_come == :last + return last if direction == :asc - column.send(nil_sorter_operator, nil) + return first + else + return first if direction == :asc + + return last + end end end end diff --git a/lib/helpers/flatten_keys_of.rb b/lib/helpers/flatten_keys_of.rb index 9e9bad5..a7e3ad7 100644 --- a/lib/helpers/flatten_keys_of.rb +++ b/lib/helpers/flatten_keys_of.rb @@ -24,7 +24,7 @@ # => { "key"=>"value", "nested.key"=>"nested_value", "array.0"=>0, "array.1"=>1, "array.2"=>2 } # refactored from https://stackoverflow.com/a/23861946/2884386 -def flatten_keys_of(input, keys = [], output = {}, flattener: ->(*keys) { keys }, flatten_arrays: false) +def flatten_keys_of(input, keys = [], output = {}, flattener: ->(*k) { k }, flatten_arrays: false) if input.is_a?(Hash) input.each do |key, value| flatten_keys_of( diff --git a/spec/action_set/attribute_value_spec.rb b/spec/action_set/attribute_value_spec.rb new file mode 100644 index 0000000..4da3e16 --- /dev/null +++ b/spec/action_set/attribute_value_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActionSet::AttributeValue do + before(:all) do + @thing = FactoryBot.create(:thing) + end + + describe '#cast' do + ApplicationRecord::FIELD_TYPES.each do |type| + it do + type_value = @thing.public_send(type) + string_value = type_value.to_s + attribute_value = ActionSet::AttributeValue.new(string_value) + cast_value = attribute_value.cast(to: type_value.class) + + expect(cast_value).to eql type_value + end + + it do + type_value = @thing.public_send(type) + string_value = type_value.to_s + attribute_value = ActionSet::AttributeValue.new(string_value) + cast_value = attribute_value.cast(to: Module.new) + + expect(cast_value).to eql string_value + end + end + end +end diff --git a/spec/active_set/filter/predicates_spec.rb b/spec/active_set/filter/predicates_spec.rb index 56858e0..226ee48 100644 --- a/spec/active_set/filter/predicates_spec.rb +++ b/spec/active_set/filter/predicates_spec.rb @@ -6,185 +6,171 @@ before(:all) do @thing_1 = FactoryBot.create(:thing) @thing_2 = FactoryBot.create(:thing) - @active_set = ActiveSet.new(Thing.all) end describe '#filter' do - let(:results) { @active_set.filter(instructions) } - let(:result_ids) { results.map(&:id) } - - ApplicationRecord::DB_FIELD_TYPES.each do |type| - [1, 2].each do |id| - # single value inclusive operators - %i[ - eq - lteq - gteq - matches - ].each do |operator| - %W[ - #{type}(#{operator}) - only.#{type}(#{operator}) - ].each do |path| - context "{ #{path}: }" do - let(:matching_item) { instance_variable_get("@thing_#{id}") } - let(:instruction_single_value) do - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: matching_item) - end - let(:instructions) do - { - path => instruction_single_value - } - end - - it { expect(result_ids).to include matching_item.id } - end - end + ['itself', 'to_a'].each do |relation_mutator| + context "/#{ relation_mutator == 'itself' ? 'ActiveRecord' : 'Enumberable' }/" do + before(:all) do + @active_set = ActiveSet.new(Thing.all.public_send(relation_mutator)) end + let(:results) { @active_set.filter(instructions) } + let(:result_ids) { results.map(&:id) } - # single value exlusive operators - %i[ - not_eq - lt - gt - does_not_match - ].each do |operator| - %W[ - #{type}(#{operator}) - only.#{type}(#{operator}) - ].each do |path| - context "{ #{path}: }" do - let(:matching_item) { instance_variable_get("@thing_#{id}") } - let(:instruction_single_value) do - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: matching_item) - end - let(:instructions) do - { - path => instruction_single_value - } - end + ApplicationRecord::DB_FIELD_TYPES.each do |type| + [1, 2].each do |id| + inclusive_unary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| + context "{ #{path}: }" do + let(:matching_item) { instance_variable_get("@thing_#{id}") } + let(:instruction_single_value) do + value_for(object: matching_item, path: path) + end + let(:instructions) do + { + path => instruction_single_value + } + end - it { expect(result_ids).not_to include matching_item.id } + it { expect(result_ids).to include matching_item.id } + end + end end - end - end - # multi value inclusive operators - %i[ - eq_any - not_eq_any - in - in_any - not_in_any - lteq_any - gteq_any - matches_any - does_not_match_any - ].each do |operator| - %W[ - #{type}(#{operator}) - only.#{type}(#{operator}) - ].each do |path| - context "{ #{path}: }" do - let(:matching_item) { instance_variable_get("@thing_#{id}") } - let(:other_thing) do - guaranteed_unique_object_for(matching_item, - only: guaranteed_unique_object_for(matching_item.only)) - end - let(:instruction_multi_value) do - [ - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: matching_item), - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: other_thing) - ] - end - let(:instructions) do - { - path => instruction_multi_value - } - end + exclusive_unary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| + context "{ #{path}: }" do + let(:matching_item) { instance_variable_get("@thing_#{id}") } + let(:instruction_single_value) do + value_for(object: matching_item, path: path) + end + let(:instructions) do + { + path => instruction_single_value + } + end - it { expect(result_ids).to include matching_item.id } + it { expect(result_ids).not_to include matching_item.id } + end + end end - end - end - # multi value exclusive operators - %i[ - eq_all - not_eq_all - not_in - in_all - not_in_all - lt_all - gt_all - matches_all - does_not_match_all - ].each do |operator| - %W[ - #{type}(#{operator}) - only.#{type}(#{operator}) - ].each do |path| - context "{ #{path}: }" do - let(:matching_item) { instance_variable_get("@thing_#{id}") } - let(:other_thing) do - guaranteed_unique_object_for(matching_item, - only: guaranteed_unique_object_for(matching_item.only)) + inconclusive_unary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| + context "{ #{path}: }" do + let(:matching_item) { instance_variable_get("@thing_#{id}") } + let(:instruction_single_value) do + value_for(object: matching_item, path: path) + end + let(:instructions) do + { + path => instruction_single_value + } + end + + # By default, we expect these operators to return nothing. + # If, however, they do return something, we guarantee it is a logical result + it do + set_instruction = ActiveSet::Filtering::Enumerable::SetInstruction + .new( + ActiveSet::AttributeInstruction.new(path, instruction_single_value), + @active_set.set) + + expect(results.map { |obj| set_instruction.item_matches_query?(obj) }).to all( be true ) + end + end end - let(:instruction_multi_value) do - [ - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: matching_item), - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: other_thing) - ] + end + + inclusive_binary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| + context "{ #{path}: }" do + let(:matching_item) { instance_variable_get("@thing_#{id}") } + let(:other_thing) do + guaranteed_unique_object_for(matching_item, + only: guaranteed_unique_object_for(matching_item.only)) + end + let(:matching_value) { value_for(object: matching_item, path: path) } + let(:other_value) { value_for(object: other_thing, path: path) } + let(:instruction_multi_value) do + if operator.to_s.split('_').include?('IN') && (operator.to_s.split('_') & %w[ANY ALL]).any? + [ [matching_value], [other_value] ] + else + [ matching_value, other_value ] + end + end + let(:instructions) do + { path => instruction_multi_value } + end + + it { expect(result_ids).to include matching_item.id } + end end - let(:instructions) do - { - path => instruction_multi_value - } + end + + exclusive_binary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| + context "{ #{path}: }" do + let(:matching_item) { instance_variable_get("@thing_#{id}") } + let(:other_thing) do + guaranteed_unique_object_for(matching_item, + only: guaranteed_unique_object_for(matching_item.only)) + end + let(:matching_value) { value_for(object: matching_item, path: path) } + let(:other_value) { value_for(object: other_thing, path: path) } + let(:instruction_multi_value) do + if operator.to_s.split('_').include?('IN') && (operator.to_s.split('_') & %w[ANY ALL]).any? + [ [matching_value], [other_value] ] + else + [ matching_value, other_value ] + end + end + let(:instructions) do + { path => instruction_multi_value } + end + + it { expect(result_ids).not_to include matching_item.id } + end end + end + + inconclusive_binary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| + context "{ #{path}: }" do + let(:matching_item) { instance_variable_get("@thing_#{id}") } + let(:other_thing) do + FactoryBot.build(:thing, + boolean: !matching_item.boolean, + only: FactoryBot.build(:only, + boolean: !matching_item.only.boolean)) + end + let(:instruction_multi_value) do + [ + value_for(object: matching_item, path: path), + value_for(object: other_thing, path: path) + ] + end + let(:instructions) do + { + path => instruction_multi_value + } + end - it { expect(result_ids).not_to include matching_item.id } + # By default, we expect these operators to return nothing. + # If, however, they do return something, we guarantee it is a logical result + it do + set_instruction = ActiveSet::Filtering::Enumerable::SetInstruction + .new( + ActiveSet::AttributeInstruction.new(path, instruction_multi_value), + @active_set.set) + + expect(results.map { |obj| set_instruction.item_matches_query?(obj) }).to all( be true ) + end + end + end end end end - - # multi value mixed operators - # %i[ - # lt_any - # lteq_all - # gt_any - # gteq_all - # ].each do |operator| - # %W[ - # #{type}(#{operator}) - # only.#{type}(#{operator}) - # ].each do |path| - # context "{ #{path}: }" do - # let(:matching_item) { instance_variable_get("@thing_#{id}") } - # let(:other_thing) do - # FactoryBot.build(:thing, - # boolean: !matching_item.boolean, - # only: FactoryBot.build(:only, - # boolean: !matching_item.only.boolean)) - # end - # let(:instruction_multi_value) do - # [ - # ActiveSet::AttributeInstruction.new(path, nil).value_for(item: matching_item), - # ActiveSet::AttributeInstruction.new(path, nil).value_for(item: other_thing) - # ] - # end - # let(:instructions) do - # { - # path => instruction_multi_value - # } - # end - - # if type.presence_in(%i[binary datetime decimal float integer]) && operator == :gt_any - # it { expect(result_ids).not_to include matching_item.id } - # else - # end - # end - # end - # end end end end diff --git a/spec/active_set/filter/scopes_spec.rb b/spec/active_set/filter/scopes_spec.rb index 4518fac..a3e3493 100644 --- a/spec/active_set/filter/scopes_spec.rb +++ b/spec/active_set/filter/scopes_spec.rb @@ -6,32 +6,34 @@ before(:all) do @thing_1 = FactoryBot.create(:thing) @thing_2 = FactoryBot.create(:thing) - @active_set = ActiveSet.new(Thing.all) end describe '#filter' do - let(:result) { @active_set.filter(instructions) } + ['itself', 'to_a'].each do |relation_mutator| + context "/#{ relation_mutator == 'itself' ? 'ActiveRecord' : 'Enumberable' }/" do + before(:all) do + @active_set = ActiveSet.new(Thing.all.public_send(relation_mutator)) + end + let(:result) { @active_set.filter(instructions) } - ApplicationRecord::DB_FIELD_TYPES.each do |type| - [1, 2].each do |id| - let(:matching_item) { instance_variable_get("@thing_#{id}") } + ApplicationRecord::DB_FIELD_TYPES.each do |type| + [1, 2].each do |id| + let(:matching_item) { instance_variable_get("@thing_#{id}") } - all_possible_scope_paths_for(type).each do |path| - context "{ #{path}: }" do - let(:instructions) do - { - path => filter_value_for(object: matching_item, path: path) - } - end + all_possible_scope_paths_for(type).each do |path| + context "{ #{path}: }" do + let(:instructions) do + { + path => filter_value_for(object: matching_item, path: path) + } + end - if path.end_with?('_collection_method', '_scope_method') - if path.include?('computed_') - it { expect(result.map(&:id)).to eq [] } - else - it { expect(result.map(&:id)).to eq [matching_item.id] } + if path.end_with?('_collection_method', '_scope_method') + it { expect(result.map(&:id)).to eq [matching_item.id] } + else + it { expect(result.map(&:id)).to eq Thing.pluck(:id) } + end end - else - it { expect(result.map(&:id)).to eq Thing.pluck(:id) } end end end diff --git a/spec/active_set/filter/types_spec.rb b/spec/active_set/filter/types_spec.rb index 2dd2aef..a9c9cb6 100644 --- a/spec/active_set/filter/types_spec.rb +++ b/spec/active_set/filter/types_spec.rb @@ -44,20 +44,18 @@ ApplicationRecord::FILTERABLE_TYPES.combination(2).each do |type_1, type_2| [1, 2].each do |id| - context "matching @thing_#{id}" do - let(:matching_item) { instance_variable_get("@thing_#{id}") } - - all_possible_path_combinations_for(type_1, type_2).each do |path_1, path_2| - context "{ #{path_1}:, #{path_2} }" do - let(:instructions) do - { - path_1 => filter_value_for(object: matching_item, path: path_1), - path_2 => filter_value_for(object: matching_item, path: path_2) - } - end + let(:matching_item) { instance_variable_get("@thing_#{id}") } - it { expect(result.map(&:id)).to eq [matching_item.id] } + all_possible_path_combinations_for(type_1, type_2).each do |path_1, path_2| + context "{ #{path_1}:, #{path_2} }" do + let(:instructions) do + { + path_1 => filter_value_for(object: matching_item, path: path_1), + path_2 => filter_value_for(object: matching_item, path: path_2) + } end + + it { expect(result.map(&:id)).to eq [matching_item.id] } end end end diff --git a/spec/database_cleaner_helper.rb b/spec/database_cleaner_helper.rb new file mode 100644 index 0000000..c07559d --- /dev/null +++ b/spec/database_cleaner_helper.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.configure do |config| + config.before(:suite) do + DatabaseCleaner.clean_with(:truncation) + DatabaseCleaner.strategy = :transaction + end + + config.before(:all) do + DatabaseCleaner.start + end + + config.around(:each) do |example| + DatabaseCleaner.cleaning do + example.run + end + end + + config.after(:all) do + DatabaseCleaner.clean + end +end diff --git a/spec/factories/generic.rb b/spec/factories/generic.rb index 86e09a1..2997cfa 100644 --- a/spec/factories/generic.rb +++ b/spec/factories/generic.rb @@ -15,16 +15,16 @@ def fit_to_minmax(number, max:, min: 1) sequence(:boolean, &:even?) sequence(:date) do |n| [ - "19#{rand(99).to_s.rjust(2, '0')}", + "19#{fit_to_minmax(n, max: 99).to_s.rjust(2, '0')}", fit_to_minmax(n, max: 12).to_s.rjust(2, '0'), - fit_to_minmax(n, max: 31).to_s.rjust(2, '0') + fit_to_minmax(n, max: 28).to_s.rjust(2, '0') ].join('-') end sequence(:datetime) do |n| "#{date}T#{time}:#{fit_to_minmax(n, max: 60).to_s.rjust(2, '0')}+00:00" end - sequence(:decimal) { |n| Faker::Number.decimal(2).to_f + n } - sequence(:float) { |n| Faker::Number.decimal(2).to_f + n } + sequence(:decimal) { |n| (n.to_s + '.' + Faker::Number.number(2)).to_f } + sequence(:float) { |n| (n.to_s + '.' + Faker::Number.number(2)).to_f } sequence(:integer, &:to_i) sequence(:string) do |n| n.hash.abs.to_s.split('').map { |i| ('a'..'z').to_a.shuffle[i.to_i] }.join diff --git a/spec/helpers/pagination/record_description_for_helper_spec.rb b/spec/helpers/pagination/record_description_for_helper_spec.rb new file mode 100644 index 0000000..0e27e78 --- /dev/null +++ b/spec/helpers/pagination/record_description_for_helper_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Pagination::RecordDescriptionForHelper, type: :helper do + before(:each) do + allow(helper).to receive(:params).and_return(params.merge(_recall: { controller: 'things', action: 'index' })) + end + let(:params) do + {} + end + let(:set) { [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] } + + describe '.pagination_record_description_for' do + subject { helper.pagination_record_description_for(active_set) } + + context 'size: 10' do + let(:active_set) { ActiveSet.new(set).paginate(page: page_number, size: set.size) } + + context 'page_number: 1' do + let(:page_number) { 1 } + + it { should eql('1 – 10 of 10 records') } + end + + context 'page_number: 10' do + let(:page_number) { 10 } + + it { should eql('None of 10 records') } + end + end + + context 'size: 5' do + let(:active_set) { ActiveSet.new(set).paginate(page: page_number, size: 5) } + + context 'page_number: 1' do + let(:page_number) { 1 } + + it { should eql('1 – 5 of 10 records') } + end + + context 'page_number: 2' do + let(:page_number) { 2 } + + it { should eql('6 – 10 of 10 records') } + end + + context 'page_number: 10' do + let(:page_number) { 10 } + + it { should eql('None of 10 records') } + end + end + + context 'size: 4' do + let(:active_set) { ActiveSet.new(set).paginate(page: page_number, size: 4) } + + context 'page_number: 1' do + let(:page_number) { 1 } + + it { should eql('1 – 4 of 10 records') } + end + + context 'page_number: 2' do + let(:page_number) { 2 } + + it { should eql('5 – 8 of 10 records') } + end + + context 'page_number: 3' do + let(:page_number) { 3 } + + it { should eql('9 – 10 of 10 records') } + end + + context 'page_number: 10' do + let(:page_number) { 10 } + + it { should eql('None of 10 records') } + end + end + + context 'size: 2' do + let(:active_set) { ActiveSet.new(set).paginate(page: page_number, size: 2) } + + context 'page_number: 1' do + let(:page_number) { 1 } + + it { should eql('1 – 2 of 10 records') } + end + + context 'page_number: 3' do + let(:page_number) { 3 } + + it { should eql('5 – 6 of 10 records') } + end + + context 'page_number: 5' do + let(:page_number) { 5 } + + it { should eql('9 – 10 of 10 records') } + end + + context 'page_number: 10' do + let(:page_number) { 10 } + + it { should eql('None of 10 records') } + end + end + + context 'size: 1' do + let(:active_set) { ActiveSet.new(set).paginate(page: page_number, size: 1) } + + context 'page_number: 1' do + let(:page_number) { 1 } + + it { should eql('1 – 1 of 10 records') } + end + + context 'page_number: 5' do + let(:page_number) { 5 } + + it { should eql('5 – 5 of 10 records') } + end + + context 'page_number: 10' do + let(:page_number) { 10 } + + it { should eql('10 – 10 of 10 records') } + end + + context 'page_number: 20' do + let(:page_number) { 20 } + + it { should eql('None of 10 records') } + end + end + end +end diff --git a/spec/helpers/pagination/record_range_for_helper_spec.rb b/spec/helpers/pagination/record_range_for_helper_spec.rb new file mode 100644 index 0000000..d098982 --- /dev/null +++ b/spec/helpers/pagination/record_range_for_helper_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Pagination::RecordRangeForHelper, type: :helper do + before(:each) do + allow(helper).to receive(:params).and_return(params.merge(_recall: { controller: 'things', action: 'index' })) + end + let(:params) do + {} + end + let(:set) { [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] } + + describe '.pagination_record_range_for' do + subject { helper.pagination_record_range_for(active_set) } + + context 'size: 10' do + let(:active_set) { ActiveSet.new(set).paginate(page: page_number, size: set.size) } + + context 'page_number: 1' do + let(:page_number) { 1 } + + it { should eql('1 – 10') } + end + + context 'page_number: 10' do + let(:page_number) { 10 } + + it { should eql('None') } + end + end + + context 'size: 5' do + let(:active_set) { ActiveSet.new(set).paginate(page: page_number, size: 5) } + + context 'page_number: 1' do + let(:page_number) { 1 } + + it { should eql('1 – 5') } + end + + context 'page_number: 2' do + let(:page_number) { 2 } + + it { should eql('6 – 10') } + end + + context 'page_number: 10' do + let(:page_number) { 10 } + + it { should eql('None') } + end + end + + context 'size: 4' do + let(:active_set) { ActiveSet.new(set).paginate(page: page_number, size: 4) } + + context 'page_number: 1' do + let(:page_number) { 1 } + + it { should eql('1 – 4') } + end + + context 'page_number: 2' do + let(:page_number) { 2 } + + it { should eql('5 – 8') } + end + + context 'page_number: 3' do + let(:page_number) { 3 } + + it { should eql('9 – 10') } + end + + context 'page_number: 10' do + let(:page_number) { 10 } + + it { should eql('None') } + end + end + + context 'size: 2' do + let(:active_set) { ActiveSet.new(set).paginate(page: page_number, size: 2) } + + context 'page_number: 1' do + let(:page_number) { 1 } + + it { should eql('1 – 2') } + end + + context 'page_number: 3' do + let(:page_number) { 3 } + + it { should eql('5 – 6') } + end + + context 'page_number: 5' do + let(:page_number) { 5 } + + it { should eql('9 – 10') } + end + + context 'page_number: 10' do + let(:page_number) { 10 } + + it { should eql('None') } + end + end + + context 'size: 1' do + let(:active_set) { ActiveSet.new(set).paginate(page: page_number, size: 1) } + + context 'page_number: 1' do + let(:page_number) { 1 } + + it { should eql('1 – 1') } + end + + context 'page_number: 5' do + let(:page_number) { 5 } + + it { should eql('5 – 5') } + end + + context 'page_number: 10' do + let(:page_number) { 10 } + + it { should eql('10 – 10') } + end + + context 'page_number: 20' do + let(:page_number) { 20 } + + it { should eql('None') } + end + end + end +end diff --git a/spec/inspect_failure_helper.rb b/spec/inspect_failure_helper.rb new file mode 100644 index 0000000..bade4e4 --- /dev/null +++ b/spec/inspect_failure_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +if ENV['INSPECT_FAILURE'] == 'true' + RSpec.configure do |config| + # Give a detailed report of all relevant data if a spec fails + # http://bensnape.com/2014/08/01/rspec-after-failure-hook/ + config.after(:each) do |example| + next unless example.exception + + let_data = @__memoized.instance_variable_get('@memoized') + ivar_data = instance_variables + .reject { |v| v.to_s.start_with?('@_') } + .reject { |v| v.presence_in %i[@example @fixture_cache @fixture_connections @connection_subscriber @loaded_fixtures] } + .map { |v| [v, instance_variable_get(v)] } + .to_h + + # https://www.jvt.me/posts/2019/03/29/pretty-printing-json-ruby/ + jj let_data.merge(ivar_data).transform_values(&:inspect) + end + end +end diff --git a/spec/requests/filtering/predicates_spec.rb b/spec/requests/filtering/predicates_spec.rb index 223cee4..f672bc8 100644 --- a/spec/requests/filtering/predicates_spec.rb +++ b/spec/requests/filtering/predicates_spec.rb @@ -21,22 +21,13 @@ ApplicationRecord::DB_FIELD_TYPES.each do |type| [1, 2].each do |id| - # single value inclusive operators - [%i[ - eq - lteq - gteq - matches - ].sample].each do |operator| - %W[ - #{type}(#{operator}) - only.#{type}(#{operator}) - ].sample do |path| + inclusive_unary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| context "{ #{path}: }" do let(:matching_item) { instance_variable_get("@thing_#{id}") } let(:instruction_single_value) do - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: matching_item) - end + value_for(object: matching_item, path: path) + end let(:instructions) do { path => instruction_single_value @@ -48,22 +39,13 @@ end end - # single value exlusive operators - [%i[ - not_eq - lt - gt - does_not_match - ].sample].each do |operator| - %W[ - #{type}(#{operator}) - only.#{type}(#{operator}) - ].sample do |path| + exclusive_unary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| context "{ #{path}: }" do let(:matching_item) { instance_variable_get("@thing_#{id}") } let(:instruction_single_value) do - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: matching_item) - end + value_for(object: matching_item, path: path) + end let(:instructions) do { path => instruction_single_value @@ -75,33 +57,51 @@ end end - # multi value inclusive operators - [%i[ - eq_any - not_eq_any - in - in_any - not_in_any - lteq_any - gteq_any - matches_any - does_not_match_any - ].sample].each do |operator| - %W[ - #{type}(#{operator}) - only.#{type}(#{operator}) - ].sample do |path| + inconclusive_unary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| + context "{ #{path}: }" do + let(:matching_item) { instance_variable_get("@thing_#{id}") } + let(:instruction_single_value) do + value_for(object: matching_item, path: path) + end + let(:instructions) do + { + path => instruction_single_value + } + end + + # By default, we expect these operators to return nothing. + # If, however, they do return something, we guarantee it is a logical result + it do + set_instruction = ActiveSet::Filtering::Enumerable::SetInstruction + .new( + ActiveSet::AttributeInstruction.new(path, instruction_single_value), + @active_set.set) + result_objs = Thing.where(id: result_ids) + + expect(result_objs.map { |obj| set_instruction.item_matches_query?(obj) }) + .to all( be true ) + end + end + end + end + + inclusive_binary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| context "{ #{path}: }" do let(:matching_item) { instance_variable_get("@thing_#{id}") } let(:other_thing) do guaranteed_unique_object_for(matching_item, only: guaranteed_unique_object_for(matching_item.only)) end + let(:matching_value) { value_for(object: matching_item, path: path) } + let(:other_value) { value_for(object: other_thing, path: path) } let(:instruction_multi_value) do - [ - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: matching_item), - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: other_thing) - ] + if operator.to_s.split('_').include?('IN') && (operator.to_s.split('_') & %w[ANY ALL]).any? + [ [matching_value], [other_value] ] + else + [ matching_value, other_value ] + end end let(:instructions) do { @@ -114,33 +114,22 @@ end end - # multi value exclusive operators - [%i[ - eq_all - not_eq_all - not_in - in_all - not_in_all - lt_all - gt_all - matches_all - does_not_match_all - ].sample].each do |operator| - %W[ - #{type}(#{operator}) - only.#{type}(#{operator}) - ].sample do |path| + exclusive_binary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| context "{ #{path}: }" do let(:matching_item) { instance_variable_get("@thing_#{id}") } let(:other_thing) do guaranteed_unique_object_for(matching_item, only: guaranteed_unique_object_for(matching_item.only)) end + let(:matching_value) { value_for(object: matching_item, path: path) } + let(:other_value) { value_for(object: other_thing, path: path) } let(:instruction_multi_value) do - [ - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: matching_item), - ActiveSet::AttributeInstruction.new(path, nil).value_for(item: other_thing) - ] + if operator.to_s.split('_').include?('IN') && (operator.to_s.split('_') & %w[ANY ALL]).any? + [ [matching_value], [other_value] ] + else + [ matching_value, other_value ] + end end let(:instructions) do { @@ -153,44 +142,43 @@ end end - # multi value mixed operators - # [%i[ - # lt_any - # lteq_all - # gt_any - # gteq_all - # ].sample].each do |operator| - # %W[ - # #{type}(#{operator}) - # only.#{type}(#{operator}) - # ].sample do |path| - # context "{ #{path}: }" do - # let(:matching_item) { instance_variable_get("@thing_#{id}") } - # let(:other_thing) do - # FactoryBot.build(:thing, - # boolean: !matching_item.boolean, - # only: FactoryBot.build(:only, - # boolean: !matching_item.only.boolean)) - # end - # let(:instruction_multi_value) do - # [ - # ActiveSet::AttributeInstruction.new(path, nil).value_for(item: matching_item), - # ActiveSet::AttributeInstruction.new(path, nil).value_for(item: other_thing) - # ] - # end - # let(:instructions) do - # { - # path => instruction_multi_value - # } - # end - - # if type.presence_in(%i[binary datetime decimal float integer]) && operator == :gt_any - # it { expect(result_ids).not_to include matching_item.id } - # else - # end - # end - # end - # end + inconclusive_binary_operators.each do |operator| + all_possible_type_operator_paths_for(type, operator).each do |path| + context "{ #{path}: }" do + let(:matching_item) { instance_variable_get("@thing_#{id}") } + let(:other_thing) do + FactoryBot.build(:thing, + boolean: !matching_item.boolean, + only: FactoryBot.build(:only, + boolean: !matching_item.only.boolean)) + end + let(:instruction_multi_value) do + [ + value_for(object: matching_item, path: path), + value_for(object: other_thing, path: path) + ] + end + let(:instructions) do + { + path => instruction_multi_value + } + end + + # By default, we expect these operators to return nothing. + # If, however, they do return something, we guarantee it is a logical result + it do + set_instruction = ActiveSet::Filtering::Enumerable::SetInstruction + .new( + ActiveSet::AttributeInstruction.new(path, instruction_multi_value), + @active_set.set) + result_objs = Thing.where(id: result_ids) + + expect(result_objs.map { |obj| set_instruction.item_matches_query?(obj) }) + .to all( be true ) + end + end + end + end end end end diff --git a/spec/requests/filtering/types_spec.rb b/spec/requests/filtering/types_spec.rb index 802a61e..6aad621 100644 --- a/spec/requests/filtering/types_spec.rb +++ b/spec/requests/filtering/types_spec.rb @@ -14,6 +14,11 @@ let(:result_ids) { results.map { |f| f['id'] } } before(:each) do + allow_any_instance_of(ThingsController) + .to receive(:filter_set_types) + .and_return({ + types: instructions.transform_values(&:class) + }) if defined?(filter_set_types) get things_path(format: :json), params: { filter: instructions } end @@ -22,8 +27,7 @@ [1, 2].each do |id| let(:matching_item) { instance_variable_get("@thing_#{id}") } - paths = all_possible_paths_for(type) - paths.shuffle.take(paths.size / 2).each do |path| + all_possible_paths_for(type).each do |path| context "{ #{path}: }" do let(:instructions) do { @@ -33,27 +37,92 @@ it { expect(result_ids).to eq [matching_item.id] } end + + context "{ #{path}: } #filter_set_types" do + let(:filter_set_types) { true } + let(:instructions) do + { + path => filter_value_for(object: matching_item, path: path) + } + end + + it { expect(result_ids).to eq [matching_item.id] } + end + + context "{ 0: { attribute: #{path} } }" do + let(:instructions) do + { + '0': { + attribute: path, + operator: 'EQ', + query: filter_value_for(object: matching_item, path: path) + } + } + end + + it { expect(result_ids).to eq [matching_item.id] } + end + + context "{ { attribute: #{path} } }" do + let(:instructions) do + { + attribute: path, + operator: 'EQ', + query: filter_value_for(object: matching_item, path: path) + } + end + + it { expect(result_ids).to eq [matching_item.id] } + end end end end ApplicationRecord::FIELD_TYPES.combination(2).each do |type_1, type_2| [1, 2].each do |id| - context "matching @thing_#{id}" do - let(:matching_item) { instance_variable_get("@thing_#{id}") } - - paths = all_possible_path_combinations_for(type_1, type_2) - paths.shuffle.take(paths.size / 2).each do |path_1, path_2| - context "{ #{path_1}:, #{path_2} }" do - let(:instructions) do - { - path_1 => filter_value_for(object: matching_item, path: path_1), - path_2 => filter_value_for(object: matching_item, path: path_2) - } - end + let(:matching_item) { instance_variable_get("@thing_#{id}") } - it { expect(result_ids).to eq [matching_item.id] } + all_possible_path_combinations_for(type_1, type_2).each do |path_1, path_2| + context "{ #{path_1}:, #{path_2} }" do + let(:instructions) do + { + path_1 => filter_value_for(object: matching_item, path: path_1), + path_2 => filter_value_for(object: matching_item, path: path_2) + } end + + it { expect(result_ids).to eq [matching_item.id] } + end + + context "{ #{path_1}:, #{path_2} } #filter_set_types" do + let(:filter_set_types) { true } + let(:instructions) do + { + path_1 => filter_value_for(object: matching_item, path: path_1), + path_2 => filter_value_for(object: matching_item, path: path_2) + } + end + + it { expect(result_ids).to eq [matching_item.id] } + end + + context "{ 0: { attribute: #{path_1} }, 1: { attribute: #{path_2} } }" do + let(:instructions) do + { + '0': { + attribute: path_1, + operator: 'EQ', + query: filter_value_for(object: matching_item, path: path_1) + }, + '1': { + attribute: path_2, + operator: 'EQ', + query: filter_value_for(object: matching_item, path: path_2) + } + } + end + + it { expect(result_ids).to eq [matching_item.id] } end end end diff --git a/spec/requests/sort_spec.rb b/spec/requests/sort_spec.rb index 377ba14..598ff8a 100644 --- a/spec/requests/sort_spec.rb +++ b/spec/requests/sort_spec.rb @@ -48,21 +48,68 @@ end ApplicationRecord::SORTABLE_TYPES.each do |type| - all_possible_sort_instructions_for(type).sample do |instruction| + all_possible_sort_instructions_for(type).each do |instruction| context instruction do - it_should_behave_like 'a sorted collection', instruction do - let(:result) { @active_set.sort(instruction) } + let(:instructions) { instruction } + + it_should_behave_like 'a sorted collection', instruction + end + end + + all_possible_paths_for(type).each do |path| + [:asc, 'desc'].each do |dir| + context "{ 0: { attribute: #{path}, direction: #{dir} } }" do + let(:instructions) do + { + '0': { + attribute: path, + direction: dir + } + } + end + + it_should_behave_like 'a sorted collection', { path => dir } + end + + context "{ attribute: #{path}, direction: #{dir} }" do + let(:instructions) do + { + attribute: path, + direction: dir + } + end + + it_should_behave_like 'a sorted collection', { path => dir } end end end end ApplicationRecord::SORTABLE_TYPES.combination(2).each do |type_1, type_2| - all_possible_sort_instruction_combinations_for(type_1, type_2).sample do |instructions| + all_possible_sort_instruction_combinations_for(type_1, type_2).each do |instructions| context instructions do - it_should_behave_like 'a sorted collection', instructions do - let(:result) { @active_set.sort(instructions) } + let(:instructions) { instructions } + + it_should_behave_like 'a sorted collection', instructions + end + end + + all_possible_path_combinations_for(type_1, type_2).each do |path_1, path_2| + context "{ 0: { attribute: #{path_1}, direction: asc }, 1: { attribute: #{path_2}, direction: desc } }" do + let(:instructions) do + { + '0': { + attribute: path_1, + direction: 'asc' + }, + '1': { + attribute: path_2, + direction: :desc + } + } end + + it_should_behave_like 'a sorted collection', { path_1 => 'asc', path_2 => :desc } end end end diff --git a/spec/simplecov_helper.rb b/spec/simplecov_helper.rb index cfdabbd..5a52d8d 100644 --- a/spec/simplecov_helper.rb +++ b/spec/simplecov_helper.rb @@ -5,7 +5,7 @@ require 'simplecov-console' require 'codecov' -unless ENV['COVERAGE'] == 'false' +if ENV['COVERAGE'] == 'true' ROOT = File.expand_path('..', __dir__) # SimpleCov.minimum_coverage 99 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2b818f0..6d84168 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require 'simplecov_helper' +require 'database_cleaner_helper' +require 'inspect_failure_helper' require 'bundler' require 'combustion' @@ -46,39 +48,4 @@ nil end end - - config.before(:suite) do - DatabaseCleaner.clean_with(:truncation) - DatabaseCleaner.strategy = :transaction - end - - config.before(:all) do - DatabaseCleaner.start - end - - config.around(:each) do |example| - DatabaseCleaner.cleaning do - example.run - end - end - - config.after(:all) do - DatabaseCleaner.clean - end - - # Give a detailed report of all relevant data if a spec fails - # http://bensnape.com/2014/08/01/rspec-after-failure-hook/ - config.after(:each) do |example| - next unless example.exception - - let_data = @__memoized.instance_variable_get('@memoized') - ivar_data = instance_variables - .reject { |v| v.to_s.start_with?('@_') } - .reject { |v| v.presence_in %i[@example @fixture_cache @fixture_connections @connection_subscriber @loaded_fixtures] } - .map { |v| [v, instance_variable_get(v)] } - .to_h - - # https://www.jvt.me/posts/2019/03/29/pretty-printing-json-ruby/ - jj let_data.merge(ivar_data).transform_values(&:inspect) - end end diff --git a/spec/support/filtering_helpers.rb b/spec/support/filtering_helpers.rb index b163b1c..375cd1d 100644 --- a/spec/support/filtering_helpers.rb +++ b/spec/support/filtering_helpers.rb @@ -1,6 +1,62 @@ # frozen_string_literal: true module FilteringHelpers + PREDICATE_OPERATORS = ActiveSet::Filtering::Constants::PREDICATES + + def inclusive_unary_operators + operators_for_test = PREDICATE_OPERATORS.select do |_, o| + o[:compound] == false && + o[:behavior] == :inclusive + end.map(&:first) + + relevant_paths_for_testing_given_environment(operators_for_test) + end + + def exclusive_unary_operators + operators_for_test = PREDICATE_OPERATORS.select do |_, o| + o[:compound] == false && + o[:behavior] == :exclusive + end.map(&:first) + + relevant_paths_for_testing_given_environment(operators_for_test) + end + + def inconclusive_unary_operators + operators_for_test = PREDICATE_OPERATORS.select do |_, o| + o[:compound] == false && + o[:behavior] == :inconclusive + end.map(&:first) + + relevant_paths_for_testing_given_environment(operators_for_test) + end + + def inclusive_binary_operators + operators_for_test = PREDICATE_OPERATORS.select do |_, o| + o[:compound] == true && + o[:behavior] == :inclusive + end.map(&:first) + + relevant_paths_for_testing_given_environment(operators_for_test) + end + + def exclusive_binary_operators + operators_for_test = PREDICATE_OPERATORS.select do |_, o| + o[:compound] == true && + o[:behavior] == :exclusive + end.map(&:first) + + relevant_paths_for_testing_given_environment(operators_for_test) + end + + def inconclusive_binary_operators + operators_for_test = PREDICATE_OPERATORS.select do |_, o| + o[:compound] == true && + o[:behavior] == :inconclusive + end.map(&:first) + + relevant_paths_for_testing_given_environment(operators_for_test) + end + def all_possible_scope_paths_for(type) %W[ #{type}_scope_method @@ -12,6 +68,14 @@ def all_possible_scope_paths_for(type) end end + def all_possible_type_operator_paths_for(type, operator) + paths_for_test = %W[ + #{type}(#{operator}) + only.#{type}(#{operator}) + ] + relevant_paths_for_testing_given_environment(paths_for_test) + end + def filter_value_for(object:, path:) path = path.remove('_scope_method') path = path.remove('_collection_method') diff --git a/spec/support/path_helpers.rb b/spec/support/path_helpers.rb index 405e4f0..efd36cd 100644 --- a/spec/support/path_helpers.rb +++ b/spec/support/path_helpers.rb @@ -1,12 +1,20 @@ # frozen_string_literal: true module PathHelpers + def relevant_paths_for_testing_given_environment(paths_for_test) + if ENV['LOGICALLY_EXHAUSTIVE_REQUEST_SPECS'] == 'true' + paths_for_test + else + [paths_for_test.sample] + end + end + def all_possible_paths_for(type, options = {}) association = 'only' computed_field = "computed_#{type}" computed_association = "computed_#{association}" - [].tap do |paths| + paths_for_test = [].tap do |paths| paths << type paths << computed_field unless options[:computed_fields] == false unless options[:associations] == false @@ -18,13 +26,16 @@ def all_possible_paths_for(type, options = {}) paths << [computed_association, computed_field].join('.') unless options[:computed_fields] == false end end + + relevant_paths_for_testing_given_environment(paths_for_test) end def all_possible_path_combinations_for(type_1, type_2) - all_possible_paths_for(type_1) - .product( - all_possible_paths_for(type_2) - ) + paths_for_test = all_possible_paths_for(type_1) + .product( + all_possible_paths_for(type_2) + ) + relevant_paths_for_testing_given_environment(paths_for_test) end def value_for(object:, path:) diff --git a/spec/support/sorting_helpers.rb b/spec/support/sorting_helpers.rb index a422075..49c57af 100644 --- a/spec/support/sorting_helpers.rb +++ b/spec/support/sorting_helpers.rb @@ -2,19 +2,23 @@ module SortingHelpers def all_possible_sort_instructions_for(type) - all_possible_paths_for(type) + paths_for_test = all_possible_paths_for(type) .product([:asc, 'desc']) .map { |instruction_tuple| Hash[*instruction_tuple] } + + relevant_paths_for_testing_given_environment(paths_for_test) end def all_possible_sort_instruction_combinations_for(type_1, type_2) - all_possible_path_combinations_for(type_1, type_2) + paths_for_test = all_possible_path_combinations_for(type_1, type_2) .to_enum .with_index .map do |paths, index| directions = index.odd? ? ['asc', :desc] : [:desc, 'asc'] Hash[paths.zip(directions)] end + + relevant_paths_for_testing_given_environment(paths_for_test) end def sort_value_for(object:, path:)