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:)