├── .github └── workflows │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── active_model-relation.gemspec ├── bin ├── console └── setup ├── lib └── active_model │ ├── relation.rb │ └── relation │ ├── model.rb │ ├── order_clause.rb │ ├── querying.rb │ ├── railtie.rb │ ├── scoping.rb │ ├── version.rb │ ├── where_chain.rb │ └── where_clause.rb └── spec ├── active_model └── relation_spec.rb ├── spec_helper.rb └── support └── fixtures.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | name: Ruby ${{ matrix.ruby }} 11 | strategy: 12 | matrix: 13 | ruby: 14 | - '3.3.0' 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Ruby 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ matrix.ruby }} 22 | bundler-cache: true 23 | - name: Run the default task 24 | run: bundle exec rake 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | Gemfile.lock -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 3.1 3 | NewCops: enable 4 | SuggestExtensions: false 5 | 6 | Layout/LineLength: 7 | Max: 120 8 | 9 | Metrics/BlockLength: 10 | Exclude: 11 | - 'spec/**/*' 12 | 13 | Style/Documentation: 14 | Enabled: false -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | - Treat `ActiveModel::RecordNotFound` like `ActiveRecord::RecordNotFound` in `ActionDispatch` 4 | - Properly type cast values when they are defined as attribute 5 | - Ensure that there is always at least an empty array of records 6 | 7 | ## [0.2.0] - 2024-09-16 8 | 9 | - Rename `ActiveModel::ModelNotFound` to `ActiveModel::RecordNotFound` 10 | - Allow creating a `ActiveModel::Relation` without passing a collection 11 | - Don't require a `.records` class method on model classes 12 | - Allow passing a block to `ActiveModel::Relation#find` 13 | 14 | ## [0.1.0] - 2024-09-09 15 | 16 | - Initial release 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders 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, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at benedikt@benediktdeicke.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in active_model-relation.gemspec 6 | gemspec 7 | 8 | gem 'rake', '~> 13.0' 9 | 10 | gem 'rspec', '~> 3.0' 11 | 12 | gem 'rubocop', '~> 1.21' 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Benedikt Deicke 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 | # ActiveModel::Relation 2 | 3 | Query a collection of ActiveModel objects like an ActiveRecord::Relation. 4 | 5 | ## Installation 6 | 7 | Install the gem and add to the application's Gemfile by executing: 8 | 9 | $ bundle add active_model-relation 10 | 11 | If bundler is not being used to manage dependencies, install the gem by executing: 12 | 13 | $ gem install active_model-relation 14 | 15 | ## Usage 16 | 17 | ### Initialization 18 | 19 | Create a new relation by passing the model class and a collection: 20 | 21 | ```ruby 22 | relation = ActiveModel::Relation.new(Project, [ 23 | Project.new(id: 1, state: 'draft', priority: 1), 24 | Project.new(id: 2, state: 'running', priority: 2), 25 | Project.new(id: 3, state: 'completed', priority: 3), 26 | Project.new(id: 4, state: 'completed', priority: 1) 27 | ]) 28 | ``` 29 | 30 | As an alternative, it's also possible to create a collection for a model without explicitly passing a collection. 31 | In this case, the library will attempt to call `Project.records` to get the default collection. If the method doesn't exist or returns `nil`, the collection will default to an empty array. 32 | 33 | ```ruby 34 | class Project 35 | def self.records 36 | [ 37 | Project.new(id: 1, state: 'draft', priority: 1), 38 | Project.new(id: 2, state: 'running', priority: 2), 39 | Project.new(id: 3, state: 'completed', priority: 3), 40 | Project.new(id: 4, state: 'completed', priority: 1) 41 | ] 42 | end 43 | end 44 | 45 | relation = ActiveModel::Relation.new(Project) 46 | ``` 47 | 48 | ### Querying 49 | 50 | An `ActiveModel::Relation` can be queried almost exactly like an `ActiveRecord::Relation`. 51 | 52 | #### `#find` 53 | 54 | You can look up a record by it's primary key, using the `find` method. If no record is found, it will raise a `ActiveModel::RecordNotFound` error. 55 | 56 | ```ruby 57 | project = relation.find(1) 58 | ``` 59 | 60 | By default, `ActiveModel::Relation` will assume `:id` as the primary key. You can customize this behavior by setting a `primary_key` on the model class. 61 | 62 | ```ruby 63 | class Project 64 | def self.primary_key = :identifier 65 | end 66 | ``` 67 | 68 | When passed a block, the `find` method will behave like `Enumerable#find`. 69 | 70 | ```ruby 71 | project = relation.find { |p| p.id == 1 } 72 | ``` 73 | 74 | #### `#find_by` 75 | 76 | To look up a record based on a set of arbitary attributes, you can use `find_by`. It accepts the same arguments as `#where` and will return the first matching record. 77 | 78 | ```ruby 79 | project = relation.find_by(state: 'draft') 80 | ``` 81 | 82 | #### `#where` 83 | 84 | To filter a relation, you can use `where` and pass a set of attributes and the expected values. This method will return a new `ActiveModel::Relation` that only returns the matching records, so it's possible to chain multiple calls. The filtering will only happen when actually accessing records. 85 | 86 | ```ruby 87 | relation.where(state: 'completed') 88 | ``` 89 | 90 | The following two lines will return the same filtered results: 91 | 92 | ```ruby 93 | relation.where(state: 'completed', priority: 1) 94 | relation.where(state: 'completed').where(priority: 1) 95 | ``` 96 | 97 | To allow for more advanced filtering, `#where` allows filtering using a block. This works similar to `Enumerable#select`, but will return a new `ActiveModel::Relation` instead of an already filtered array. 98 | 99 | ```ruby 100 | relation.where { |p| p.state == 'completed' && p.priority == 1 } 101 | ``` 102 | 103 | #### `#where.not` 104 | 105 | Similar to `#where`, the `#where.not` chain allows you to filter a relation. It will also return a new `ActiveModel::Relation` with that returns only the matching records. 106 | 107 | ```ruby 108 | relation.where.not(state: 'draft') 109 | ``` 110 | 111 | To allow for more advanced filtering, `#where.not` allows filtering using a block. This works similar to `Enumerable#reject`, but will return a new `ActiveModel::Relation` instead of an already filtered array. 112 | 113 | ```ruby 114 | relation.where.not { |p| p.state == 'draft' && p.priority == 1 } 115 | ``` 116 | 117 | ### Sorting 118 | 119 | It is possible to sort an `ActiveModel::Relation` by a given set of attribute names. Sorting will be applied after filtering, but before limits and offsets. 120 | 121 | #### `#order` 122 | 123 | To sort by a single attribute in ascending order, you can just pass the attribute name to the `order` method. 124 | 125 | ```ruby 126 | relation.order(:priority) 127 | ``` 128 | 129 | To specify the sort direction, you can pass a hash with the attribute name as key and either `:asc`, or `:desc` as value. 130 | 131 | ```ruby 132 | relation.order(priorty: :desc) 133 | ``` 134 | 135 | To order by multiple attributes, you can pass them in the order of specificity you want. 136 | 137 | ```ruby 138 | relation.order(:state, :priority) 139 | ``` 140 | 141 | For multiple attributes, it's also possible to specify the direction. 142 | 143 | ```ruby 144 | relation.order(state: :desc, priority: :asc) 145 | ``` 146 | 147 | ### Limiting and offsets 148 | 149 | #### `#limit` 150 | 151 | To limit the amount of records returned in the collection, you can call `limit` on the relation. It will return a new `ActiveModel::Relation` that only returns the given limit of records, allowing you to chain multiple other calls. The limit will only be applied when actually accessing the records later on. 152 | 153 | ```ruby 154 | relation.limit(10) 155 | ``` 156 | 157 | #### `#offset` 158 | 159 | To skip a certain number of records in the collection, you can use `offset` on the relation. It will return a new `ActiveModel::Relation` that skips the given number of records at the beginning. The offset will only be applied when actually accessing the records later on. 160 | 161 | ```ruby 162 | relation.offset(20) 163 | ``` 164 | 165 | ### Scopes 166 | 167 | After including `ActiveModel::Relation::Model`, the library also supports calling class methods defined on the model class as part of the relation. 168 | 169 | ```ruby 170 | class Project 171 | include ActiveModel::Model 172 | include ActiveModel::Attributes 173 | include ActiveModel::Relation::Model 174 | 175 | attribute :id, :integer 176 | attribute :state, :string, default: :draft 177 | attribute :priority, :integer, default: 1 178 | 179 | def self.completed 180 | where(state: 'completed') 181 | end 182 | end 183 | ``` 184 | 185 | Given the example above, you can now create relations like you're used to from `ActiveRecord::Relation`. 186 | 187 | ```ruby 188 | projects = Project.all 189 | completed_projects = all_projects.completed 190 | important_projects = all_projects.where(priority: 1) 191 | ``` 192 | 193 | ### Spawning 194 | 195 | It's possilbe to create new versions of a `ActiveModel::Relation` that only includes certain aspects of the `ActiveModel::Relation` it is based on. It's currently possible to customize the following aspects: `:where`, `:limit`, `:offset`. 196 | 197 | #### `#except` 198 | 199 | To create a new `ActiveModel::Relation` without certain aspects, you can use `except` and pass a list of aspects, you'd like to exclude from the newly created instance. The following example will create a new `ActiveModel::Relation` without any previously defined limit or offset. 200 | 201 | ```ruby 202 | relation.except(:limit, :offset) 203 | ``` 204 | #### `#only` 205 | 206 | Similar to `except`, the `only` method will return a new instance of the `ActiveModel::Relation` it is based on but with only the passed list of aspects applied to it. 207 | 208 | ```ruby 209 | relation.only(:where) 210 | ``` 211 | 212 | ### Extending relations 213 | 214 | #### `#extending` 215 | 216 | In order to add additional methods to a relation, you can use `extending`. You can either pass a list of modules that will be included in this particular instance, or a block defining additional methods. 217 | 218 | ```ruby 219 | module Pagination 220 | def page_size = 25 221 | 222 | def page(page) 223 | limit(page_size).offset(page.to_i * page_size) 224 | end 225 | 226 | def total_count 227 | except(:limit, :offset).count 228 | end 229 | end 230 | 231 | relation.extending(Pagination) 232 | ``` 233 | 234 | The following example is equivalent to the example above: 235 | 236 | ```ruby 237 | relation.extending do 238 | def page_size = 25 239 | 240 | def page(page) 241 | limit(page_size).offset(page.to_i * page_size) 242 | end 243 | 244 | def total_count 245 | except(:limit, :offset).count 246 | end 247 | end 248 | ``` 249 | 250 | ## Development 251 | 252 | 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. 253 | 254 | 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 the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 255 | 256 | ## Contributing 257 | 258 | Bug reports and pull requests are welcome on GitHub at https://github.com/userlist/active_model-relation. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/userlist/active_model-relation/blob/main/CODE_OF_CONDUCT.md). 259 | 260 | ## Acknowledgements 261 | 262 | This library is _heavily_ inspired by [`ActiveRecord::Relation`](https://github.com/rails/rails/blob/main/activerecord/lib/active_record/relation.rb) and uses similar patterns and implementations in various parts. 263 | 264 | ## License 265 | 266 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 267 | 268 | ## Code of Conduct 269 | 270 | Everyone interacting in the ActiveModel::Relation project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/userlist/active_model-relation/blob/main/CODE_OF_CONDUCT.md). 271 | 272 | ## What is Userlist? 273 | 274 | [![Userlist](https://userlist.com/images/external/userlist-logo-github.svg)](https://userlist.com/) 275 | 276 | [Userlist](https://userlist.com/) allows you to onboard and engage your SaaS users with targeted behavior-based campaigns using email or in-app messages. 277 | 278 | Userlist was started in 2017 as an alternative to bulky enterprise messaging tools. We believe that running SaaS products should be more enjoyable. Learn more [about us](https://userlist.com/about-us/). 279 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require 'rubocop/rake_task' 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | -------------------------------------------------------------------------------- /active_model-relation.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/active_model/relation/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'active_model-relation' 7 | spec.version = ActiveModel::Relation::VERSION 8 | spec.authors = ['Benedikt Deicke'] 9 | spec.email = ['benedikt@benediktdeicke.com'] 10 | 11 | spec.summary = 'Query collections of ActiveModel objects like an ActiveRecord::Relation' 12 | spec.description = 'This library allows querying of collections of Ruby objects, with a similar interface ' \ 13 | 'to ActiveRecord::Relation.' 14 | spec.homepage = 'https://github.com/userlist/active_model-relation/' 15 | spec.license = 'MIT' 16 | spec.required_ruby_version = '>= 3.1.0' 17 | 18 | spec.metadata['homepage_uri'] = spec.homepage 19 | spec.metadata['source_code_uri'] = 'https://github.com/userlist/active_model-relation/' 20 | spec.metadata['changelog_uri'] = 'https://github.com/userlist/active_model-relation/blob/main/CHANGELOG.md' 21 | 22 | # Specify which files should be added to the gem when it is released. 23 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 24 | spec.files = Dir.chdir(__dir__) do 25 | `git ls-files -z`.split("\x0").reject do |f| 26 | (File.expand_path(f) == __FILE__) || 27 | f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) 28 | end 29 | end 30 | spec.bindir = 'exe' 31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ['lib'] 33 | 34 | spec.add_dependency 'activemodel', '>= 7.2', '< 8.1' 35 | 36 | spec.metadata['rubygems_mfa_required'] = 'true' 37 | end 38 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'active_model/relation' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require 'irb' 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/active_model/relation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_model' 4 | require_relative 'relation/railtie' if defined?(Rails::Railtie) 5 | 6 | module ActiveModel 7 | class RecordNotFound < StandardError 8 | def initialize(message = nil, model = nil, primary_key = nil, id = nil) # rubocop:disable Metrics/ParameterLists 9 | @primary_key = primary_key 10 | @model = model 11 | @id = id 12 | 13 | super(message) 14 | end 15 | end 16 | 17 | # = Active Model Relation 18 | class Relation # rubocop:disable Metrics/ClassLength 19 | include Enumerable 20 | 21 | autoload :Model, 'active_model/relation/model' 22 | autoload :Querying, 'active_model/relation/querying' 23 | autoload :Scoping, 'active_model/relation/scoping' 24 | autoload :WhereClause, 'active_model/relation/where_clause' 25 | autoload :WhereChain, 'active_model/relation/where_chain' 26 | autoload :OrderClause, 'active_model/relation/order_clause' 27 | 28 | attr_reader :model 29 | attr_accessor :offset_value, :limit_value, :where_clause, :order_clause, :extending_values 30 | 31 | delegate :each, :size, :last, to: :records 32 | 33 | def initialize(model, records = model.try(:records)) 34 | @model = model 35 | @records = records || [] 36 | @where_clause = WhereClause.new 37 | @order_clause = OrderClause.new 38 | @offset_value = nil 39 | @limit_value = nil 40 | @extending_values = [] 41 | end 42 | 43 | def find(id = nil, &) 44 | return records.find(id, &) if block_given? 45 | 46 | primary_key = model.try(:primary_key) || :id 47 | 48 | find_by(primary_key => id) || 49 | raise(RecordNotFound.new("Couldn't find #{model} with '#{primary_key}'=#{id}", model, primary_key, id)) 50 | end 51 | 52 | def find_by(attributes = {}) 53 | where_clause = self.where_clause + WhereClause.from_hash(type_cast_values(attributes)) 54 | 55 | records.find(&where_clause) 56 | end 57 | 58 | def where(...) 59 | spawn.where!(...) 60 | end 61 | 62 | def where!(attributes = {}, &) 63 | return WhereChain.new(spawn) unless attributes.any? || block_given? 64 | 65 | self.where_clause += WhereClause.build(type_cast_values(attributes), &) 66 | self 67 | end 68 | 69 | def offset(...) 70 | spawn.offset!(...) 71 | end 72 | 73 | def offset!(offset) 74 | self.offset_value = offset 75 | self 76 | end 77 | 78 | def limit(...) 79 | spawn.limit!(...) 80 | end 81 | 82 | def limit!(limit) 83 | self.limit_value = limit 84 | self 85 | end 86 | 87 | def order(...) 88 | spawn.order!(...) 89 | end 90 | 91 | def order!(*values) 92 | self.order_clause += OrderClause.build(values) 93 | self 94 | end 95 | 96 | def extending(...) 97 | spawn.extending!(...) 98 | end 99 | 100 | def extending!(*modules, &) 101 | modules << Module.new(&) if block_given? 102 | modules.flatten! 103 | 104 | self.extending_values += modules 105 | 106 | extend(*extending_values) if extending_values.any? 107 | 108 | self 109 | end 110 | 111 | def all 112 | spawn 113 | end 114 | 115 | def to_ary 116 | records.dup 117 | end 118 | alias to_a to_ary 119 | 120 | def records 121 | @records 122 | .select(&where_clause) 123 | .sort(&order_clause) 124 | .drop(offset_value || 0) 125 | .take(limit_value || @records.size) 126 | end 127 | 128 | def scoping 129 | previous_scope = model.current_scope 130 | model.current_scope = self 131 | yield 132 | ensure 133 | model.current_scope = previous_scope 134 | end 135 | 136 | def inspect 137 | entries = records.take(11).map!(&:inspect) 138 | entries[10] = '...' if entries.size == 11 139 | 140 | "#<#{self.class.name} [#{entries.join(', ')}]>" 141 | end 142 | 143 | def except(*skips) 144 | relation_with(values.except(*skips)) 145 | end 146 | 147 | def only(*keeps) 148 | relation_with(values.slice(*keeps)) 149 | end 150 | 151 | private 152 | 153 | def method_missing(...) 154 | if model.respond_to?(...) 155 | scoping { model.public_send(...) } 156 | else 157 | super 158 | end 159 | end 160 | 161 | def respond_to_missing?(...) 162 | super || model.respond_to?(...) 163 | end 164 | 165 | def spawn 166 | clone 167 | end 168 | 169 | def values 170 | { 171 | where: where_clause, 172 | offset: offset_value, 173 | limit: limit_value 174 | } 175 | end 176 | 177 | def relation_with(values) 178 | spawn.tap do |relation| 179 | relation.where_clause = values[:where] || WhereClause.new 180 | relation.offset_value = values[:offset] 181 | relation.limit_value = values[:limit] 182 | end 183 | end 184 | 185 | def type_cast_values(attributes) 186 | attributes.to_h do |key, value| 187 | type = model.try(:type_for_attribute, key) 188 | value = type.cast(value) if type 189 | 190 | [key, value] 191 | end 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /lib/active_model/relation/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveModel 4 | class Relation 5 | module Model 6 | extend ActiveSupport::Concern 7 | 8 | include ActiveModel::Relation::Scoping 9 | include ActiveModel::Relation::Querying 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/active_model/relation/order_clause.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveModel 4 | class Relation 5 | class OrderClause 6 | class OrderExpression 7 | attr_reader :name 8 | 9 | def initialize(name) 10 | @name = name 11 | end 12 | 13 | def call(record, other) 14 | record.public_send(name) <=> other.public_send(name) 15 | end 16 | end 17 | 18 | class Ascending < OrderExpression; end 19 | 20 | class Descending < OrderExpression 21 | def call(record, other) 22 | super(other, record) 23 | end 24 | end 25 | 26 | attr_reader :expressions 27 | 28 | def self.build(value = []) 29 | if value.is_a?(Array) 30 | from_array(value) 31 | elsif value.is_a?(Hash) 32 | from_hash(value) 33 | else 34 | from_value(value) 35 | end 36 | end 37 | 38 | def self.from_value(value) 39 | new(Ascending.new(value)) 40 | end 41 | 42 | def self.from_array(attributes) 43 | expressions = attributes.map { |name| build(name) } 44 | 45 | new(expressions) 46 | end 47 | 48 | def self.from_hash(attributes) 49 | expressions = attributes.map do |name, direction| 50 | if direction == :asc 51 | Ascending.new(name) 52 | elsif direction == :desc 53 | Descending.new(name) 54 | else 55 | raise ArgumentError, "Invalid direction #{direction.inspect}. Direction should either be :asc or :desc." 56 | end 57 | end 58 | 59 | new(expressions) 60 | end 61 | 62 | def initialize(*expressions) 63 | @expressions = expressions.flatten(1) 64 | end 65 | 66 | def +(other) 67 | OrderClause.new(@expressions + other.expressions) 68 | end 69 | 70 | def call(record, other) 71 | return 0 if expressions.empty? 72 | 73 | expressions.each do |expression| 74 | result = expression.call(record, other) 75 | 76 | return result unless result.zero? 77 | end 78 | 79 | 0 80 | end 81 | 82 | def to_proc 83 | method(:call).to_proc 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/active_model/relation/querying.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveModel 4 | class Relation 5 | module Querying 6 | extend ActiveSupport::Concern 7 | 8 | module ClassMethods 9 | delegate :where, :find, :find_by, :offset, :limit, :first, :last, to: :all 10 | 11 | def all 12 | current_scope || ActiveModel::Relation.new(self) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/active_model/relation/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails' 4 | 5 | module ActiveModel 6 | class Relation 7 | class Railtie < Rails::Railtie # :nodoc: 8 | config.action_dispatch.rescue_responses.merge!( 9 | 'ActiveModel::RecordNotFound' => :not_found 10 | ) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/active_model/relation/scoping.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveModel 4 | class Relation 5 | module Scoping 6 | extend ActiveSupport::Concern 7 | 8 | module ClassMethods 9 | def current_scope 10 | ScopeRegistry.current_scope(self) 11 | end 12 | 13 | def current_scope=(value) 14 | ScopeRegistry.set_current_scope(self, value) 15 | end 16 | end 17 | 18 | class ScopeRegistry 19 | class << self 20 | delegate :current_scope, :set_current_scope, to: :instance 21 | 22 | def instance 23 | ActiveSupport::IsolatedExecutionState[:active_model_scope_registry] ||= new 24 | end 25 | end 26 | 27 | def initialize 28 | @current_scope = {} 29 | end 30 | 31 | def current_scope(model) 32 | @current_scope[model.name] 33 | end 34 | 35 | def set_current_scope(model, value) 36 | @current_scope[model.name] = value 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/active_model/relation/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveModel 4 | class Relation 5 | VERSION = '0.2.2' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/active_model/relation/where_chain.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveModel 4 | class Relation 5 | class WhereChain 6 | attr_reader :relation 7 | 8 | def initialize(relation) 9 | @relation = relation 10 | end 11 | 12 | def not(...) 13 | relation.where_clause += WhereClause.build(...).invert 14 | relation 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/active_model/relation/where_clause.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveModel 4 | class Relation 5 | class WhereClause 6 | class Predicate 7 | def call(record) 8 | raise NotImplementedError 9 | end 10 | 11 | def invert 12 | NotPredicate.new(self) 13 | end 14 | end 15 | 16 | class EqualsPredicate < Predicate 17 | attr_reader :attribute, :value 18 | 19 | def initialize(attribute, value) 20 | super() 21 | 22 | @attribute = attribute 23 | @value = value 24 | end 25 | 26 | def call(record) 27 | record.public_send(attribute) == value 28 | end 29 | end 30 | 31 | class BlockPredicate < Predicate 32 | attr_reader :block 33 | 34 | def initialize(block) 35 | super() 36 | 37 | @block = block 38 | end 39 | 40 | def call(record) 41 | block.call(record) 42 | end 43 | end 44 | 45 | class NotPredicate < Predicate 46 | attr_reader :predicate 47 | 48 | def initialize(predicate) 49 | super() 50 | 51 | @predicate = predicate 52 | end 53 | 54 | def call(record) 55 | !predicate.call(record) 56 | end 57 | 58 | def invert 59 | predicate 60 | end 61 | end 62 | 63 | attr_reader :predicates 64 | 65 | def self.from_hash(attributes = {}) 66 | new(attributes.map { |attribute, value| EqualsPredicate.new(attribute, value) }) 67 | end 68 | 69 | def self.from_block(block) 70 | new(BlockPredicate.new(block)) 71 | end 72 | 73 | def self.build(attributes = {}, &block) 74 | where_clause = new 75 | where_clause += from_hash(attributes) if attributes.any? 76 | where_clause += from_block(block) if block_given? 77 | where_clause 78 | end 79 | 80 | def initialize(*predicates) 81 | @predicates = predicates.flatten(1) 82 | end 83 | 84 | def +(other) 85 | WhereClause.new(predicates + other.predicates) 86 | end 87 | 88 | def call(record) 89 | predicates.all? { |predicate| predicate.call(record) } 90 | end 91 | 92 | def invert 93 | WhereClause.new(predicates.map(&:invert)) 94 | end 95 | 96 | def to_proc 97 | method(:call).to_proc 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/active_model/relation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActiveModel::Relation do 6 | it 'has a version number' do 7 | expect(ActiveModel::Relation::VERSION).not_to be nil 8 | end 9 | 10 | let(:model_class) { Project } 11 | let(:records) do 12 | [ 13 | Project.new(id: 1, state: 'draft', priority: 1), 14 | Project.new(id: 2, state: 'running', priority: 2), 15 | Project.new(id: 3, state: 'completed', priority: 3), 16 | Project.new(id: 4, state: 'completed', priority: 1) 17 | ] 18 | end 19 | 20 | subject { described_class.new(model_class, records) } 21 | 22 | describe '#initialize' do 23 | it 'should use the given records' do 24 | relation = described_class.new(model_class, records[1..3]) 25 | 26 | expect(relation).to match_array(records[1..3]) 27 | end 28 | 29 | it 'should prefer the given records over the ones from the model class' do 30 | allow(model_class).to receive(:records).and_return(records) 31 | 32 | relation = described_class.new(model_class, records[1..3]) 33 | 34 | expect(relation).to match_array(records[1..3]) 35 | end 36 | 37 | it 'should use the records from the model class when none are given' do 38 | allow(model_class).to receive(:records).and_return(records) 39 | 40 | relation = described_class.new(model_class) 41 | 42 | expect(relation).to match_array(records) 43 | end 44 | 45 | it 'should default to an empty array when no records are given' do 46 | relation = described_class.new(model_class, nil) 47 | 48 | expect(relation).to match_array([]) 49 | end 50 | 51 | it 'should default to an empty array when the model does not respond to records' do 52 | relation = described_class.new(model_class) 53 | 54 | expect(relation).to match_array([]) 55 | end 56 | end 57 | 58 | describe '#model' do 59 | it 'should return the model class' do 60 | expect(subject.model).to eq(model_class) 61 | end 62 | end 63 | 64 | describe '#find' do 65 | it 'should return the model by primary key' do 66 | expect(subject.find(1)).to eq(records[0]) 67 | end 68 | 69 | it 'should raise an error if the model is not found' do 70 | expect { subject.find(-1) }.to raise_error(ActiveModel::RecordNotFound) 71 | end 72 | 73 | it 'should allow passing a block' do 74 | expect(subject.find { |record| record.id == 1 }).to eq(records[0]) 75 | end 76 | 77 | it 'should type cast the given value' do 78 | expect(subject.find('1')).to eq(records[0]) 79 | end 80 | 81 | it 'should use the given primary key' do 82 | allow(Project).to receive(:primary_key).and_return(:identifier) 83 | 84 | expect(subject.find('project-1')).to eq(records[0]) 85 | end 86 | end 87 | 88 | describe '#find_by' do 89 | it 'should return the record matching the given attributes' do 90 | expect(subject.find_by(state: 'running')).to eq(records[1]) 91 | end 92 | 93 | it 'should return nil if the record is not found' do 94 | expect(subject.find_by(state: 'paused')).to be_nil 95 | end 96 | 97 | it 'should cast the attribute values' do 98 | expect(subject.find_by(id: '1')).to eq(records[0]) 99 | end 100 | 101 | it 'should allow filtering by arbitrary methods' do 102 | expect(subject.find_by(identifier: 'project-1')).to eq(records[0]) 103 | end 104 | end 105 | 106 | describe '#size' do 107 | it 'should return the number of records' do 108 | expect(subject.size).to eq(4) 109 | end 110 | 111 | context 'when the records are filtered' do 112 | it 'should return the number of filtered records' do 113 | expect(subject.where(state: 'completed').size).to eq(2) 114 | end 115 | end 116 | end 117 | 118 | describe '#where' do 119 | it 'should return a new relation' do 120 | expect(subject.where(state: 'completed')).to be_a(described_class) 121 | end 122 | 123 | it 'should return a new instance' do 124 | expect(subject.where(state: 'completed')).not_to eq(subject) 125 | end 126 | 127 | it 'should filter the records' do 128 | expect(subject.where(state: 'completed')).to match_array(records[2..4]) 129 | end 130 | 131 | it 'should filter the records with multiple conditions' do 132 | expect(subject.where(state: 'completed', id: 1)).to match_array([]) 133 | end 134 | 135 | it 'should filter the records with a block' do 136 | expect(subject.where { |record| record.state == 'completed' }).to match_array(records[2..3]) 137 | end 138 | 139 | it 'should cast the attribute values' do 140 | expect(subject.where(priority: '1')).to match_array([records[0], records[3]]) 141 | end 142 | 143 | it 'should allow filtering by arbitrary methods' do 144 | expect(subject.where(identifier: 'project-1')).to match_array([records[0]]) 145 | end 146 | end 147 | 148 | describe '#where.not' do 149 | it 'should return a new relation' do 150 | expect(subject.where.not(state: 'completed')).to be_a(described_class) 151 | end 152 | 153 | it 'should return a new instance' do 154 | expect(subject.where.not(state: 'completed')).not_to eq(subject) 155 | end 156 | 157 | it 'should filter the records' do 158 | expect(subject.where.not(state: 'completed')).to match_array(records[0..1]) 159 | end 160 | 161 | it 'should filter the records with multiple conditions' do 162 | expect(subject.where.not(state: 'completed', id: 3)).to match_array(records[0..1]) 163 | end 164 | 165 | it 'should filter the records with a block' do 166 | expect(subject.where.not { |record| record.state == 'completed' }).to match_array(records[0..1]) 167 | end 168 | end 169 | 170 | describe '#first' do 171 | it 'should return the first record' do 172 | expect(subject.first).to eq(records[0]) 173 | end 174 | 175 | context 'when the records are filtered' do 176 | it 'should return the first filtered record' do 177 | expect(subject.where(state: 'completed').first).to eq(records[2]) 178 | end 179 | end 180 | end 181 | 182 | describe '#last' do 183 | it 'should return the last record' do 184 | expect(subject.last).to eq(records[3]) 185 | end 186 | 187 | context 'when the records are filtered' do 188 | it 'should return the last filtered record' do 189 | expect(subject.where(state: 'completed').last).to eq(records[3]) 190 | end 191 | end 192 | end 193 | 194 | describe '#offset' do 195 | it 'should return a new relation' do 196 | expect(subject.offset(1)).to be_a(described_class) 197 | end 198 | 199 | it 'should return a new instance' do 200 | expect(subject.offset(1)).not_to eq(subject) 201 | end 202 | 203 | it 'should offset the records' do 204 | expect(subject.offset(1)).to match_array(records[1..3]) 205 | end 206 | end 207 | 208 | describe '#limit' do 209 | it 'should return a new relation' do 210 | expect(subject.limit(1)).to be_a(described_class) 211 | end 212 | 213 | it 'should return a new instance' do 214 | expect(subject.limit(1)).not_to eq(subject) 215 | end 216 | 217 | it 'should limit the records' do 218 | expect(subject.limit(1)).to match_array(records[0..0]) 219 | end 220 | end 221 | 222 | describe '#all' do 223 | it 'should return a new relation' do 224 | expect(subject.all).to be_a(described_class) 225 | end 226 | 227 | it 'should return a new instance' do 228 | expect(subject.all).not_to eq(subject) 229 | end 230 | 231 | it 'should return the same records' do 232 | expect(subject.all).to match_array(subject) 233 | end 234 | end 235 | 236 | describe '#extending' do 237 | let(:extension) do 238 | Module.new do 239 | def doubled_size 240 | size * 2 241 | end 242 | end 243 | end 244 | 245 | it 'should return a new relation' do 246 | expect(subject.extending(extension)).to be_a(described_class) 247 | end 248 | 249 | it 'should return a new instance' do 250 | expect(subject.extending(extension)).not_to eq(subject) 251 | end 252 | 253 | it 'should extend the relation' do 254 | expect(subject.extending(extension).doubled_size).to eq(8) 255 | end 256 | 257 | it 'should extend the relation with a block' do 258 | expect(subject.extending { def tripled_size = size * 3 }.tripled_size).to eq(12) 259 | end 260 | end 261 | 262 | describe '#except' do 263 | it 'should return a new relation' do 264 | expect(subject.except(:where)).to be_a(described_class) 265 | end 266 | 267 | it 'should return a new instance' do 268 | expect(subject.except(:where)).not_to eq(subject) 269 | end 270 | 271 | it 'should remove the where values' do 272 | expect(subject.where(state: 'completed').except(:where)).to match_array(records) 273 | end 274 | 275 | it 'should remove the offset value' do 276 | expect(subject.offset(1).except(:offset)).to match_array(records) 277 | end 278 | 279 | it 'should remove the limit value' do 280 | expect(subject.limit(1).except(:limit)).to match_array(records) 281 | end 282 | end 283 | 284 | describe '#only' do 285 | subject { super().where(state: 'completed').offset(1).limit(1) } 286 | 287 | it 'should return a new relation' do 288 | expect(subject.only(:where)).to be_a(described_class) 289 | end 290 | 291 | it 'should return a new instance' do 292 | expect(subject.only(:where)).not_to eq(subject) 293 | end 294 | 295 | it 'should keep only the where values' do 296 | expect(subject.where(state: 'completed').only(:where)).to match_array(records[2..3]) 297 | end 298 | 299 | it 'should keep only the offset value' do 300 | expect(subject.offset(1).only(:offset)).to match_array(records[1..3]) 301 | end 302 | 303 | it 'should keep only the limit value' do 304 | expect(subject.limit(1).only(:limit)).to match_array(records[0..0]) 305 | end 306 | end 307 | 308 | describe '#order' do 309 | it 'should return a new relation' do 310 | expect(subject.order(:priority)).to be_a(described_class) 311 | end 312 | 313 | it 'should return a new instance' do 314 | expect(subject.order(:priority)).not_to eq(subject) 315 | end 316 | 317 | it 'should order the records' do 318 | expect(subject.order(:priority)).to match_array(records.sort_by(&:priority)) 319 | end 320 | 321 | it 'should order the records in descending order' do 322 | expect(subject.order(priority: :desc)).to match_array(records.sort_by(&:priority).reverse) 323 | end 324 | 325 | it 'should order the records with multiple attributes' do 326 | expect(subject.order(priority: :desc, state: :asc)).to match_array(records.sort_by { |r| [-r.priority, r.state] }) 327 | end 328 | end 329 | 330 | describe 'model class methods' do 331 | it 'should delegate methods to the model class' do 332 | expect(subject.model_name).to eq(model_class.model_name) 333 | end 334 | 335 | it 'should apply the scope to the model class method' do 336 | expect(subject.completed).to match_array(records[2..3]) 337 | end 338 | end 339 | end 340 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_model/relation' 4 | 5 | require_relative 'support/fixtures' 6 | 7 | RSpec.configure do |config| 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = '.rspec_status' 10 | 11 | # Disable RSpec exposing methods globally on `Module` and `main` 12 | config.disable_monkey_patching! 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/fixtures.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Project 4 | include ActiveModel::Model 5 | include ActiveModel::Attributes 6 | include ActiveModel::Relation::Model 7 | 8 | attribute :id, :integer 9 | attribute :state, :string, default: :draft 10 | attribute :priority, :integer, default: 1 11 | 12 | def self.completed 13 | where(state: 'completed') 14 | end 15 | 16 | def identifier 17 | "project-#{id}" 18 | end 19 | end 20 | --------------------------------------------------------------------------------