├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── active_enumerable-1.0.0.gem ├── active_enumerable.gemspec ├── bin ├── console └── setup ├── lib ├── active_enumerable.rb └── active_enumerable │ ├── base.rb │ ├── comparable.rb │ ├── english_dsl.rb │ ├── extract_options.rb │ ├── finder.rb │ ├── method_caller.rb │ ├── order.rb │ ├── queries.rb │ ├── record_not_found.rb │ ├── scope_method.rb │ ├── scopes.rb │ ├── version.rb │ ├── where.rb │ └── where │ ├── where_not_chain.rb │ └── where_or_chain.rb └── spec ├── active_enumerable_spec.rb ├── base_spec.rb ├── comparable_spec.rb ├── english_dsl_spec.rb ├── finder_spec.rb ├── method_caller_spec.rb ├── queries_spec.rb ├── scopes_spec.rb ├── spec_helper.rb └── where_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | cache: bundler 4 | rvm: 5 | - 2.2.9 6 | - 2.3.3 7 | - 2.4.3 8 | before_install: gem install bundler -v 1.10.6 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## 1.1.0 - 2018-08-01 5 | ### Feature 6 | - `#where` can take a block that runs with instance exec for each record. Event allows for nested objects with method chaining. 7 | 8 | ## 1.0.0 - 2018-01-26 9 | - Remove mutation methods and ensure 100% test coverage 10 | 11 | ## 0.2.0 - 2015-02-15 12 | ### Features 13 | - English like querying DSL 14 | 15 | ### Enhancement 16 | - `#where` can match when arrays are equal. 17 | - `#where` can match when strings with regex. 18 | 19 | ## 0.1.0 - 2015-12-31 20 | This version is only to secure the rubygem name. 21 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in active_enumerable.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Dustin Zeisler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActiveEnumerable 2 | 3 | [![Build Status](https://travis-ci.org/zeisler/active_enumerable.svg?branch=master)](https://travis-ci.org/zeisler/active_enumerable) 4 | [![Gem Version](https://badge.fury.io/rb/active_enumerable.svg)](https://badge.fury.io/rb/active_enumerable) 5 | 6 | Provides ActiveRecord like query methods for use in Ruby Enumerable collections. 7 | Use Hashes or custom Ruby Objects to represent records. 8 | 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | ```ruby 14 | gem 'active_enumerable' 15 | ``` 16 | 17 | And then execute: 18 | 19 | $ bundle 20 | 21 | Or install it yourself as: 22 | 23 | $ gem install active_enumerable 24 | 25 | ## Usage 26 | 27 | [Documentation](http://www.rubydoc.info/gems/active_enumerable/1.0.0) 28 | 29 | ### ActiveRecord Like Querying 30 | 31 | ```ruby 32 | require "active_enumerable" 33 | 34 | class Customers 35 | include ActiveEnumerable 36 | 37 | scope :unpaid, -> { where(paid: false).or(credit: 0) } 38 | end 39 | 40 | customers = Customers.new([{paid: true, credit: 1000}, {paid: false, credit: 2000}, {paid: false, credit: 0}]) 41 | 42 | customers.unpaid 43 | # => <#Customers [{:paid=>false, :credit=>2000}, {:paid=>false, :credit=>0}]]> 44 | 45 | customers.scope { select { |y| y[:credit] >= 1000 } } 46 | #=> <#Customers [{paid: true, credit: 1000}, {paid: false, credit: 2000}]> 47 | 48 | customers.sum(:credit) 49 | #=> 3000 50 | 51 | customers << { paid: true, credit: 1500 } # accepts Hashes 52 | 53 | customers.where{ paid && credit == 1000 } 54 | # => <#Customers [{paid: true, credit: 1000}] 55 | 56 | class Customer 57 | attr_reader :paid, :credit 58 | def initialize(paid:, credit:) 59 | @paid = paid 60 | @credit = credit 61 | end 62 | end 63 | 64 | customers << Customer.new(paid: true, credit: 1500) # Or Objects 65 | ``` 66 | 67 | ### English Like DSL 68 | 69 | ```ruby 70 | require "active_enumerable" 71 | 72 | class People 73 | include ActiveEnumerable 74 | 75 | scope :unpaid, -> { where(paid: false).or(credit: 0) } 76 | end 77 | 78 | people = People.new([{ name: "Reuben" }, { name: "Naomi" }]) 79 | people.where { has(:name).of("Reuben") } 80 | #=> <#People [{ name: "Reuben" }]] 81 | 82 | 83 | people = People.new( [ 84 | { name: "Reuben", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 33 }] }, 85 | { name: "Naomi", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 41 }] } 86 | ] ) 87 | 88 | people.where { has(:parents).of(age: 29, name: "Mom").or(age: 33, name: "Dad") } 89 | #=> <#People [{ name: "Reuben", parents: [...] }, { name: "Naomi", parents: [...] }] 90 | ``` 91 | 92 | ## Development 93 | 94 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 95 | 96 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 97 | 98 | ## Contributing 99 | 100 | This is a community project and commit access will be granted to those who show interest by having a history of acceptable pull-requests. 101 | 102 | Bug reports and pull requests are welcome on GitHub at https://github.com/zeisler/active_enumerable. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](contributor-covenant.org) code of conduct. 103 | 104 | 105 | ## License 106 | 107 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 108 | 109 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /active_enumerable-1.0.0.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeisler/active_enumerable/7af2f9ca7a1bbdad226738b005687f6edae989a1/active_enumerable-1.0.0.gem -------------------------------------------------------------------------------- /active_enumerable.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'active_enumerable/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "active_enumerable" 8 | spec.version = ActiveEnumerable::VERSION 9 | spec.authors = ["Dustin Zeisler"] 10 | spec.email = ["dustin@zeisler.net"] 11 | 12 | spec.summary = %q{Provides ActiveRecord like query methods for use in Ruby Enumerable collections.} 13 | spec.description = %q{Provides ActiveRecord like query methods for use in Ruby Enumerable collections. Use Hashes or custom Ruby Objects to represent records.} 14 | spec.homepage = "https://github.com/zeisler/active_enumerable" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.required_ruby_version = '>= 2.1' 23 | 24 | spec.add_development_dependency "bundler", "~> 1.10" 25 | spec.add_development_dependency "rake", "~> 10.0" 26 | spec.add_development_dependency "rspec", "~> 3.4" 27 | spec.add_development_dependency "simplecov", "~> 0.15.1" 28 | end 29 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "active_enumerable" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /lib/active_enumerable.rb: -------------------------------------------------------------------------------- 1 | require "active_enumerable/version" 2 | require "active_enumerable/base" 3 | require "active_enumerable/record_not_found" 4 | require "active_enumerable/comparable" 5 | require "active_enumerable/finder" 6 | require "active_enumerable/method_caller" 7 | require "active_enumerable/scope_method" 8 | require "active_enumerable/scopes" 9 | require "active_enumerable/where" 10 | require "active_enumerable/queries" 11 | require "active_enumerable/english_dsl" 12 | 13 | module ActiveEnumerable 14 | include Base 15 | include Enumerable 16 | include Comparable 17 | include Queries 18 | include Scopes 19 | include EnglishDsl 20 | 21 | def self.included(base) 22 | base.extend(Scopes::ClassMethods) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/active_enumerable/base.rb: -------------------------------------------------------------------------------- 1 | module ActiveEnumerable 2 | module Base 3 | include ::Enumerable 4 | 5 | def each(*args, &block) 6 | @collection.send(:each, *args, &block) 7 | end 8 | 9 | def initialize(collection=[]) 10 | active_enumerable_setup(collection) 11 | end 12 | 13 | def active_enumerable_setup(collection=[]) 14 | if collection.is_a? ::Enumerator::Lazy 15 | @collection = collection 16 | else 17 | @collection = collection.to_a 18 | end 19 | end 20 | 21 | def to_a 22 | @collection.to_a 23 | end 24 | 25 | def <<(item) 26 | @collection << item 27 | end 28 | 29 | alias_method :add, :<< 30 | 31 | def all 32 | self.tap { to_a } 33 | end 34 | 35 | def name 36 | self.class.name 37 | end 38 | 39 | # @private 40 | def __new_relation__(collection) 41 | self.class.new(collection) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/active_enumerable/comparable.rb: -------------------------------------------------------------------------------- 1 | module ActiveEnumerable 2 | module Comparable 3 | include ::Comparable 4 | 5 | def <=>(anOther) 6 | if anOther.is_a?(Array) || self.class == anOther.class 7 | to_a <=> anOther.to_a 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/active_enumerable/english_dsl.rb: -------------------------------------------------------------------------------- 1 | module ActiveEnumerable 2 | module EnglishDsl 3 | include ScopeMethod 4 | include Where 5 | 6 | UnmetCondition = Class.new(StandardError) 7 | 8 | # @param [Hash] 9 | # @yield takes block to evaluate English Dsl 10 | # #where{ has(:name).of("Dustin") } 11 | # @return [ActiveEnumerable] 12 | def where(*args, &block) 13 | if block_given? 14 | scope(&block).send(:_english_eval__) 15 | else 16 | super 17 | end 18 | end 19 | 20 | # @param [String, Symbol] attr is either a method name a Hash key. 21 | # has(:name) 22 | def has(attr) 23 | all_conditions << [attr] 24 | self 25 | end 26 | 27 | # @param [Hash, Object] matches is list of sub conditions or associations to query. 28 | # Or this can by any value to compare the result from attr. 29 | def of(matches=nil, &block) 30 | raise ArgumentError if matches.nil? && block.nil? 31 | if all_conditions.empty? || !(all_conditions.last.count == 1) 32 | raise UnmetCondition, ".has(attr) must be call before calling #of." 33 | else 34 | all_conditions.last << (matches || block) 35 | self 36 | end 37 | end 38 | 39 | # After calling #of(matches) providing additional matches to #or(matches) build a either or query. 40 | # @param [Hash, Object] matches is list of sub conditions or associations to query. 41 | # Or this can by any value to compare the result from attr. 42 | def or(matches) 43 | raise UnmetCondition, ".has(attr).of(matches) must be call before calling #or(matches)." if all_conditions.empty? || !(all_conditions.last.count == 2) 44 | evaluation_results << english_where 45 | all_conditions.last[1] = matches 46 | evaluation_results << english_where 47 | self 48 | end 49 | 50 | # @param [Hash, Object, NilClass] matches is list of sub conditions or associations to query. 51 | # Or this can by any value to compare the result from attr. 52 | # Or passing nothing and provide a different has(attr) 53 | # has(attr).of(matches).and.has(other_attr) 54 | def and(matches=nil) 55 | if matches 56 | all_conditions.last[1].merge!(matches) 57 | evaluation_results << english_where 58 | end 59 | self 60 | end 61 | 62 | private 63 | 64 | def all_conditions 65 | @all_conditions ||= [] 66 | end 67 | 68 | def _english_eval__ 69 | if evaluation_results.empty? 70 | english_where 71 | else 72 | __new_relation__ evaluation_results.flat_map(&:to_a).uniq 73 | end 74 | end 75 | 76 | def english_where 77 | where all_conditions.each_with_object({}) { |e, h| h[e[0]] = e[1] } 78 | end 79 | 80 | def evaluation_results 81 | @evaluation_results ||= [] 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/active_enumerable/extract_options.rb: -------------------------------------------------------------------------------- 1 | class Hash 2 | # By default, only instances of Hash itself are extractable. 3 | # Subclasses of Hash may implement this method and return 4 | # true to declare themselves as extractable. If a Hash 5 | # is extractable, Array#extract_options! pops it from 6 | # the Array when it is the last element of the Array. 7 | def extractable_options? 8 | instance_of?(Hash) 9 | end 10 | end unless Hash.respond_to?(:extractable_options?) 11 | 12 | class Array 13 | # Extracts options from a set of arguments. Removes and returns the last 14 | # element in the array if it's a hash, otherwise returns a blank hash. 15 | # 16 | # def options(*args) 17 | # args.extract_options! 18 | # end 19 | # 20 | # options(1, 2) # => {} 21 | # options(1, 2, a: :b) # => {:a=>:b} 22 | def extract_options! 23 | if last.is_a?(Hash) && last.extractable_options? 24 | pop 25 | else 26 | {} 27 | end 28 | end 29 | end unless Array.respond_to?(:extract_options!) 30 | -------------------------------------------------------------------------------- /lib/active_enumerable/finder.rb: -------------------------------------------------------------------------------- 1 | module ActiveEnumerable 2 | 3 | class Finder 4 | def initialize(record) 5 | @method_caller = MethodCaller.new(record) 6 | end 7 | 8 | # Regex conditions 9 | # Finder.new({ name: "Timmy" }).is_of({ name: /Tim/ }) 10 | # #=> true 11 | # 12 | # Hash conditions 13 | # record = { name: "Timmy", parents: [{ name: "Dad", age: 33 }, { name: "Mom", age: 29 }] } } 14 | # 15 | # Matching array of partial hashes identities 16 | # Finder.new(record).is_of(parents: [{ name: "Dad" }, { name: "Mom" }])) 17 | # #=> true 18 | # 19 | # Matching partial hashes identities to an array of hashes 20 | # Finder.new(record).is_of(parents: { name: "Dad", age: 33 }) 21 | # #=> true 22 | # 23 | # Array conditions 24 | # record = { name: "Timmy" } 25 | # 26 | # Finder.new(record).is_of(name: %w(Timmy Fred)) 27 | # #=> true 28 | # Finder.new(record).is_of(name: ["Sammy", /Tim/]) 29 | # #=> true 30 | # 31 | # Value conditions 32 | # record = { name: "Timmy", age: 10 } 33 | # 34 | # Finder.new(record).is_of(name: "Timmy") 35 | # #=> true 36 | # Finder.new(record).is_of(age: 10) 37 | # #=> true 38 | # 39 | # @param [Hash] conditions 40 | # @return [true, false] 41 | def is_of(conditions = {}) 42 | conditions.all? do |col, match| 43 | case match 44 | when Proc 45 | proc_match(col, match) 46 | when Hash 47 | hash_match(col, match) 48 | when Array 49 | array_match(col, match) 50 | else 51 | compare(col, match) 52 | end 53 | end 54 | end 55 | 56 | private 57 | 58 | def proc_match(col, match) 59 | return @method_caller.instance_exec(&match) unless col 60 | next_record = @method_caller.call(col) 61 | if next_record.is_a? Array 62 | next_record.all? { |record| Finder.new(record).is_of({nil => match}) } 63 | else 64 | MethodCaller.new(next_record).instance_exec(&match) 65 | end 66 | end 67 | 68 | def hash_match(col, match) 69 | next_record = @method_caller.call(col) 70 | if next_record.is_a? Array 71 | next_record.any? { |record| Finder.new(record).is_of(match) } 72 | else 73 | Finder.new(next_record).is_of(match) 74 | end 75 | end 76 | 77 | def array_match(col, match) 78 | if @method_caller.call(col).is_a? Array 79 | if !(r = compare(col, match)) && match.map(&:class).uniq == [Hash] 80 | match.all? { |m| hash_match(col, m) } 81 | else 82 | r 83 | end 84 | else 85 | match.any? { |m| compare(col, m) } 86 | end 87 | end 88 | 89 | def compare(col, match) 90 | @method_caller.call(col).public_send(compare_by(match), match) 91 | end 92 | 93 | def compare_by(match) 94 | (match.is_a? Regexp) ? :=~ : :== 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/active_enumerable/method_caller.rb: -------------------------------------------------------------------------------- 1 | module ActiveEnumerable 2 | # @private 3 | class MethodCaller 4 | attr_reader :__object__, :raise_no_method 5 | 6 | def initialize(object, raise_no_method: true) 7 | @__object__ = object 8 | @raise_no_method = raise_no_method 9 | end 10 | 11 | def call(method) 12 | if __object__.is_a? Hash 13 | wrap_return __object__.fetch(method) 14 | else 15 | wrap_return __object__.public_send(method) 16 | end 17 | rescue NoMethodError => e 18 | raise e if raise_no_method 19 | rescue KeyError => e 20 | raise e, "#{e.message} for #{__object__}" if raise_no_method 21 | end 22 | 23 | def method_missing(method) 24 | call(method) 25 | end 26 | 27 | private 28 | 29 | def wrap_return(return_value) 30 | case return_value 31 | when Hash 32 | self.class.new(return_value, raise_no_method: raise_no_method) 33 | else 34 | return_value 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/active_enumerable/order.rb: -------------------------------------------------------------------------------- 1 | require "active_enumerable/extract_options" 2 | 3 | module ActiveEnumerable 4 | # @private 5 | module Order 6 | class << self 7 | def call(args, all) 8 | options = args.extract_options! 9 | if options.empty? && args.count == 1 10 | all.sort_by { |item| MethodCaller.new(item).call(args.first) } 11 | else 12 | order_mixed_args(all, args, options) 13 | end 14 | end 15 | 16 | private 17 | 18 | def order_mixed_args(all, args, options) 19 | normalized_opt = args.each_with_object({}) { |a, h| h[a] = :asc }.merge(options) # Add non specified direction keys 20 | all.sort { |a, b| build_order(a, normalized_opt) <=> build_order(b, normalized_opt) } 21 | end 22 | 23 | def build_order(a, options) 24 | options.map { |k, v| send(v, MethodCaller.new(a).call(k)) } 25 | end 26 | 27 | def desc(r) 28 | DESC.new(r) 29 | end 30 | 31 | def asc(r) 32 | r 33 | end 34 | end 35 | 36 | class DESC 37 | attr_reader :r 38 | 39 | def initialize(r) 40 | @r = r 41 | end 42 | 43 | def <=>(other) 44 | -(r <=> other.r) # Flip negative/positive result 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/active_enumerable/queries.rb: -------------------------------------------------------------------------------- 1 | require "active_enumerable/order" 2 | require "bigdecimal" 3 | 4 | module ActiveEnumerable 5 | module Queries 6 | # Find by id - Depends on either having an Object#id or Hash{id: Integer} 7 | # This can either be a specific id (1), a list of ids (1, 5, 6), or an array of ids ([5, 6, 10]). 8 | # If no record can be found for all of the listed ids, then RecordNotFound will be raised. If the primary key 9 | # is an integer, find by id coerces its arguments using +to_i+. 10 | # 11 | # <#ActiveEnumerable>.find(1) # returns the object for ID = 1 12 | # <#ActiveEnumerable>.find(1, 2, 6) # returns an array for objects with IDs in (1, 2, 6) 13 | # <#ActiveEnumerable>.find([7, 17]) # returns an array for objects with IDs in (7, 17) 14 | # <#ActiveEnumerable>.find([1]) # returns an array for the object with ID = 1 15 | # 16 | # ActiveEnumerable::RecordNotFound will be raised if one or more ids are not found. 17 | # @return [ActiveEnumerable, Object] 18 | # @param [*Fixnum, Array] args 19 | def find(*args) 20 | raise RecordNotFound.new("Couldn't find #{self.respond_to?(:name) ? self.name : self.class.name} without an ID") if args.compact.empty? 21 | if args.count > 1 || args.first.is_a?(Array) 22 | __new_relation__(args.flatten.lazy.map do |id| 23 | find_by!(id: id.to_i) 24 | end) 25 | else 26 | find_by!(id: args.first.to_i) 27 | end 28 | end 29 | 30 | # Finds the first record matching the specified conditions. There 31 | # is no implied ordering so if order matters, you should specify it 32 | # yourself. 33 | # 34 | # If no record is found, returns nil. 35 | # 36 | # <#ActiveEnumerable>.find_by name: 'Spartacus', rating: 4 37 | # 38 | # # @see ActiveEnumerable::Finder#is_of for all usages of conditions. 39 | def find_by(conditions = {}) 40 | to_a.detect do |record| 41 | Finder.new(record).is_of(conditions) 42 | end 43 | end 44 | 45 | # Like find_by, except that if no record is found, raises 46 | # an ActiveEnumerable::RecordNotFound error. 47 | def find_by!(conditions={}) 48 | result = find_by(conditions) 49 | if result.nil? 50 | raise RecordNotFound.new("Couldn't find #{self.name} with '#{conditions.keys.first}'=#{conditions.values.first}") 51 | end 52 | result 53 | end 54 | 55 | # Count the records. 56 | # 57 | # <#ActiveEnumerable>.count 58 | # # => the total count of all people 59 | # 60 | # <#ActiveEnumerable>.count(:age) 61 | # # => returns the total count of all people whose age is not nil 62 | def count(name = nil) 63 | return to_a.size if name.nil? 64 | to_a.reject { |record| Finder.new(record).is_of(name: nil) }.size 65 | end 66 | 67 | # Specifies a limit for the number of records to retrieve. 68 | # 69 | # <#ActiveEnumerable>.limit(10) 70 | def limit(num) 71 | __new_relation__(all.take(num)) 72 | end 73 | 74 | # Calculates the sum of values on a given attribute. The value is returned 75 | # with the same data type of the attribute, 0 if there's no row. 76 | # 77 | # <#ActiveEnumerable>.sum(:age) # => 4562 78 | def sum(key) 79 | values = values_by_key(key) 80 | values.inject(0) do |sum, n| 81 | sum + (n || 0) 82 | end 83 | end 84 | 85 | # Calculates the average value on a given attribute. Returns +nil+ if there's 86 | # no row. 87 | # 88 | # <#ActiveEnumerable>.average(:age) # => 35.8 89 | def average(key) 90 | values = values_by_key(key) 91 | total = values.inject { |sum, n| sum + n } 92 | return unless total 93 | BigDecimal.new(total) / BigDecimal.new(values.count) 94 | end 95 | 96 | # Calculates the minimum value on a given attribute. Returns +nil+ if there's 97 | # no row. 98 | # 99 | # <#ActiveEnumerable>.minimum(:age) # => 7 100 | def minimum(key) 101 | values_by_key(key).min_by { |i| i } 102 | end 103 | 104 | # Calculates the maximum value on a given attribute. The value is returned 105 | # with the same data type of the attribute, or +nil+ if there's no row. 106 | # 107 | # <#ActiveEnumerable>.maximum(:age) # => 93 108 | def maximum(key) 109 | values_by_key(key).max_by { |i| i } 110 | end 111 | 112 | # Allows to specify an order attribute: 113 | # 114 | # <#ActiveEnumerable>.order('name') 115 | # 116 | # <#ActiveEnumerable>.order(:name) 117 | # 118 | # <#ActiveEnumerable>.order(email: :desc) 119 | # 120 | # <#ActiveEnumerable>.order(:name, email: :desc) 121 | def order(*args) 122 | __new_relation__(Order.call(args, all)) 123 | end 124 | 125 | 126 | # Reverse the existing order clause on the relation. 127 | # 128 | # <#ActiveEnumerable>.order('name').reverse_order 129 | def reverse_order 130 | __new_relation__(to_a.reverse) 131 | end 132 | 133 | # Returns a chainable relation with zero records. 134 | # 135 | # Any subsequent condition chained to the returned relation will continue 136 | # generating an empty relation. 137 | # 138 | # Used in cases where a method or scope could return zero records but the 139 | # result needs to be chainable. 140 | # 141 | # For example: 142 | # 143 | # @posts = current_user.visible_posts.where(name: params[:name]) 144 | # # => the visible_posts method is expected to return a chainable Relation 145 | # 146 | # def visible_posts 147 | # case role 148 | # when 'Country Manager' 149 | # <#ActiveEnumerable>.where(country: country) 150 | # when 'Reviewer' 151 | # <#ActiveEnumerable>.published 152 | # when 'Bad User' 153 | # <#ActiveEnumerable>.none # It can't be chained if [] is returned. 154 | # end 155 | # end 156 | # 157 | def none 158 | __new_relation__([]) 159 | end 160 | 161 | private 162 | 163 | def values_by_key(key) 164 | all.map { |obj| MethodCaller.new(obj).call(key) } 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /lib/active_enumerable/record_not_found.rb: -------------------------------------------------------------------------------- 1 | module ActiveEnumerable 2 | class RecordNotFound < StandardError 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/active_enumerable/scope_method.rb: -------------------------------------------------------------------------------- 1 | module ActiveEnumerable 2 | module ScopeMethod 3 | def scope(&block) 4 | result = instance_exec(&block) 5 | if result.is_a? Array 6 | __new_relation__(result) 7 | else 8 | result 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/active_enumerable/scopes.rb: -------------------------------------------------------------------------------- 1 | module ActiveEnumerable 2 | module Scopes 3 | include ScopeMethod 4 | def method_missing(meth, *args, &block) 5 | if create_scope_method(meth) 6 | send(meth, *args, &block) 7 | else 8 | super 9 | end 10 | end 11 | 12 | def respond_to_missing?(meth, _include_private = false) 13 | create_scope_method(meth) 14 | end 15 | 16 | def create_scope_method(meth) 17 | if (scope = self.class.__scoped_methods__.find { |a| a.first == meth }) 18 | self.define_singleton_method(scope.first) do 19 | scope(&scope.last) 20 | end 21 | end 22 | end 23 | 24 | private :create_scope_method 25 | 26 | module ClassMethods 27 | def scope(name, block) 28 | __scoped_methods__ << [name, block] 29 | end 30 | 31 | def __scoped_methods__ 32 | @__scoped_methods__ ||= [] 33 | end 34 | end 35 | 36 | def self.included(base) 37 | base.extend(ClassMethods) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/active_enumerable/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveEnumerable 2 | VERSION = "1.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /lib/active_enumerable/where.rb: -------------------------------------------------------------------------------- 1 | require "active_enumerable/finder" 2 | require "active_enumerable/where/where_not_chain" 3 | require "active_enumerable/where/where_or_chain" 4 | 5 | module ActiveEnumerable 6 | module Where 7 | # Returns a new relation, which is the result of filtering the current relation 8 | # according to the conditions in the arguments. 9 | # 10 | # === hash 11 | # 12 | # #where will accept a hash condition, in which the keys are fields and the values 13 | # are values to be searched for. 14 | # 15 | # Fields can be symbols or strings. Values can be single values, arrays, or ranges. 16 | # 17 | # <#ActiveEnumerable>.where({ name: "Joe", email: "joe@example.com" }) 18 | # 19 | # <#ActiveEnumerable>.where({ name: ["Alice", "Bob"]}) 20 | # 21 | # <#ActiveEnumerable>.where({ created_at: (Time.now.midnight - 1.day)..Time.now.midnight }) 22 | # 23 | # <#ActiveEnumerable>.where(contracts:[{ created_at: (Time.now.midnight - 1.day)..Time.now.midnight }]) 24 | # 25 | # .or 26 | # 27 | # Returns a new relation, which is the logical union of this relation and the one passed as an 28 | # argument. 29 | # 30 | # The two relations must be structurally compatible: they must be scoping the same model, and 31 | # they must differ only by #where. 32 | # 33 | # <#ActiveEnumerable>.where(id: 1).or(<#ActiveEnumerable>.where(author_id: 3)) 34 | # 35 | # Additional conditions can be passed to where in hash form. 36 | # 37 | # <#ActiveEnumerable>.where(id: 1).or(author_id: 3) 38 | # 39 | # @see ActiveEnumerable::Finder#is_of for all usages of conditions. 40 | def where(conditions = nil, &block) 41 | return WhereNotChain.new(all, method(:__new_relation__)) unless conditions || block 42 | conditions = conditions || { nil => block } 43 | create_where_relation(conditions, to_a.select do |record| 44 | Finder.new(record).is_of(conditions || { nil => block }) 45 | end).tap do |where| 46 | where.extend(WhereOrChain) 47 | where.original_collection = to_a 48 | end 49 | end 50 | 51 | def where_conditions 52 | @where_conditions ||= {} 53 | end 54 | 55 | def create_where_relation(conditions, array) 56 | nr = __new_relation__(array) 57 | nr.where_conditions.merge!(conditions) 58 | nr 59 | end 60 | 61 | private :create_where_relation 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/active_enumerable/where/where_not_chain.rb: -------------------------------------------------------------------------------- 1 | module ActiveEnumerable 2 | module Where 3 | class WhereNotChain 4 | def initialize(collection, parent_class) 5 | @collection = collection 6 | @parent_class = parent_class 7 | end 8 | 9 | # Returns a new relation expressing WHERE + NOT condition according to 10 | # the conditions in the arguments. 11 | # 12 | # #not accepts conditions as a string, array, or hash. See Where#where for 13 | # more details on each format. 14 | # 15 | # <#ActiveEnumerable>.where.not(name: "Jon") 16 | # <#ActiveEnumerable>.where.not(name: nil) 17 | # <#ActiveEnumerable>.where.not(name: %w(Ko1 Nobu)) 18 | # <#ActiveEnumerable>.where.not(name: "Jon", role: "admin") 19 | def not(conditions = {}) 20 | @parent_class.call(@collection.reject do |record| 21 | Finder.new(record).is_of(conditions) 22 | end) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/active_enumerable/where/where_or_chain.rb: -------------------------------------------------------------------------------- 1 | module ActiveEnumerable 2 | module Where 3 | module WhereOrChain 4 | def or(conditions_or_relation) 5 | conditions = get_conditions(conditions_or_relation) 6 | or_result = create_where_relation(where_conditions, original_collection).where(conditions) 7 | create_where_relation(or_result.where_conditions, to_a.concat(or_result.to_a).uniq) 8 | end 9 | 10 | attr_accessor :original_collection 11 | 12 | private 13 | 14 | def get_conditions(conditions_or_relation) 15 | if conditions_or_relation.respond_to?(:where_conditions) 16 | conditions_or_relation.where_conditions 17 | else 18 | conditions_or_relation 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/active_enumerable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ActiveEnumerable do 4 | it 'has a version number' do 5 | expect(ActiveEnumerable::VERSION).to match(/d*\.\d*\.\d*/) 6 | end 7 | 8 | it "README example"do 9 | class Customers 10 | include ActiveEnumerable 11 | 12 | scope :unpaid, -> { where(paid: false).or(credit: 0) } 13 | end 14 | 15 | customers = Customers.new([{ paid: true, credit: 1000 }, { paid: false, credit: 2000 }, { paid: false, credit: 0 }]) 16 | 17 | expect(customers.unpaid.to_a).to eq([{ :paid => false, :credit => 2000 }, { :paid => false, :credit => 0 }]) 18 | 19 | expect(customers.scope { select { |y| y[:credit] >= 1000 } }.to_a).to eq([{ paid: true, credit: 1000 }, { paid: false, credit: 2000 }]) 20 | 21 | expect(customers.sum(:credit)).to eq(3000) 22 | 23 | customers << { paid: true, credit: 1500 } # accepts Hashes 24 | 25 | class Customer 26 | attr_reader :paid, :credit 27 | 28 | def initialize(paid:, credit:) 29 | @paid = paid 30 | @credit = credit 31 | end 32 | end 33 | 34 | customers << Customer.new(paid: true, credit: 1500) # Or Objects 35 | 36 | expect(customers.count).to eq(5) 37 | end 38 | 39 | it "README example" do 40 | class People 41 | include ActiveEnumerable 42 | 43 | scope :unpaid, -> { where(paid: false).or(credit: 0) } 44 | end 45 | 46 | people = People.new([{ name: "Reuben" }, { name: "Naomi" }]) 47 | expect(people.where { has(:name).of("Reuben") }.to_a).to eq([{ name: "Reuben" }]) 48 | 49 | 50 | people = People.new([ 51 | { name: "Reuben", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 33 }] }, 52 | { name: "Naomi", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 41 }] } 53 | ]) 54 | 55 | 56 | expect(people.where { has(:parents).of(age: 29, name: "Mom").or(age: 33, name: "Dad") }).to eq(people) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/base_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "ostruct" 3 | 4 | RSpec.describe ActiveEnumerable::Base do 5 | 6 | class BaseTestActiveEnumerable 7 | include ActiveEnumerable::Base 8 | def initialize(collection=[]) 9 | active_enumerable_setup(collection) 10 | end 11 | end 12 | 13 | describe "initialize" do 14 | it "raise an error if type not array" do 15 | expect { BaseTestActiveEnumerable.new(1) }.to raise_error(NoMethodError) 16 | end 17 | end 18 | 19 | describe "#to_a" do 20 | it "returns the array passed to the initializer" do 21 | passed_array = [1, 2, 4] 22 | expect(BaseTestActiveEnumerable.new(passed_array).to_a).to eq passed_array 23 | end 24 | end 25 | 26 | describe "#__new_relation__" do 27 | it "alias method to self.class.new" do 28 | expect(BaseTestActiveEnumerable.new([]).__new_relation__([1, 2]).to_a).to eq [1, 2] 29 | end 30 | end 31 | 32 | describe "#add" do 33 | it "adds an item to the collection" do 34 | subject = BaseTestActiveEnumerable.new 35 | subject.add(name: "Naomi", age: 4) 36 | expect(subject.to_a).to eq [{ name: "Naomi", age: 4 }] 37 | end 38 | 39 | it "works the same as :<<" do 40 | subject = BaseTestActiveEnumerable.new 41 | subject << { name: "Naomi", age: 4 } 42 | expect(subject.to_a).to eq [{ name: "Naomi", age: 4 }] 43 | end 44 | end 45 | 46 | describe "#all" do 47 | it "returns it's self" do 48 | subject = BaseTestActiveEnumerable.new 49 | expect(subject.all).to eq subject 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/comparable_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "ostruct" 3 | 4 | RSpec.describe ActiveEnumerable::Where do 5 | 6 | let(:item_objects) { [OpenStruct.new(name: "Fred"), OpenStruct.new(name: "Dave")] } 7 | let(:item_hashes) { [{ name: "Fred" }, { name: "Sam" }, { name: "Dave" }] } 8 | 9 | context "Active Enumerable's with the same collection are comparable" do 10 | context "with only base and comparable components" do 11 | class TestEnumerableBasic 12 | include ActiveEnumerable::Base 13 | include ActiveEnumerable::Comparable 14 | end 15 | 16 | it "with an array of object" do 17 | expect(TestEnumerableBasic.new(item_objects)).to eq TestEnumerableBasic.new(item_objects) 18 | end 19 | 20 | it "with an array of hashes" do 21 | expect(TestEnumerableBasic.new(item_hashes)).to eq TestEnumerableBasic.new(item_hashes) 22 | end 23 | end 24 | 25 | context "with all components" do 26 | class TestEnumerableFull 27 | include ActiveEnumerable 28 | end 29 | 30 | it "with an array of object" do 31 | expect(TestEnumerableFull.new(item_objects)).to eq TestEnumerableFull.new(item_objects) 32 | end 33 | 34 | it "with an array of hashes" do 35 | expect(TestEnumerableFull.new(item_hashes)).to eq TestEnumerableFull.new(item_hashes) 36 | end 37 | end 38 | end 39 | end 40 | 41 | -------------------------------------------------------------------------------- /spec/english_dsl_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ActiveEnumerable::EnglishDsl do 2 | 3 | class TestEnglishDsl 4 | include ActiveEnumerable::Base 5 | include ActiveEnumerable::EnglishDsl 6 | end 7 | 8 | subject { TestEnglishDsl.new(records) } 9 | 10 | describe "#where(&block)" do 11 | context "has(attr).of(single_match)" do 12 | let(:records) { [{ name: "Reuben" }, { name: "Naomi" }] } 13 | let(:result) { subject.where { has(:name).of("Reuben") } } 14 | it { expect(result).to be_an_instance_of TestEnglishDsl } 15 | it { expect(result.to_a).to eq [{ name: "Reuben" }] } 16 | end 17 | 18 | context "has(attr).of(true)" do 19 | let(:records) { [{ dog: true }, { dog: false }] } 20 | let(:result) { subject.where { has(:dog).of(true) } } 21 | it { expect(result.to_a).to eq [records.first] } 22 | end 23 | 24 | context "has(attr).of(matches)" do 25 | let(:records) { [ 26 | { name: "Reuben", parents: [{ name: "Mom", age: 26 }, { name: "Dad", age: 33 }] }, 27 | { name: "Naomi", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 33 }] } 28 | ] } 29 | let(:result) { subject.where { has(:parents).of(age: 29) } } 30 | it { expect(result).to be_an_instance_of TestEnglishDsl } 31 | it { expect(result.to_a).to eq [records.last] } 32 | 33 | context "calling in wrong order" do 34 | it { expect { subject.where { of(age: 29) } }.to raise_error(described_class::UnmetCondition, ".has(attr) must be call before calling #of.") } 35 | end 36 | end 37 | 38 | context "has(attr).of(matches).or(matches)" do 39 | let(:records) { [ 40 | { name: "Reuben", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 33 }] }, 41 | { name: "Naomi", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 41 }] } 42 | ] } 43 | let(:result) { subject.where { has(:parents).of(age: 29, name: "Mom").or(age: 33, name: "Dad") } } 44 | it { expect(result).to be_an_instance_of TestEnglishDsl } 45 | it { expect(result.to_a).to eq records } 46 | 47 | context "calling in wrong order" do 48 | it { expect { subject.where { self.or(age: 29) } }.to raise_error(described_class::UnmetCondition, ".has(attr).of(matches) must be call before calling #or(matches).") } 49 | end 50 | end 51 | 52 | context "has(attr).of(matches).and(matches)" do 53 | let(:records) { [ 54 | { name: "Reuben", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 33 }] }, 55 | { name: "Naomi", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 41 }] } 56 | ] } 57 | let(:result) { subject.where { has(:parents).of(age: 29, name: "Mom").and(age: 33, name: "Dad") } } 58 | it { expect(result).to be_an_instance_of TestEnglishDsl } 59 | it { expect(result.to_a).to eq [records.first] } 60 | 61 | context "of(&block)" do 62 | let(:records) { [ 63 | { name: "Reuben", parents: [{ name: "Mom", age: 30 }, { name: "Dad", age: 33 }] }, 64 | { name: "Naomi", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 41 }] } 65 | ] } 66 | let(:result) { subject.where { has(:parents).of { age > 29 } } } 67 | it { expect(result).to be_an_instance_of TestEnglishDsl } 68 | it { expect(result.to_a).to eq [records.first] } 69 | end 70 | end 71 | 72 | context "has(attr).of(matches).and.of(matches)" do 73 | let(:records) { [ 74 | { name: "Reuben", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 33 }] }, 75 | { name: "Naomi", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 41 }] } 76 | ] } 77 | let(:result) { subject.where { has(:parents).of(age: 29, name: "Mom").and.has(:name).of("Naomi") } } 78 | it { expect(result).to be_an_instance_of TestEnglishDsl } 79 | it { expect(result.to_a).to eq [records.last] } 80 | end 81 | 82 | context "#where still works" do 83 | let(:records) { [ 84 | { name: "Reuben", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 33 }] }, 85 | { name: "Naomi", parents: [{ name: "Mom", age: 29 }, { name: "Dad", age: 41 }] } 86 | ] } 87 | let(:result) { subject.where(name: "Reuben") } 88 | it { expect(result).to be_an_instance_of TestEnglishDsl } 89 | it { expect(result.to_a).to eq [records.first] } 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/finder_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ActiveEnumerable::Finder do 4 | describe "#is_of" do 5 | subject { described_class.new(record) } 6 | 7 | context "regex condition" do 8 | let(:record) { { name: "Timmy" } } 9 | 10 | it { expect(subject.is_of({ name: /Tim/ })).to eq(true) } 11 | it { expect(subject.is_of({ name: /Tix/ })).to eq(false) } 12 | end 13 | 14 | context "hash condition" do 15 | let(:record) { { name: "Timmy", parents: [{ name: "Dad", age: 33 }, { name: "Mom", age: 29 }] } } 16 | 17 | context "matches array of partial hashes identities" do 18 | it { expect(subject.is_of(parents: [{ name: "Dad" }, { name: "Mom" }])).to eq(true) } 19 | it { expect(subject.is_of(parents: [{ name: "Dad", age: 29 }, { name: "Mom", age: 33 }])).to eq(false) } 20 | it { expect(subject.is_of(parents: [{ name: "Dad", age: 33 }, { name: "Mom", age: 29 }])).to eq(true) } 21 | end 22 | 23 | context "matches partial hashes identities to an array of hashes" do 24 | it { expect(subject.is_of(parents: { name: "Dad", age: 33 })).to eq(true) } 25 | it { expect(subject.is_of(parents: { name: "Dad", age: 29 })).to eq(false) } 26 | it { expect(subject.is_of(parents: { name: "Dad" })).to eq(true) } 27 | end 28 | end 29 | 30 | context "array condition" do 31 | let(:record) { { name: "Timmy" } } 32 | 33 | it { expect(subject.is_of({ name: %w(Timmy Fred) })).to eq(true) } 34 | it { expect(subject.is_of({ name: %w(Sammy Fred) })).to eq(false) } 35 | it { expect(subject.is_of({ name: ["Sammy", /Tim/] })).to eq(true) } 36 | it { expect(subject.is_of({ name: ["Sammy", /Tix/] })).to eq(false) } 37 | 38 | context "when match is equal to value" do 39 | let(:record) { { name: %w(Timmy Fred Jim) } } 40 | 41 | it { expect(subject.is_of(name: %w(Timmy Fred Jim))).to eq(true) } 42 | it { expect(subject.is_of(name: %w(Timmy Ted Jim))).to eq(false) } 43 | end 44 | end 45 | 46 | context "value condition" do 47 | let(:record) { { name: "Timmy", age: 10 } } 48 | 49 | it { expect(subject.is_of(name: "Timmy")).to eq(true) } 50 | it { expect(subject.is_of(name: "Jim")).to eq(false) } 51 | it { expect(subject.is_of(age: 10)).to eq(true) } 52 | it { expect(subject.is_of(name: 11)).to eq(false) } 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/method_caller_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "ostruct" 3 | 4 | RSpec.describe ActiveEnumerable::MethodCaller do 5 | 6 | describe "#call" do 7 | context "when object is hash" do 8 | it "returns the values" do 9 | expect(described_class.new({name: "David"}).call(:name)).to eq "David" 10 | end 11 | 12 | it "raises an error when no method" do 13 | expect{described_class.new({name: "David"}).call(:first_name)}.to raise_error(KeyError, %[key not found: :first_name for {:name=>"David"}]) 14 | end 15 | 16 | it "returns nil when no method" do 17 | expect(described_class.new({name: "David"}, raise_no_method: false).call(:first_name)).to eq nil 18 | end 19 | end 20 | 21 | context "when is an object" do 22 | Person = Struct.new(:name) 23 | 24 | it "returns the values" do 25 | expect(described_class.new(Person.new("David")).call(:name)).to eq "David" 26 | end 27 | 28 | it "raises an error when no method" do 29 | expect{described_class.new(Person.new("David")).call(:first_name)}.to raise_error(NoMethodError, %[undefined method `first_name' for #]) 30 | end 31 | 32 | it "returns nil when no method" do 33 | expect(described_class.new(Person.new("David"), raise_no_method: false).call(:first_name)).to eq nil 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/queries_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ActiveEnumerable::Queries do 4 | 5 | class QueriesTestEnumerable 6 | include ActiveEnumerable::Base 7 | include ActiveEnumerable::Queries 8 | end 9 | 10 | subject { QueriesTestEnumerable.new(records) } 11 | let(:records) { [{ id: 2, name: "Fred" }, { id: 4, name: "Sam" }, { id: 5, name: "Dave" }] } 12 | 13 | describe "#sum" do 14 | it "queries collection for object with name fred" do 15 | expect(subject.sum(:id)).to eq(11) 16 | end 17 | end 18 | 19 | describe "order" do 20 | context "single arg" do 21 | it { expect(subject.order(:name).to_a.map { |h| h[:name] }).to eq(%w(Dave Fred Sam)) } 22 | end 23 | 24 | context "more that one arg" do 25 | let(:records) { [{ age: 20, name: "Fred" }, { age: 29, name: "Fred" }, { age: 4, name: "Sam" }, { age: 5, name: "Dave" }] } 26 | it { expect(subject.order(:name, :age).to_a).to eq([{ :age => 5, :name => "Dave" }, { :age => 20, :name => "Fred" }, { :age => 29, :name => "Fred" }, { :age => 4, :name => "Sam" }]) } 27 | end 28 | 29 | context "directions with key args" do 30 | let(:records) { [{ age: 20, name: "Fred" }, { age: 29, name: "Fred" }, { age: 4, name: "Sam" }, { age: 5, name: "Dave" }] } 31 | it { expect(subject.order(:name, age: :desc).to_a).to eq([{ :age => 5, :name => "Dave" }, { :age => 29, :name => "Fred" }, { :age => 20, :name => "Fred" }, { :age => 4, :name => "Sam" }]) } 32 | it { expect(subject.order(:name, age: :asc).to_a).to eq([{ :age => 5, :name => "Dave" }, { :age => 20, :name => "Fred" }, { :age => 29, :name => "Fred" }, { :age => 4, :name => "Sam" }]) } 33 | end 34 | end 35 | 36 | describe "#find" do 37 | it { expect(subject.find(5)).to eq({ id: 5, name: "Dave" }) } 38 | it { expect(subject.find(2, 5).to_a).to eq [{ id: 2, name: "Fred" }, { id: 5, name: "Dave" }] } 39 | it { expect(subject.find([2, 5]).to_a).to eq [{ id: 2, name: "Fred" }, { id: 5, name: "Dave" }] } 40 | it { expect(subject.find([2]).to_a).to eq [{ id: 2, name: "Fred" }] } 41 | it { expect(subject.find([2])).to be_an_instance_of QueriesTestEnumerable } 42 | it { expect { subject.find(nil) }.to raise_error(ActiveEnumerable::RecordNotFound, "Couldn't find QueriesTestEnumerable without an ID") } 43 | end 44 | 45 | 46 | describe "#find_by" do 47 | it "queries collection for object with name fred" do 48 | expect(subject.find_by(name: "Fred")).to eq({ id: 2, name: "Fred" }) 49 | end 50 | end 51 | 52 | describe "#find_by!" do 53 | context "when records is not found" do 54 | it "raises an record not found error" do 55 | expect { subject.find_by!(name: "Tim") }.to raise_error(ActiveEnumerable::RecordNotFound) 56 | end 57 | end 58 | 59 | it "queries collection for object with name fred" do 60 | expect(subject.find_by!(name: "Fred")).to eq({ id: 2, name: "Fred" }) 61 | end 62 | end 63 | 64 | describe "#count" do 65 | it "returns a count of all records" do 66 | expect(subject.count).to eq(3) 67 | end 68 | 69 | it "returns the total count of all people whose age is not nil" do 70 | expect(subject.count(:name)).to eq(3) 71 | end 72 | end 73 | 74 | describe "#limit" do 75 | it "specifies a limit for the number of records to retrieve" do 76 | expect(subject.limit(2)).to be_an_instance_of(QueriesTestEnumerable) 77 | expect(subject.limit(2).count).to eq(2) 78 | end 79 | end 80 | 81 | describe "#average" do 82 | it "calculates the average value on a given attribute" do 83 | expect(subject.average(:id).round(2)).to eq(3.67) 84 | end 85 | 86 | it "returns nil if there's no object" do 87 | expect(subject.limit(0).average(:id)).to eq(nil) 88 | end 89 | end 90 | 91 | describe "#minimum" do 92 | it "calculates the minimum value on a given attribute" do 93 | expect(subject.minimum(:id)).to eq(2) 94 | end 95 | 96 | it "returns nil if there's no object" do 97 | expect(subject.limit(0).minimum(:id)).to eq(nil) 98 | end 99 | end 100 | 101 | describe "#maximum" do 102 | it "calculates the maximum value on a given attribute" do 103 | expect(subject.maximum(:id)).to eq(5) 104 | end 105 | 106 | it "returns nil if there's no object" do 107 | expect(subject.limit(0).maximum(:id)).to eq(nil) 108 | end 109 | end 110 | 111 | describe "#reverse_order" do 112 | it "reverses the existing order clause on the relation" do 113 | expect(subject.reverse_order.to_a).to eq([{ :id => 5, :name => "Dave" }, { :id => 4, :name => "Sam" }, { :id => 2, :name => "Fred" }]) 114 | end 115 | end 116 | 117 | describe "#none" do 118 | it "returns a chainable relation with zero records" do 119 | expect(subject.none).to be_an_instance_of(QueriesTestEnumerable) 120 | expect(subject.none.count).to eq(0) 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /spec/scopes_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "ostruct" 3 | 4 | RSpec.describe ActiveEnumerable do 5 | 6 | class TestEnumerable 7 | include ActiveEnumerable::Base 8 | include ActiveEnumerable::Scopes 9 | 10 | scope :freds, -> { to_a.select { |e| e[:name] == "Fred" } } 11 | scope :fred, -> { freds.to_a.first } 12 | scope :adults, -> { to_a.select { |e| e[:age] > 17 } } 13 | end 14 | 15 | let(:item_objects) { [OpenStruct.new(name: "Fred"), OpenStruct.new(name: "Dave")] } 16 | let(:item_hashes) { [{ name: "Fred" }, { name: "Sam" }, { name: "Dave" }] } 17 | 18 | describe ".scope" do 19 | it "creates a scoped query collection" do 20 | expect(TestEnumerable.new(item_objects).freds.to_a).to eq [item_objects.first] 21 | end 22 | 23 | context "allows chaining" do 24 | let(:item_hashes) { [{ name: "Fred" , age: 10 }, { name: "Fred", age: 23 }, { name: "Sam", age: 45 }, { name: "Dave", age: 54 }] } 25 | 26 | it do 27 | expect(TestEnumerable.new(item_hashes).freds.adults.to_a).to eq [item_hashes[1]] 28 | end 29 | end 30 | 31 | it "creates a scoped query collection" do 32 | expect(TestEnumerable.new(item_objects).fred).to eq item_objects.first 33 | expect(TestEnumerable.new(item_objects).respond_to?(:fred)).to eq true 34 | end 35 | end 36 | 37 | describe "#scope" do 38 | subject { TestEnumerable.new(item_hashes).scope { to_a.select { |e| e[:name] == "Sam" } } } 39 | 40 | it "creates a scoped query on the fly" do 41 | expect(subject.to_a).to eq [item_hashes[1]] 42 | expect(subject).to be_an_instance_of(TestEnumerable) 43 | end 44 | end 45 | 46 | it "method missing still works" do 47 | expect { TestEnumerable.new([]).this_method_will_never_be_defined }.to raise_error(NoMethodError) 48 | end 49 | 50 | it "respond to still works" do 51 | expect(TestEnumerable.new([]).respond_to?(:name)).to eq(true) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'active_enumerable' 3 | 4 | RSpec.configure do |config| 5 | 6 | config.order = "random" 7 | 8 | config.expect_with :rspec do |c| 9 | c.syntax = :expect 10 | end 11 | 12 | config.mock_with :rspec do |mocks| 13 | mocks.verify_doubled_constant_names = true 14 | mocks.verify_partial_doubles = true 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /spec/where_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "ostruct" 3 | 4 | RSpec.describe ActiveEnumerable::Where do 5 | class TestEnumerable 6 | include ActiveEnumerable::Base 7 | include ActiveEnumerable::Where 8 | 9 | def custom_method 10 | :i_am_here! 11 | end 12 | end 13 | 14 | let(:item_objects) { [OpenStruct.new(name: "Fred"), OpenStruct.new(name: "Dave")] } 15 | let(:item_hashes) { [{ name: "Fred" }, { name: "Sam" }, { name: "Dave" }] } 16 | 17 | describe "#where" do 18 | 19 | it "queries collection for object with `name` =='fred'" do 20 | expect(TestEnumerable.new(item_objects).where(name: "Fred").to_a).to eq [item_objects.first] 21 | end 22 | 23 | it "queries collection for hashes with the key of :name and value of 'fred'" do 24 | expect(TestEnumerable.new(item_hashes).where(name: "Fred").to_a).to eq [item_hashes.first] 25 | end 26 | 27 | context "#not" do 28 | it "returns results not matching conditions" do 29 | expect(TestEnumerable.new(item_hashes).where.not(name: "Fred").to_a).to eq [item_hashes[1], item_hashes[2]] 30 | end 31 | 32 | it "results is still of original type" do 33 | expect(TestEnumerable.new(item_hashes).where.not(name: "Fred").custom_method).to eq :i_am_here! 34 | end 35 | end 36 | 37 | context "#or(conditions)" do 38 | it "returns a uniq collection" do 39 | test_enum = TestEnumerable.new([{ paid: true, credit: 1000 }, { paid: false, credit: 2000 }, { paid: false, credit: 0 }]) 40 | result = test_enum.where(paid: false).or(credit: 0) 41 | expect(result.to_a). 42 | to eq [{ :paid => false, :credit => 2000 }, { :paid => false, :credit => 0 }] 43 | end 44 | 45 | it "with a where.not" do 46 | expect(TestEnumerable.new(item_hashes).where(name: "Dave").or(name: "Fred").where.not(name: "Sam").to_a). 47 | to eq [item_hashes[2], item_hashes[0]] 48 | end 49 | 50 | it "results is still of original type" do 51 | expect(TestEnumerable.new(item_hashes).where(name: "Dave").or(name: "Fred").where.not(name: "Sam").custom_method).to eq :i_am_here! 52 | end 53 | end 54 | 55 | it "#or(<#ActiveEnumerable>)" do 56 | subject = TestEnumerable.new(item_hashes) 57 | expect(subject.where(name: "Dave").or(subject.where(name: "Fred")).where.not(name: "Sam").to_a). 58 | to eq [item_hashes[2], item_hashes[0]] 59 | end 60 | 61 | it "nested with array" do 62 | items = [{ name: "Richard", accounts: [{ balance: 200 }] }] 63 | subject = TestEnumerable.new(items) 64 | expect(subject.where(accounts: { balance: 200 }).to_a).to eq items 65 | end 66 | 67 | it "nested objects" do 68 | items = [{ name: "Richard", account: { balance: 200 } }] 69 | subject = TestEnumerable.new(items) 70 | expect(subject.where(account: { balance: 200 }).to_a).to eq items 71 | end 72 | 73 | context "#where(&block)" do 74 | it "evaluations in context of records" do 75 | subject = TestEnumerable.new(item_hashes) 76 | expect(subject.where { %w(Sam Dave).include?(name) }.to_a).to eq [item_hashes[1], item_hashes[2]] 77 | end 78 | 79 | it "nested objects" do 80 | items = [{ name: "Richard", account: { balance: 200 } }] 81 | subject = TestEnumerable.new(items) 82 | expect(subject.where { account.balance == 200 }.to_a).to eq items 83 | expect(subject.where { account.balance == 1 }.to_a).to eq [] 84 | end 85 | end 86 | end 87 | end 88 | --------------------------------------------------------------------------------