├── .document ├── .gitignore ├── .rspec ├── .yardopts ├── ChangeLog.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── passive_record ├── features ├── .gitkeep ├── passive_record.feature └── step_definitions │ ├── .gitkeep │ └── passive_record_steps.rb ├── gemspec.yml ├── lib ├── passive_record.rb └── passive_record │ ├── arithmetic_helpers.rb │ ├── associations.rb │ ├── associations │ ├── belongs_to.rb │ ├── has_many.rb │ ├── has_many_through.rb │ └── has_one.rb │ ├── class_inheritable_attrs.rb │ ├── class_methods.rb │ ├── core │ └── query.rb │ ├── hooks.rb │ ├── instance_methods.rb │ ├── pretty_printing.rb │ └── version.rb ├── logo.png ├── notes.md ├── passive_record.gemspec └── spec ├── passive_record_spec.rb └── spec_helper.rb /.document: -------------------------------------------------------------------------------- 1 | - 2 | ChangeLog.md 3 | LICENSE.txt 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle 2 | /.yardoc/ 3 | /Gemfile.lock 4 | /doc/ 5 | /pkg/ 6 | /vendor/cache/*.gem 7 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour --format documentation 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown --title "passive_record Documentation" --protected 2 | -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | ### 0.1.0 / 2016-02-19 2 | 3 | * Initial release: 4 | 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | ruby '2.2.1' 3 | 4 | gemspec 5 | 6 | gem 'activesupport' 7 | 8 | gem "codeclimate-test-reporter", group: :test, require: nil 9 | 10 | group :development do 11 | gem 'pry' 12 | gem 'kramdown' 13 | end 14 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Joseph Weissman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![passive record logo](https://raw.githubusercontent.com/deepcerulean/passive_record/master/logo.png) 2 | 3 | 4 | * [Documentation](https://rubygems.org/gems/passive_record) 5 | * [Email](mailto:joe at deepc.io) 6 | 7 | [![Code Climate GPA](https://codeclimate.com/github/deepcerulean/passive_record/badges/gpa.svg)](https://codeclimate.com/github/deepcerulean/passive_record) 8 | [![Codeship Status for deepcerulean/passive_record](https://www.codeship.io/projects/66bb2d90-ba61-0133-af95-025ac38368ea/status)](https://codeship.com/projects/135673) 9 | [![Test Coverage](https://codeclimate.com/github/deepcerulean/passive_record/badges/coverage.svg)](https://codeclimate.com/github/deepcerulean/passive_record/coverage) 10 | [![Gem Version](https://badge.fury.io/rb/passive_record.svg)](https://badge.fury.io/rb/passive_record) 11 | [![Join the chat at https://gitter.im/deepcerulean/passive_record](https://badges.gitter.im/deepcerulean/passive_record.svg)](https://gitter.im/deepcerulean/passive_record?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 12 | 13 | ## Description 14 | 15 | PassiveRecord is an extremely lightweight in-memory pseudo-relational algebra. 16 | 17 | We implement a simplified subset of AR's interface in pure Ruby. 18 | 19 | ## Why? 20 | 21 | Do you need to track objects by ID and look them up again, 22 | or look them up based on attributes, 23 | or even utilize some relational semantics, 24 | but have no real need for persistence? 25 | 26 | PassiveRecord may be right for you! 27 | 28 | 29 | ## Features 30 | 31 | - Build relationships with belongs_to, has_one and has_many 32 | - Query on attributes and associations 33 | - Supports many-to-many and self-referential relationships 34 | - No database required! 35 | - Just `include PassiveRecord` to get started 36 | 37 | ## Examples 38 | 39 | ````ruby 40 | require 'passive_record' 41 | 42 | class Model 43 | include PassiveRecord 44 | end 45 | 46 | class Dog < Model 47 | attr_accessor :breed 48 | belongs_to :child 49 | end 50 | 51 | class Child < Model 52 | has_one :dog 53 | belongs_to :parent 54 | end 55 | 56 | class Parent < Model 57 | has_many :children 58 | has_many :dogs, :through => :children 59 | end 60 | 61 | # Let's build some models! 62 | parent = Parent.create 63 | => Parent (id: 1, child_ids: [], dog_ids: []) 64 | 65 | child = parent.create_child 66 | => Child (id: 1, dog_id: nil, parent_id: 1) 67 | 68 | dog = child.create_dog(breed: "Pug") 69 | => Dog (id: 1, child_id: 1, breed: "Pug") 70 | 71 | # Inverse relationships 72 | dog.child 73 | 74 | Dog.find_by child: child 75 | => Dog (id: 1, child_id: 1, breed: "Pug") 76 | 77 | # Has many through 78 | parent.dogs 79 | => [ ...has_many :through relation... ] 80 | 81 | parent.dogs.all 82 | => [Dog (id: 1, child_id: 1, breed: "Pug")] 83 | 84 | # Nested queries 85 | Dog.find_all_by(child: { parent: parent }) 86 | => [Dog (id: 1, child_id: 1, breed: "Pug")] 87 | ```` 88 | 89 | ## PassiveRecord API 90 | 91 | 92 | A class including PassiveRecord will gain the following new instance and class methods. 93 | 94 | ### Instance Methods 95 | 96 | 97 | A class `Role` which is declared to `include PassiveRecord` will gain the following instance methods: 98 | - `role.update(attrs_hash)` 99 | - `role.destroy` 100 | - `role.to_h` 101 | - We override `role.inspect` to show ID and visible attributes 102 | 103 | ### Class Methods 104 | 105 | 106 | A class `User` which is declared to `include PassiveRecord` will gain the following class methods: 107 | 108 | - `User.all` and `User.each` 109 | - `User.create(name: 'Aloysius')` 110 | - `User.descendants` 111 | - `User.destroy(id)` 112 | - `User.destroy_all` 113 | - `User.each` enumerates over `User.all`, giving `User.count`, `User.first`, etc. 114 | - `User.find(id_or_ids)` 115 | - `User.find_by(conditions)` 116 | - `User.find_all_by(conditions)` 117 | - `User.where(conditions)` (returns a `PassiveRecord::Query` object) 118 | 119 | ### Belongs To 120 | 121 | A model `Child` which is declared `belongs_to :parent` will gain: 122 | 123 | - `child.parent` 124 | - `child.parent_id` 125 | - `child.parent=` 126 | - `child.parent_id=` 127 | 128 | ### Has One 129 | 130 | A model `Parent` which declares `has_one :child` will gain: 131 | 132 | - `parent.child` 133 | - `parent.child_id` 134 | - `parent.child=` 135 | - `parent.child_id=` 136 | - `parent.create_child(attrs)` 137 | 138 | ### Has Many / Has Many Through / HABTM 139 | 140 | A model `Parent` which declares `has_many :children` or `has_and_belongs_to_many :children` will gain: 141 | 142 | - `parent.children` (returns a `Relation`, documented below) 143 | - `parent.children=` 144 | - `parent.children_ids` 145 | - `parent.children_ids=` 146 | - `parent.children<<` 147 | - `parent.create_child(attrs)` 148 | - `parent.children.all?(&predicate)` 149 | - `parent.children.empty?` 150 | - `parent.children.where(conditions)` (returns a `Core::Query`) 151 | - `parent.children.pluck(attribute)` 152 | - `parent.children.sum(attribute)` 153 | - `parent.children.average(attribute)` 154 | - `parent.children.mode(attribute)` 155 | 156 | ### Relations 157 | 158 | Parent models which declare `has_many :children` gain a `parent.children` instance that returns an explicit PassiveRecord relation object, which has the following public methods: 159 | 160 | - `parent.children.all` 161 | - `parent.children.each` enumerates over `parent.children.all`, giving `parent.children.count`, `parent.children.first`, etc. 162 | - `parent.children.all?(&predicate)` 163 | - `parent.children.empty?` 164 | - `parent.children.where(conditions)` (returns a `Core::Query`) 165 | - `parent.children<<` (insert a new child into the relation) 166 | 167 | ### Queries 168 | 169 | You can acquire `Core::Query` objects through the class method `where`. These are chainable, accept nested conditions that traverse relationships, and understand scopes defined as class methods. The query object will have the following public methods: 170 | 171 | - `Post.where(conditions).all` 172 | - `Post.where(conditions).each` enumerates over `where(conditions).all`, so we have `where(conditions).count`, `where(conditions).first`, etc. 173 | - `Post.where(conditions).create(attrs)` 174 | - `Post.where(conditions).first_or_create(attrs)` 175 | - `Post.where(conditions).pluck(attr)` 176 | - `Post.where(conditions).sum(attr)` 177 | - `Post.where(conditions).average(attr)` 178 | - `Post.where(conditions).mode(attr)` 179 | - `Post.where(conditions).where(further_conditions)` (chaining) 180 | - `Post.where.not(conditions)` (negation) 181 | - `Post.where(conditions).or(Post.where(conditions))` (disjunction) 182 | - `Post.active.recent` (scoping with class methods that return queries; supports chaining) 183 | 184 | `conditions` here is expected to be a hash of attribute values. Note that there is special behavior for certain kinds of values. 185 | 186 | - Ranges select models with an attribute covered by the range (behaving like `BETWEEN`). For instance you might query for users with birthdays between yesterday and today with `User.where(birthday: 1.day.ago...1.day.from_now)` 187 | - Arrays select models with an attribute whose value is in the array (behaving like `IN`), so for instance you may query for users whose job title is included in a list of job titles like: `User.where(job_title: ['manager', 'developer', 'qa'])` 188 | - Hash values (subhashes) select models with related models who attributes match the inner hash. So `Doctor.where(appointments: { patient: patient })` would lookup doctors whose appointments include an appointment with `patient`. 189 | 190 | ## Hooks 191 | 192 | - `before_create :call_a_method` 193 | - `after_create :call_another_method, :and_then_call_another_one` 194 | - `before_update do manually_invoke(a_method) end` 195 | - `after_update { or_use_a_block }` 196 | - `before_destroy :something` 197 | - `after_destroy { something_else }` 198 | 199 | # Prior Art 200 | 201 | - Approaches exist that use ActiveRecord directly, and then override various methods in such a way to prevent AR from trying to persist the model. The canonical example here is the [tableless model](http://railscasts.com/episodes/193-tableless-model?view=asciicast) approach, and the use case given there is a model that wraps around sending an email. This is maybe interesting because, similar to the round-trip with a database, sending mail is externally "effectful" (and so, for instance, you may wish to take additional care around confirmation or retry logic, in order ensure you are not sending the same message more than once.) 202 | - These approaches are seen as somewhat hacky today, given that [ActiveModel](https://github.com/rails/rails/tree/master/activemodel) can give plain old Ruby objects a lot of the augmentations that ActiveRecord gives, such as validations, hooks and attribute management. However I don't really see a way to do relations that interoperate with ActiveRecord the way you could, at least to some degree, with tableless models. 203 | - It's not really clear to me yet if it's interesting for PassiveRecord to be able to interoperate smoothly with ActiveRecord relations. It seems like we might be able to pull some similar tricks as the "tableless" approach in order to permit at least some relations to work between them. But their intentions are so different I can't help but think there would be very strange bugs lurking in any such integration -- so the encouraged architecture would be a complete separation between active and passive models. 204 | 205 | ## Copyright 206 | 207 | Copyright (c) 2016 Joseph Weissman 208 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'rubygems' 4 | 5 | begin 6 | require 'bundler/setup' 7 | rescue LoadError => e 8 | abort e.message 9 | end 10 | 11 | require 'rake' 12 | 13 | 14 | require 'rubygems/tasks' 15 | Gem::Tasks.new 16 | 17 | require 'rspec/core/rake_task' 18 | RSpec::Core::RakeTask.new 19 | 20 | task :test => :spec 21 | task :default => :spec 22 | 23 | require 'yard' 24 | YARD::Rake::YardocTask.new 25 | task :doc => :yard 26 | 27 | require 'cucumber/rake/task' 28 | 29 | Cucumber::Rake::Task.new do |t| 30 | t.cucumber_opts = %w[--format pretty] 31 | end 32 | -------------------------------------------------------------------------------- /bin/passive_record: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | root = File.expand_path(File.join(File.dirname(__FILE__),'..')) 4 | if File.directory?(File.join(root,'.git')) 5 | Dir.chdir(root) do 6 | begin 7 | require 'bundler/setup' 8 | rescue LoadError => e 9 | warn e.message 10 | warn "Run `gem install bundler` to install Bundler" 11 | exit(-1) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /features/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deepcerulean/passive_record/26feeb8ab12be28b39002c8e5e405945ef44acac/features/.gitkeep -------------------------------------------------------------------------------- /features/passive_record.feature: -------------------------------------------------------------------------------- 1 | Feature: Blah blah blah -------------------------------------------------------------------------------- /features/step_definitions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deepcerulean/passive_record/26feeb8ab12be28b39002c8e5e405945ef44acac/features/step_definitions/.gitkeep -------------------------------------------------------------------------------- /features/step_definitions/passive_record_steps.rb: -------------------------------------------------------------------------------- 1 | @wip 2 | -------------------------------------------------------------------------------- /gemspec.yml: -------------------------------------------------------------------------------- 1 | name: passive_record 2 | summary: "no-persistence relational algebra" 3 | description: "lightweight in-memory simplified subset of AR" 4 | license: MIT 5 | authors: Joseph Weissman 6 | email: jweissman1986@gmail.com 7 | homepage: https://rubygems.org/gems/passive_record 8 | dependencies: 9 | activesupport: ~> 4.2 10 | development_dependencies: 11 | bundler: ~> 1.10 12 | codeclimate-test-reporter: ~> 0.1 13 | cucumber: ~> 0.10.2 14 | rake: ~> 10.0 15 | rspec: ~> 3.0 16 | rubygems-tasks: ~> 0.2 17 | yard: ~> 0.8 18 | -------------------------------------------------------------------------------- /lib/passive_record.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | require 'active_support' 4 | require 'active_support/core_ext/string/inflections' 5 | require 'active_support/core_ext/numeric/time' 6 | 7 | require 'passive_record/version' 8 | 9 | require 'passive_record/arithmetic_helpers' 10 | require 'passive_record/core/query' 11 | 12 | require 'passive_record/class_inheritable_attrs' 13 | 14 | require 'passive_record/associations' 15 | require 'passive_record/hooks' 16 | 17 | require 'passive_record/pretty_printing' 18 | 19 | require 'passive_record/instance_methods' 20 | require 'passive_record/class_methods' 21 | 22 | module PassiveRecord 23 | def self.included(base) 24 | base.send :include, InstanceMethods 25 | base.send :include, ClassLevelInheritableAttributes 26 | base.send :include, PrettyPrinting 27 | 28 | base.class_eval do 29 | inheritable_attrs :hooks, :associations 30 | end 31 | 32 | base.extend(ClassMethods) 33 | 34 | model_classes << base 35 | end 36 | 37 | def self.model_classes 38 | @model_classes ||= [] 39 | end 40 | 41 | def self.drop_all 42 | (model_classes + model_classes.flat_map(&:descendants)).uniq.each(&:destroy_all) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/passive_record/arithmetic_helpers.rb: -------------------------------------------------------------------------------- 1 | module PassiveRecord 2 | module ArithmeticHelpers 3 | def pluck(attr) 4 | all.map(&attr) 5 | end 6 | 7 | def sum(attr) 8 | pluck(attr).inject(&:+) 9 | end 10 | 11 | def average(attr) 12 | sum(attr) / count 13 | end 14 | 15 | def mode(attr) 16 | arr = pluck(attr) 17 | freq = arr.inject(Hash.new(0)) { |h,v| h[v] += 1; h } 18 | arr.max_by { |v| freq[v] } 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/passive_record/associations.rb: -------------------------------------------------------------------------------- 1 | require 'passive_record/associations/belongs_to' 2 | require 'passive_record/associations/has_one' 3 | require 'passive_record/associations/has_many' 4 | require 'passive_record/associations/has_many_through' 5 | 6 | module PassiveRecord 7 | module Associations 8 | def associate!(assn) 9 | @associations ||= [] 10 | @associations += [assn] unless @associations.include?(assn) 11 | self 12 | end 13 | 14 | def associations_id_syms 15 | @associations && @associations.map do |assn| 16 | if assn.is_a?(HasOneAssociation) || assn.is_a?(BelongsToAssociation) 17 | (assn.target_name_symbol.to_s + "_id").to_sym 18 | else 19 | (assn.target_name_symbol.to_s.singularize + "_ids").to_sym 20 | end 21 | end || [] 22 | end 23 | 24 | def belongs_to(parent_name_sym, opts={}) 25 | target_class_name = opts.delete(:class_name) { (parent_name_sym.to_s).split('_').map(&:capitalize).join } 26 | association = BelongsToAssociation.new(self, target_class_name, parent_name_sym) 27 | associate!(association) 28 | 29 | define_method(:"#{parent_name_sym}_id") do 30 | prnt = send(parent_name_sym) 31 | prnt && prnt.id 32 | end 33 | 34 | define_method(parent_name_sym) do 35 | relation = detect_relation(association) 36 | association.parent_class.find(relation.parent_model_id) 37 | end 38 | 39 | define_method(:"#{parent_name_sym}=") do |new_parent| 40 | send(:"#{parent_name_sym}_id=", new_parent.id) 41 | end 42 | 43 | define_method(:"#{parent_name_sym}_id=") do |new_parent_id| 44 | relation = detect_relation(association) 45 | relation.parent_model_id = new_parent_id 46 | end 47 | end 48 | 49 | def has_one(child_name_sym) 50 | child_class_name = (child_name_sym.to_s).split('_').map(&:capitalize).join 51 | association = HasOneAssociation.new(self, child_class_name, child_name_sym) 52 | associate!(association) 53 | 54 | define_method(:"#{child_name_sym}_id") do 55 | chld = send(child_name_sym) 56 | chld && chld.id 57 | end 58 | 59 | define_method(child_name_sym) do 60 | relation = detect_relation(association) 61 | relation.lookup 62 | end 63 | 64 | define_method(:"create_#{child_name_sym}") do |attrs={}| 65 | relation = detect_relation(association) 66 | relation.create(attrs) 67 | end 68 | 69 | define_method(:"#{child_name_sym}=") do |new_child| 70 | send(:"#{child_name_sym}_id=", new_child.id) 71 | end 72 | 73 | define_method(:"#{child_name_sym}_id=") do |new_child_id| 74 | relation = detect_relation(association) #relata.detect { |rel| rel.association == association } 75 | rel = relation.lookup 76 | rel && rel.send(:"#{relation.parent_model_id_field}=", nil) 77 | 78 | relation.child_class. 79 | find(new_child_id). 80 | update(relation.parent_model_id_field => relation.id) 81 | end 82 | end 83 | 84 | def has_many(collection_name_sym, opts={}) 85 | target_class_name = opts.delete(:class_name) { (collection_name_sym.to_s).split('_').map(&:capitalize).join.singularize } 86 | habtm = opts.delete(:habtm) { false } 87 | 88 | association = nil 89 | if opts.key?(:through) 90 | through_class_collection_name = opts.delete(:through) 91 | 92 | through_class_name = (through_class_collection_name.to_s).split('_').map(&:capitalize).join.singularize 93 | base_association = associations.detect { |assn| assn.child_class_name == through_class_name rescue false } 94 | 95 | association = HasManyThroughAssociation.new( 96 | self, target_class_name, collection_name_sym, through_class_collection_name, base_association, habtm) 97 | 98 | associate!(association) 99 | 100 | define_method(:"#{collection_name_sym}=") do |new_collection| 101 | send(:"#{collection_name_sym.to_s.singularize}_ids=", new_collection.map(&:id)) 102 | end 103 | 104 | define_method(:"#{collection_name_sym.to_s.singularize}_ids=") do |new_collection_ids| 105 | relation = detect_relation(association) # relata.detect { |rel| rel.association == association } 106 | 107 | intermediary = relation.intermediary_relation 108 | 109 | # drop all intermediary relations 110 | intermediary.where( relation.parent_model_id_field => relation.id ).each do |intermediate| 111 | intermediate.destroy 112 | end 113 | 114 | # add in new ones... 115 | singular_target = collection_name_sym.to_s.singularize 116 | if !(relation.nested_association.is_a?(BelongsToAssociation)) 117 | intermediary.create( 118 | singular_target + "_ids" => new_collection_ids, 119 | relation.parent_model_id_field => relation.id 120 | ) 121 | else 122 | new_collection_ids.each do |child_id| 123 | intermediary.create( 124 | singular_target + "_id" => child_id, 125 | relation.parent_model_id_field => relation.id 126 | ) 127 | end 128 | end 129 | end 130 | else 131 | association = HasManyAssociation.new(self, target_class_name, collection_name_sym) 132 | associate!(association) 133 | 134 | define_method(:"#{collection_name_sym}=") do |new_collection| 135 | relation = detect_relation(association) 136 | 137 | # detach existing children... 138 | relation.all.each do |child| 139 | child.send(:"#{relation.parent_model_id_field}=", nil) 140 | end 141 | 142 | # reattach new children 143 | new_collection.each do |child| 144 | child.send(:"#{relation.parent_model_id_field}=", relation.id) 145 | end 146 | end 147 | 148 | define_method(:"#{collection_name_sym.to_s.singularize}_ids=") do |new_collection_ids| 149 | relation = detect_relation(association) #@ relata.detect { |rel| rel.association == association } 150 | send(:"#{collection_name_sym}=", relation.child_class.find(new_collection_ids)) 151 | end 152 | end 153 | 154 | define_method(collection_name_sym) do 155 | detect_relation(association) 156 | # relata.detect { |rel| rel.association == association } 157 | end 158 | 159 | define_method(:"#{collection_name_sym.to_s.singularize}_ids") do 160 | begin 161 | send(collection_name_sym).map(&:id) 162 | rescue 163 | binding.pry 164 | end 165 | end 166 | 167 | define_method(:"create_#{collection_name_sym.to_s.singularize}") do |attrs={}| 168 | relation = detect_relation(association) # relata.detect { |rel| rel.association == association } 169 | relation.create(attrs) 170 | end 171 | end 172 | 173 | def has_and_belongs_to_many(collection_name_sym) 174 | habtm_join_class_name = 175 | self.name.split('::').last.singularize + 176 | collection_name_sym.to_s.camelize.singularize + 177 | "JoinModel" 178 | inverse_habtm_join_class_name = 179 | collection_name_sym.to_s.camelize.singularize + 180 | self.name.split('::').last.singularize + 181 | "JoinModel" 182 | 183 | module_name = self.name.deconstantize 184 | module_name = "Object" if module_name.empty? 185 | intended_module = module_name.constantize 186 | 187 | if (intended_module.const_get(inverse_habtm_join_class_name) rescue false) 188 | has_many inverse_habtm_join_class_name.underscore.to_sym 189 | has_many collection_name_sym, :through => inverse_habtm_join_class_name.underscore.to_sym, habtm: true 190 | else 191 | auto_collection_sym = self.name.split('::').last.underscore.pluralize.to_sym 192 | eval <<-ruby 193 | class #{module_name}::#{habtm_join_class_name} # class System::UserRoleJoinModel 194 | include PassiveRecord # include PassiveRecord 195 | belongs_to :#{collection_name_sym.to_s.singularize} # belongs_to :role 196 | belongs_to :#{auto_collection_sym.to_s.singularize} # belongs_to :user 197 | end # end 198 | ruby 199 | has_many habtm_join_class_name.underscore.to_sym 200 | has_many(collection_name_sym, :through => habtm_join_class_name.underscore.to_sym, habtm: true) 201 | end 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /lib/passive_record/associations/belongs_to.rb: -------------------------------------------------------------------------------- 1 | module PassiveRecord 2 | module Associations 3 | class BelongsToAssociation < Struct.new(:child_class, :parent_class_name, :target_name_symbol) 4 | def to_relation(child_model) 5 | BelongsToRelation.new(self, child_model) 6 | end 7 | 8 | def parent_class 9 | @parent_class ||= ( 10 | module_name = child_class.name.deconstantize 11 | module_name = "Object" if module_name.empty? 12 | (module_name.constantize).const_get(parent_class_name) 13 | ) 14 | end 15 | 16 | def child_class_name 17 | child_class.name 18 | end 19 | end 20 | 21 | class BelongsToRelation < Struct.new(:association, :child_model) 22 | def singular? 23 | true 24 | end 25 | 26 | def lookup 27 | association.parent_class.find_by(parent_model_id) 28 | end 29 | 30 | def parent_model_id 31 | @parent_model_id ||= nil 32 | end 33 | 34 | def parent_model_id=(id) 35 | @parent_model_id = id 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/passive_record/associations/has_many.rb: -------------------------------------------------------------------------------- 1 | module PassiveRecord 2 | module Associations 3 | class HasManyAssociation < Struct.new(:parent_class, :child_class_name, :children_name_sym) 4 | def to_relation(parent_model) 5 | HasManyRelation.new(self, parent_model) 6 | end 7 | 8 | def target_name_symbol 9 | children_name_sym 10 | end 11 | 12 | def habtm 13 | false #@habtm ||= false 14 | end #? 15 | end 16 | 17 | class HasManyRelation < HasOneRelation 18 | include Enumerable 19 | extend Forwardable 20 | 21 | include PassiveRecord::ArithmeticHelpers 22 | 23 | def all 24 | child_class.where(parent_model_id_field => parent_model.id).all 25 | end 26 | 27 | def_delegators :all, :each, :last, :all?, :empty?, :sample 28 | 29 | def where(conditions={}) 30 | child_class.where(conditions.merge(parent_model_id_field.to_sym => parent_model.id)) 31 | end 32 | 33 | def <<(child) 34 | child.send(parent_model_id_field + "=", parent_model.id) 35 | all 36 | end 37 | 38 | def singular? 39 | false 40 | end 41 | 42 | def method_missing(meth,*args,&blk) 43 | if child_class.methods.include?(meth) 44 | where.send(meth,*args,&blk) 45 | else 46 | super(meth,*args,&blk) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/passive_record/associations/has_many_through.rb: -------------------------------------------------------------------------------- 1 | module PassiveRecord 2 | module Associations 3 | class HasManyThroughAssociation < Struct.new(:parent_class, :child_class_name, :target_name_symbol, :through_class, :base_association, :habtm) 4 | def to_relation(parent_model) 5 | HasManyThroughRelation.new(self, parent_model) 6 | end 7 | end 8 | 9 | class HasManyThroughRelation < HasManyRelation 10 | def <<(child) 11 | if nested_association.is_a?(HasManyAssociation) 12 | intermediary_id = 13 | child.send(association.base_association.target_name_symbol.to_s.singularize + "_id") 14 | 15 | if intermediary_id 16 | intermediary_relation.child_class.find(intermediary_id). 17 | send(:"#{parent_model_id_field}=", parent_model.id) 18 | else 19 | nested_ids_field = nested_association.children_name_sym.to_s.singularize + "_ids" 20 | intermediary_model = intermediary_relation.singular? ? 21 | intermediary_relation.lookup_or_create : 22 | intermediary_relation.where(parent_model_id_field => parent_model.id).first_or_create 23 | 24 | intermediary_model.update( 25 | nested_ids_field => intermediary_model.send(nested_ids_field) + [ child.id ] 26 | ) 27 | end 28 | else 29 | intermediary_model = intermediary_relation. 30 | where( 31 | association.target_name_symbol.to_s.singularize + "_id" => child.id). 32 | first_or_create 33 | end 34 | self 35 | end 36 | 37 | def create(attrs={}) 38 | child = child_class.create(attrs) 39 | send(:<<, child) 40 | child 41 | end 42 | 43 | def nested_class 44 | module_name = association.parent_class.name.deconstantize 45 | module_name = "Object" if module_name.empty? 46 | (module_name.constantize). 47 | const_get("#{association.base_association.child_class_name.singularize}") 48 | end 49 | 50 | def nested_association 51 | nested_class.associations.detect { |assn| 52 | assn.child_class_name == association.child_class_name || 53 | assn.child_class_name == association.child_class_name.singularize || 54 | 55 | (assn.parent_class_name == association.child_class_name rescue false) || 56 | (assn.parent_class_name == association.child_class_name.singularize rescue false) || 57 | 58 | assn.target_name_symbol == association.target_name_symbol.to_s.singularize.to_sym 59 | } 60 | end 61 | 62 | def all 63 | join_results = intermediate_results 64 | if intermediate_results && !join_results.empty? 65 | final_results = join_results.flat_map(&nested_association.target_name_symbol) 66 | if final_results.first.is_a?(Associations::Relation) 67 | final_results.flat_map(&:all).compact 68 | else 69 | Array(final_results.compact) 70 | end 71 | else 72 | [] 73 | end 74 | end 75 | 76 | def intermediary_relation 77 | @intermediary_relation ||= association.base_association.to_relation(parent_model) 78 | end 79 | 80 | def intermediate_results 81 | if intermediary_relation.singular? 82 | Array(intermediary_relation.lookup) 83 | else 84 | intermediary_relation.all 85 | end 86 | end 87 | 88 | def where(conditions={}) 89 | Core::HasManyThroughQuery.new( 90 | child_class, 91 | parent_model, 92 | association.target_name_symbol, 93 | conditions 94 | ) 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/passive_record/associations/has_one.rb: -------------------------------------------------------------------------------- 1 | module PassiveRecord 2 | module Associations 3 | class HasOneAssociation < Struct.new(:parent_class, :child_class_name, :child_name_sym) 4 | def to_relation(parent_model) 5 | HasOneRelation.new(self, parent_model) 6 | end 7 | 8 | def target_name_symbol 9 | child_name_sym 10 | end 11 | 12 | def children_name_sym; child_name_sym end 13 | end 14 | 15 | class Relation < Struct.new(:association, :parent_model) 16 | def singular? 17 | true 18 | end 19 | end 20 | 21 | class HasOneRelation < Relation 22 | def lookup 23 | child_class.find_by(parent_model_id_field => parent_model.id) 24 | end 25 | 26 | def create(attrs={}) 27 | child_class.create( 28 | attrs.merge( 29 | parent_model_id_field => parent_model.id 30 | ) 31 | ) 32 | end 33 | 34 | def lookup_or_create 35 | lookup || create 36 | end 37 | 38 | def parent_model_id_field 39 | association.parent_class.name.demodulize.underscore + "_id" 40 | end 41 | 42 | def child_class 43 | @child_class ||= ( 44 | module_name = association.parent_class.name.deconstantize 45 | module_name = "Object" if module_name.empty? 46 | (module_name.constantize).const_get(association.child_class_name.singularize) 47 | ) 48 | end 49 | 50 | def id 51 | parent_model.id 52 | end 53 | 54 | def child_class_name 55 | child_class.name 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/passive_record/class_inheritable_attrs.rb: -------------------------------------------------------------------------------- 1 | # taken directly from http://stackoverflow.com/a/10729812/90042 2 | module ClassLevelInheritableAttributes 3 | def self.included(base) 4 | base.extend(ClassMethods) 5 | end 6 | 7 | module ClassMethods 8 | def inheritable_attrs(*args) 9 | @inheritable_attributes ||= [:inheritable_attributes] 10 | @inheritable_attributes += args 11 | args.each do |arg| 12 | class_eval %( 13 | class << self; attr_accessor :#{arg} end 14 | ) 15 | end 16 | 17 | @inheritable_attributes 18 | end 19 | 20 | def inherited(subclass) 21 | @inheritable_attributes.each do |inheritable_attribute| 22 | instance_var = "@#{inheritable_attribute}" 23 | subclass.instance_variable_set(instance_var, instance_variable_get(instance_var)) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/passive_record/class_methods.rb: -------------------------------------------------------------------------------- 1 | module PassiveRecord 2 | module ClassMethods 3 | include PassiveRecord::Core 4 | include PassiveRecord::Associations 5 | include PassiveRecord::Hooks 6 | 7 | include Enumerable 8 | extend Forwardable 9 | 10 | # from http://stackoverflow.com/a/2393750/90042 11 | def descendants 12 | ObjectSpace.each_object(Class).select { |klass| klass < self } 13 | end 14 | 15 | def all 16 | instances_by_id.values 17 | end 18 | def_delegators :all, :each, :last 19 | 20 | def find(id_or_ids) 21 | if id_or_ids.is_a?(Array) 22 | find_by_ids(id_or_ids) 23 | else 24 | find_by_id(id_or_ids) 25 | end 26 | end 27 | 28 | def find_by(conditions) 29 | if conditions.is_a?(Hash) 30 | where(conditions).first 31 | else # assume we have an identifier/identifiers 32 | find(conditions) 33 | end 34 | end 35 | 36 | def find_all_by(conditions) 37 | where(conditions).all 38 | end 39 | 40 | def where(conditions={}) 41 | Query.new(self, conditions) 42 | end 43 | 44 | def create(attrs={}) 45 | instance = new 46 | 47 | instance.singleton_class.class_eval { attr_accessor :id } 48 | 49 | instance_id = attrs.delete(:id) { SecureRandom.uuid } 50 | instance.send(:id=, instance_id) 51 | 52 | register(instance) 53 | 54 | before_create_hooks.each do |hook| 55 | hook.run(instance) 56 | end 57 | 58 | attrs.each do |(k,v)| 59 | instance.send("#{k}=", v) 60 | end 61 | 62 | after_create_hooks.each do |hook| 63 | hook.run(instance) 64 | end 65 | 66 | instance 67 | end 68 | 69 | def destroy(id) 70 | @instances.reject! {|k,_| id == k } 71 | end 72 | 73 | def destroy_all 74 | @instances = {} 75 | end 76 | 77 | protected 78 | def find_by_id(id_to_find) 79 | instances_by_id[id_to_find] 80 | end 81 | 82 | def find_by_ids(ids) 83 | instances_by_id.values_at(*ids) 84 | end 85 | 86 | private 87 | def instances_by_id 88 | @instances ||= {} 89 | end 90 | 91 | def register(model) 92 | id = model.id 93 | instances_by_id[id] = model 94 | self 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/passive_record/core/query.rb: -------------------------------------------------------------------------------- 1 | module PassiveRecord 2 | module Core 3 | class Query 4 | include Enumerable 5 | extend Forwardable 6 | include PassiveRecord::ArithmeticHelpers 7 | 8 | attr_reader :conditions 9 | 10 | def initialize(klass,conditions={},scope=nil) 11 | @klass = klass 12 | @conditions = conditions 13 | @scope = scope 14 | end 15 | 16 | def not(new_conditions={}) 17 | NegatedQuery.new(@klass, new_conditions) 18 | end 19 | 20 | def or(query=nil) 21 | DisjoinedQuery.new(@klass, self, query) 22 | end 23 | 24 | def all 25 | if @scope 26 | matching = @scope.method(:matching_instances) 27 | if negated? 28 | raw_all.reject(&matching) 29 | else 30 | raw_all.select(&matching) 31 | end 32 | else 33 | matching = method(:matching_instances) 34 | raw_all.select(&matching) 35 | end 36 | end 37 | def_delegators :all, :sample, :uniq, :count 38 | 39 | def raw_all 40 | @klass.all 41 | end 42 | 43 | def each 44 | if @scope 45 | matching = @scope.method(:matching_instances) 46 | if negated? 47 | raw_all.each do |instance| 48 | yield instance unless matching[instance] 49 | end 50 | else 51 | raw_all.each do |instance| 52 | yield instance if matching[instance] 53 | end 54 | end 55 | else 56 | matching = method(:matching_instances) 57 | @klass.all.each do |instance| 58 | yield instance if matching[instance] 59 | end 60 | end 61 | end 62 | 63 | def matching_instances(instance) 64 | @conditions.all? do |(field,value)| 65 | evaluate_condition(instance, field, value) 66 | end 67 | end 68 | 69 | def create(attrs={}) 70 | @klass.create(@conditions.merge(attrs)) 71 | end 72 | 73 | def first_or_create(*args) 74 | q = where(*args) 75 | q.first || q.create 76 | end 77 | 78 | def where(new_conditions={}) 79 | @conditions.merge!(new_conditions) 80 | self 81 | end 82 | 83 | def negated? 84 | false 85 | end 86 | 87 | def disjoined? 88 | false 89 | end 90 | 91 | def conjoined? 92 | false 93 | end 94 | 95 | def basic? 96 | !negated? && !disjoined? && !conjoined? 97 | end 98 | 99 | def and(scope_query) 100 | ConjoinedQuery.new(@klass, self, scope_query) 101 | end 102 | 103 | def method_missing(meth,*args,&blk) 104 | if @klass.methods.include?(meth) 105 | scope_query = @klass.send(meth,*args,&blk) 106 | if negated? && @scope.nil? && @conditions.empty? 107 | @scope = scope_query 108 | self 109 | elsif basic? && scope_query.basic? 110 | @conditions.merge!(scope_query.conditions) 111 | self 112 | else 113 | scope_query.and(self) 114 | end 115 | else 116 | super(meth,*args,&blk) 117 | end 118 | end 119 | 120 | protected 121 | def evaluate_condition(instance, field, value) 122 | case value 123 | when Hash then evaluate_nested_conditions(instance, field, value) 124 | when Range then value.cover?(instance.send(field)) 125 | when Array then value.include?(instance.send(field)) 126 | else 127 | instance.send(field) == value 128 | end 129 | end 130 | 131 | def evaluate_nested_conditions(instance, field, value) 132 | association = instance.send(field) 133 | association && value.all? do |(association_field,val)| 134 | if association.is_a?(Associations::Relation) && !association.singular? 135 | association.where(association_field => val).any? 136 | elsif val.is_a?(Hash) 137 | evaluate_nested_conditions(association, association_field, val) 138 | else 139 | association.send(association_field) == val 140 | end 141 | end 142 | end 143 | end 144 | 145 | class NegatedQuery < Query 146 | def matching_instances(instance) 147 | @conditions.none? do |(field,value)| 148 | evaluate_condition(instance, field, value) 149 | end 150 | end 151 | 152 | def negated? 153 | true 154 | end 155 | end 156 | 157 | class DisjoinedQuery < Query 158 | def initialize(klass, first_query, second_query, conditions={}) 159 | @klass = klass 160 | @first_query = first_query 161 | @second_query = second_query 162 | @conditions = conditions 163 | end 164 | 165 | def all 166 | (@first_query.where(conditions).all + @second_query.where(conditions).all).uniq 167 | end 168 | 169 | def disjoined? 170 | true 171 | end 172 | end 173 | 174 | class ConjoinedQuery < Query 175 | def initialize(klass, first_query, second_query, conditions={}) 176 | @klass = klass 177 | @first_query = first_query 178 | @second_query = second_query 179 | @conditions = conditions 180 | end 181 | 182 | def all 183 | @first_query.where(conditions).all & @second_query.all 184 | end 185 | 186 | def conjoined? 187 | true 188 | end 189 | end 190 | 191 | class HasManyThroughQuery < Query 192 | def initialize(klass, instance, target_name_sym, conditions={}) 193 | @klass = klass 194 | @instance = instance 195 | @target_name_sym = target_name_sym 196 | @conditions = conditions 197 | end 198 | 199 | def raw_all 200 | @instance.send(@target_name_sym).all 201 | end 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /lib/passive_record/hooks.rb: -------------------------------------------------------------------------------- 1 | module PassiveRecord 2 | module Hooks 3 | class Hook 4 | attr_reader :kind 5 | 6 | def initialize(kind,*meth_syms,&blk) 7 | @kind = kind 8 | @methods_to_call = meth_syms 9 | @block_to_invoke = blk 10 | end 11 | 12 | def run(instance) 13 | @methods_to_call.each do |meth| 14 | instance.send(meth) 15 | end 16 | 17 | unless @block_to_invoke.nil? 18 | instance.instance_eval(&@block_to_invoke) 19 | end 20 | 21 | instance 22 | end 23 | end 24 | 25 | def inject_hook(hook) 26 | @hooks ||= [] 27 | @hooks += [ hook ] 28 | end 29 | 30 | def find_hooks_of_type(type) 31 | @hooks ||= [] 32 | @hooks.select { |hook| hook.kind == type } 33 | end 34 | 35 | def before_create_hooks 36 | find_hooks_of_type :before_create 37 | end 38 | 39 | def before_create(*meth_syms, &blk) 40 | hook = Hook.new(:before_create,*meth_syms,&blk) 41 | inject_hook hook 42 | self 43 | end 44 | 45 | def after_create_hooks 46 | find_hooks_of_type :after_create 47 | end 48 | 49 | def after_create(*meth_syms, &blk) 50 | hook = Hook.new(:after_create,*meth_syms,&blk) 51 | inject_hook hook 52 | self 53 | end 54 | 55 | def before_update_hooks 56 | find_hooks_of_type :before_update 57 | end 58 | 59 | def before_update(*meth_syms, &blk) 60 | hook = Hook.new(:before_update,*meth_syms,&blk) 61 | inject_hook hook 62 | self 63 | end 64 | 65 | def after_update_hooks 66 | find_hooks_of_type :after_update 67 | end 68 | 69 | def after_update(*meth_syms, &blk) 70 | hook = Hook.new(:after_update,*meth_syms,&blk) 71 | inject_hook hook 72 | self 73 | end 74 | 75 | def before_destroy_hooks 76 | find_hooks_of_type :before_destroy 77 | end 78 | 79 | def before_destroy(*meth_syms,&blk) 80 | hook = Hook.new(:before_destroy,*meth_syms,&blk) 81 | inject_hook(hook) 82 | self 83 | end 84 | 85 | def after_destroy_hooks 86 | find_hooks_of_type :after_destroy 87 | end 88 | 89 | def after_destroy(*meth_syms,&blk) 90 | hook = Hook.new(:after_destroy,*meth_syms,&blk) 91 | inject_hook(hook) 92 | self 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/passive_record/instance_methods.rb: -------------------------------------------------------------------------------- 1 | module PassiveRecord 2 | module InstanceMethods 3 | def update(attrs={}) 4 | self.class.before_update_hooks.each do |hook| 5 | hook.run(self) 6 | end 7 | 8 | attrs.each do |k,v| 9 | send("#{k}=", v) 10 | end 11 | 12 | self.class.after_update_hooks.each do |hook| 13 | hook.run(self) 14 | end 15 | 16 | self 17 | end 18 | 19 | def destroy 20 | self.class.before_destroy_hooks.each do |hook| 21 | hook.run(self) 22 | end 23 | 24 | self.class.destroy(self.id) 25 | 26 | self.class.after_destroy_hooks.each do |hook| 27 | hook.run(self) 28 | end 29 | end 30 | 31 | # from http://stackoverflow.com/a/8417341/90042 32 | def to_h 33 | Hash[ 34 | attribute_names. 35 | map do |name| [ 36 | name.to_s.gsub("@","").to_sym, # key 37 | (instance_variable_get(name) rescue send(name))] # val 38 | end 39 | ] 40 | end 41 | 42 | protected 43 | def attribute_names 44 | attr_names = instance_variables 45 | attr_names += self.class.associations_id_syms 46 | attr_names += members rescue [] 47 | attr_names.reject! { |name| name.to_s.start_with?("@_") || name.match(/join_model/) } 48 | attr_names - blacklisted_attribute_names 49 | end 50 | 51 | def blacklisted_attribute_names 52 | [] 53 | end 54 | 55 | def detect_relation(assn) 56 | @_relata ||= {} 57 | @_relata[assn] ||= assn.to_relation(self) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/passive_record/pretty_printing.rb: -------------------------------------------------------------------------------- 1 | module PassiveRecord 2 | module PrettyPrinting 3 | def inspect 4 | pretty_vars = to_h.map do |k,v| 5 | "#{k.to_s.gsub(/^\@/,'')}: #{v.inspect}" 6 | end.join(', ') 7 | 8 | "#{self.class.name} (#{pretty_vars})" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/passive_record/version.rb: -------------------------------------------------------------------------------- 1 | module PassiveRecord 2 | # passive_record version 3 | VERSION = "0.4.15" 4 | end 5 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deepcerulean/passive_record/26feeb8ab12be28b39002c8e5e405945ef44acac/logo.png -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | (channel spec) (stream spec) 2 | 3 | Stream -> [parent] 4 | Channel -> [from parent model] [intermed] 5 | Feed -> [intermediary relation] [from nested...] 6 | (*) Blog -> [ from nested assn...? ] 7 | Post 8 | 9 | we need a process that starts the other way, and 'completes' the through 10 | like a belongs_to :through ... ? 11 | 12 | for stream spec: 13 | 14 | - nested assn is (channel has_many posts through feeds) 15 | if we could get at (feed has_many posts through blogs) 16 | i think we could recurse? 17 | note: it should be the 'nested assn' for (channels has_many posts)... 18 | 19 | - intermediray relation is (stream has many channels...) # through 20 | can we get at channels has many posts? 21 | 22 | in this context Channel is 23 | 24 | `intermediary_relation.child_class_name.to_s.singularize.constantize' [ ugh! ] 25 | 26 | 27 | 28 | 29 | Post.where(blog_id: 1) 30 | 31 | Post.where(blog: { feed_id: 234 }) 32 | 33 | Post.where(blog: { feed: { channel_id: 567 }}) 34 | 35 | Post.where(blog: { feed: { channel: { stream: 890 }}}) 36 | 37 | 38 | # network.posts.recent 39 | 40 | Post.where(published_at: 1.day.ago...Time.now, blog: { feed: { channel: { stream: { network_id: 1234 }}}}) 41 | 42 | 43 | --------- 44 | 45 | 46 | 47 | Okay, so in this case we are trying to do `feed.posts.recent` .... 48 | 49 | => #, 55 | habtm=false> 56 | 57 | We are constructing a query on `Post`, and want to say {blog: {feed_id: ...}} ... 58 | 59 | The through_class is :blogs which needs to be singularized 60 | 61 | --- 62 | 63 | In the other case we are just trying to do `user.resources.where ...` ... 64 | 65 | => #, 71 | habtm=false> 72 | 73 | We are constructing a query on `Resource` ... 74 | 75 | We need to check from the perspective of `Resource` what the relation is to allocations...? 76 | 77 | -------------------------------------------------------------------------------- /passive_record.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'yaml' 4 | 5 | Gem::Specification.new do |gem| 6 | gemspec = YAML.load_file('gemspec.yml') 7 | 8 | gem.name = gemspec.fetch('name') 9 | gem.version = gemspec.fetch('version') do 10 | lib_dir = File.join(File.dirname(__FILE__),'lib') 11 | $LOAD_PATH << lib_dir unless $LOAD_PATH.include?(lib_dir) 12 | 13 | require 'passive_record/version' 14 | PassiveRecord::VERSION 15 | end 16 | 17 | gem.summary = gemspec['summary'] 18 | gem.description = gemspec['description'] 19 | gem.licenses = Array(gemspec['license']) 20 | gem.authors = Array(gemspec['authors']) 21 | gem.email = gemspec['email'] 22 | gem.homepage = gemspec['homepage'] 23 | 24 | glob = lambda { |patterns| gem.files & Dir[*patterns] } 25 | 26 | gem.files = `git ls-files`.split($/) 27 | gem.files = glob[gemspec['files']] if gemspec['files'] 28 | 29 | gem.executables = gemspec.fetch('executables') do 30 | glob['bin/*'].map { |path| File.basename(path) } 31 | end 32 | gem.default_executable = gem.executables.first if Gem::VERSION < '1.7.' 33 | 34 | gem.extensions = glob[gemspec['extensions'] || 'ext/**/extconf.rb'] 35 | gem.test_files = glob[gemspec['test_files'] || '{test/{**/}*_test.rb'] 36 | gem.extra_rdoc_files = glob[gemspec['extra_doc_files'] || '*.{txt,md}'] 37 | 38 | gem.require_paths = Array(gemspec.fetch('require_paths') { 39 | %w[ext lib].select { |dir| File.directory?(dir) } 40 | }) 41 | 42 | gem.requirements = Array(gemspec['requirements']) 43 | gem.required_ruby_version = gemspec['required_ruby_version'] 44 | gem.required_rubygems_version = gemspec['required_rubygems_version'] 45 | gem.post_install_message = gemspec['post_install_message'] 46 | 47 | split = lambda { |string| string.split(/,\s*/) } 48 | 49 | if gemspec['dependencies'] 50 | gemspec['dependencies'].each do |name,versions| 51 | gem.add_dependency(name,split[versions]) 52 | end 53 | end 54 | 55 | if gemspec['development_dependencies'] 56 | gemspec['development_dependencies'].each do |name,versions| 57 | gem.add_development_dependency(name,split[versions]) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/passive_record_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe PassiveRecord do 4 | describe ".drop_all" do 5 | it 'should remove all records' do 6 | SimpleModel.create 7 | Post.create 8 | 10.times { Doctor.create } 9 | 10 | PassiveRecord.drop_all 11 | 12 | expect(SimpleModel.count).to eq(0) 13 | expect(Post.count).to eq(0) 14 | expect(Doctor.count).to eq(0) 15 | end 16 | end 17 | end 18 | 19 | describe "passive record models" do 20 | before(:each) { PassiveRecord.drop_all } 21 | 22 | context "a simple model including PR" do 23 | let!(:model) { SimpleModel.create(foo: value) } 24 | let(:value) { 'foo_value' } 25 | 26 | describe "instance methods" do 27 | describe "#update" do 28 | it 'should update attrs' do 29 | expect {model.update(foo: '123')}. 30 | to change {model.foo}.from(value).to('123') 31 | end 32 | 33 | it 'should invoke callbacks' do 34 | model.update(foo: 'barbazquux') 35 | expect(model.updated_at).to be_a(Time) 36 | end 37 | end 38 | 39 | describe "#destroy" do 40 | it 'should remove the entity and freeze it' do 41 | doomed = SimpleModel.create 42 | doomed_id = doomed.id 43 | expect(SimpleModel.find(doomed_id)).to eq(doomed) 44 | doomed.destroy 45 | expect(SimpleModel.find(doomed_id)).to eq(nil) 46 | 47 | SimpleModel.destroy_all 48 | expect{10.times{SimpleModel.create}}.to change{SimpleModel.count}.by(10) 49 | end 50 | end 51 | 52 | describe "#inspect" do 53 | it 'should report attribute details' do 54 | expect(model.inspect).to eq("SimpleModel (id: #{model.id.inspect}, foo: \"foo_value\")") 55 | end 56 | 57 | it 'should report relations' do 58 | dog = Family::Dog.create 59 | expect(dog.inspect). 60 | to eq("Family::Dog (id: #{dog.id.inspect}, breed: \"#{dog.breed}\", created_at: #{dog.created_at}, sound: \"bark\", child_id: nil)") 61 | 62 | child = Family::Child.create 63 | child.dogs << dog 64 | expect(dog.inspect). 65 | to eq("Family::Dog (id: #{dog.id.inspect}, breed: \"#{dog.breed}\", created_at: #{dog.created_at}, sound: \"bark\", child_id: #{child.id.inspect})") 66 | 67 | expect(child.inspect). 68 | to eq("Family::Child (id: #{child.id.inspect}, created_at: #{child.created_at}, name: \"Alice\", parent_id: nil, toy_id: nil, toy_quality_ids: [], dog_ids: [#{dog.id.inspect}], secret_club_ids: [])") 69 | end 70 | end 71 | 72 | describe "#id" do 73 | it 'should be retrievable by id' do 74 | expect(SimpleModel.find_by(model.id)).to eq(model) 75 | expect(SimpleModel.find(model.id)).to eq(model) 76 | end 77 | end 78 | end 79 | 80 | describe "class methods" do 81 | describe "#first" do 82 | it 'should find the first model' do 83 | expect(Model.first).to eq(Model.find(1)) 84 | end 85 | end 86 | 87 | describe "#count" do 88 | it 'should indicate the size of the models list' do 89 | expect { SimpleModel.create }.to change { SimpleModel.count }.by(1) 90 | end 91 | end 92 | 93 | describe "#create" do 94 | it 'should assign attributes' do 95 | expect(model.foo).to eq('foo_value') 96 | end 97 | 98 | it 'should assign ids' do 99 | expect(SimpleModel.create(id: 'the_id').id).to eq('the_id') 100 | end 101 | end 102 | 103 | describe "#first_or_create" do 104 | it 'should assign attributes' do 105 | 106 | end 107 | end 108 | 109 | describe "#destroy_all" do 110 | before { 111 | SimpleModel.create(foo: 'val1') 112 | SimpleModel.create(foo: 'val2') 113 | } 114 | 115 | it 'should remove all models' do 116 | expect { SimpleModel.destroy_all }.to change { SimpleModel.count }.by(-SimpleModel.count) 117 | end 118 | end 119 | 120 | context 'querying by id' do 121 | describe "#find" do 122 | subject(:model) { SimpleModel.create(id: 'model_id') } 123 | it 'should lookup a record based on an identifier' do 124 | expect(SimpleModel.find(-1)).to eq(nil) 125 | expect(SimpleModel.find(model.id)).to eq(model) 126 | expect(SimpleModel.find('model_id')).to eq(model) 127 | end 128 | 129 | it 'should lookup records based on ids' do 130 | model_b = SimpleModel.create 131 | expect(SimpleModel.find([model.id, model_b.id])).to eq([model, model_b]) 132 | end 133 | end 134 | 135 | describe "#where" do 136 | it 'should return a query obj' do 137 | expect(SimpleModel.where(id: 'fake_id')).to be_a(PassiveRecord::Core::Query) 138 | end 139 | 140 | context "queries" do 141 | describe "#create" do 142 | it 'should create objects' do 143 | expect{SimpleModel.where(id: 'new_id').create }.to change{SimpleModel.count}.by(1) 144 | end 145 | end 146 | 147 | describe "#first_or_create" do 148 | it 'should create the object or return matching' do 149 | expect{SimpleModel.where(id: 'another_id').first_or_create }.to change{SimpleModel.count}.by(1) 150 | expect{SimpleModel.where(id: 'another_id').first_or_create }.not_to change{SimpleModel.count} 151 | 152 | expect{SimpleModel.where(id: 'another_id').first_or_create(foo: 'ack') }.not_to change{SimpleModel.count} 153 | expect(SimpleModel.find('another_id').foo).to eq('ack') 154 | end 155 | end 156 | end 157 | end 158 | end 159 | end 160 | 161 | context 'querying by attributes' do 162 | describe "#find_by" do 163 | it 'should be retrievable by query' do 164 | expect(SimpleModel.find_by(foo: 'foo_value')).to eq(model) 165 | end 166 | 167 | context 'nested queries' do 168 | let(:post) { Post.create } 169 | let(:author) { Author.create } 170 | 171 | subject(:posts_with_comment_by_user) do 172 | Post.find_by comments: { author: author } 173 | end 174 | 175 | before do 176 | post.create_comment(author: author) 177 | end 178 | 179 | it 'should find a single record through a nested query' do 180 | expect(post).to eq(posts_with_comment_by_user) 181 | end 182 | 183 | it 'should find multiple records through a nested query' do 184 | another_post = Post.create 185 | another_post.create_comment(author: author) 186 | 187 | posts = Post.find_all_by comments: { author: author } 188 | expect(posts.count).to eq(2) 189 | end 190 | 191 | it 'should find records through a doubly-nested query' do 192 | feed = Feed.create 193 | blog = feed.create_blog 194 | post = blog.create_post 195 | 196 | # expect( Post.find_by(blog: { feed_id: feed.id }) ).to eq(post) 197 | expect( Post.find_by(blog: { feed: { id: feed.id }}) ).to eq(post) 198 | end 199 | end 200 | 201 | context 'queries with ranges' do 202 | let(:model) { Model.create } 203 | it 'should find where attribute value is in range' do 204 | model.created_at = 2.days.ago 205 | expect(Model.find_by(created_at: 3.days.ago..1.day.ago)).to eq(model) 206 | end 207 | end 208 | 209 | context 'queries with arrays (subset)' do 210 | it 'should find where attribute value is included in subset' do 211 | model_a = Model.create(id: 10) 212 | model_b = Model.create(id: 11) 213 | Model.create(id: 12) 214 | expect(Model.find_all_by(id: [10,11])).to eq([model_a, model_b]) 215 | end 216 | end 217 | 218 | context 'queries with negations' do 219 | it 'should find where attribute value is NOT equal' do 220 | model_a = Model.create(id: 'alpha') 221 | model_b = Model.create(id: 'beta') 222 | 223 | expect(Model.where.not(id: 'alpha').first).to eq(model_b) 224 | expect(Model.where.not(id: 'beta').first).to eq(model_a) 225 | end 226 | end 227 | 228 | context 'queries with disjunctions' do 229 | let(:poms_or_pugs) do 230 | Family::Dog. 231 | where(breed: 'pom').or(Family::Dog.where(breed: 'pug')) 232 | end 233 | 234 | let(:poms_or_small_dogs) do 235 | Family::Dog. 236 | where(breed: 'pom').or(Family::Dog.where(size: %w[ tiny small ])) 237 | end 238 | 239 | before do 240 | @pom = Family::Dog.create(breed: 'pom', size: 'tiny') 241 | @pug = Family::Dog.create(breed: 'pug', size: 'big') 242 | 243 | Family::Dog.create(breed: 'mutt', size: 'medium') 244 | Family::Dog.create(breed: 'lab', size: 'large') 245 | 246 | @pap = Family::Dog.create(breed: 'papillon', size: 'small') 247 | end 248 | 249 | it 'should find where attributes match EITHER query' do 250 | expect(poms_or_pugs.all).to eq([@pom, @pug]) 251 | expect(poms_or_small_dogs.all).to eq([@pom, @pap]) 252 | end 253 | 254 | it 'should be able to refine a disjunction' do 255 | expect( 256 | poms_or_pugs.where(size: %w[ tiny small ]).all 257 | ).to eq([@pom]) 258 | end 259 | end 260 | 261 | context 'query chaining' do 262 | it 'should handle conjoining scopes together' do 263 | Post.create published_at: 10.days.ago, active: true 264 | Post.create active: false 265 | recent_and_active = Post.create active: true 266 | 267 | expect(Post.active.recent.all).to eq([recent_and_active]) 268 | expect(Post.recent.active.all).to eq([recent_and_active]) 269 | end 270 | end 271 | 272 | context 'queries with scopes' do 273 | let!(:post) { Post.create(published_at: 10.days.ago) } 274 | let!(:another_post) {Post.create(published_at: 2.days.ago)} 275 | 276 | describe 'should restrict using class method' do 277 | it 'should use a class method as a scope' do 278 | expect(Post.recent).not_to include(post) 279 | expect(Post.recent).to include(another_post) 280 | end 281 | 282 | it 'should negate a nullary scope' do 283 | expect(Post.where.not.recent).to include(post) 284 | expect(Post.where.not.recent).not_to include(another_post) 285 | end 286 | 287 | it 'should use a class method with an argument as a scope' do 288 | expect(Post.where.published_within_days(3)).not_to include(post) 289 | expect(Post.where.published_within_days(3)).to include(another_post) 290 | end 291 | 292 | it 'should negate a scope with an argument' do 293 | expect(Post.where.not.published_within_days(3)).to include(post) 294 | expect(Post.where.not.published_within_days(3)).not_to include(another_post) 295 | end 296 | end 297 | end 298 | 299 | context 'querying with scopes through relationships' do 300 | let(:network) { Network.create } 301 | let(:stream) { network.create_stream } 302 | let(:channel) { stream.create_channel } 303 | let(:feed) { channel.create_feed } 304 | let(:a_blog) { feed.create_blog } 305 | 306 | let!(:not_recent_post) { a_blog.create_post(published_at: 10.days.ago) } 307 | let!(:recent_post) do 308 | a_blog.create_post(published_at: 1.day.ago) 309 | end 310 | 311 | let!(:special_category) { recent_post.create_category(special: true) } 312 | let!(:unspecial_category) { recent_post.create_category(special: false) } 313 | 314 | let!(:approved_comment) { recent_post.create_comment(approved: true) } 315 | let!(:unapproved_comment) { recent_post.create_comment(approved: false) } 316 | 317 | let!(:promoted_tag) { recent_post.create_tag(promoted: true) } 318 | let!(:unpromoted_tag) { recent_post.create_tag(promoted: false) } 319 | 320 | ### 321 | # 322 | let(:another_network) { Network.create } 323 | let(:another_stream) { another_network.create_stream } 324 | let(:another_channel) { another_stream.create_channel } 325 | let(:another_feed) { another_channel.create_feed } 326 | let(:another_blog) { another_feed.create_blog } 327 | 328 | 329 | let!(:post_from_unrelated_blog) { another_blog.create_post(published_at: 1.day.ago) } 330 | let!(:unrelated_comment) do 331 | post_from_unrelated_blog.create_comment(approved: true) 332 | end 333 | 334 | let!(:another_category) do 335 | post_from_unrelated_blog.create_category(special: true) 336 | end 337 | 338 | let!(:another_tag) { post_from_unrelated_blog.create_tag(promoted: true) } 339 | 340 | describe 'should find related models through a has many' do 341 | it 'should refine' do 342 | expect(a_blog.posts.recent).to include(recent_post) 343 | expect(a_blog.posts.recent).not_to include(not_recent_post) 344 | end 345 | 346 | it 'should restrict' do 347 | a_blog.posts.all.each do |post| 348 | expect(another_blog.posts.all.map(&:id)).not_to include(post.id) 349 | end 350 | end 351 | end 352 | 353 | describe 'should find related models on a has_many through' do 354 | it 'should refine' do 355 | expect(feed.posts.recent).to include(recent_post) 356 | expect(feed.posts.recent).not_to include(not_recent_post) 357 | end 358 | 359 | it 'should restrict' do 360 | feed.posts.each do |post| 361 | expect(another_feed.posts).not_to include(post) 362 | end 363 | end 364 | end 365 | 366 | describe 'should find related models on a nested has_many thru' do 367 | it 'should refine' do 368 | expect(channel.posts.recent).to include(recent_post) 369 | expect(channel.posts.recent).not_to include(not_recent_post) 370 | end 371 | 372 | it 'should restrict' do 373 | channel.posts.each do |post| 374 | expect(another_channel.posts).not_to include(post) 375 | end 376 | end 377 | end 378 | 379 | describe 'should find related models on a double-nested has_many thru' do 380 | it 'should refine' do 381 | expect(stream.posts.recent).to include(recent_post) 382 | expect(stream.posts.recent).not_to include(not_recent_post) 383 | end 384 | 385 | it 'should restrict' do 386 | expect(stream.posts.where.all).not_to be_empty 387 | stream.posts.where.all.each do |post| 388 | expect(another_stream.posts.where.all).not_to include(post) 389 | end 390 | end 391 | end 392 | 393 | describe 'should find related models on a deeply nested has_many thru' do 394 | it 'should refine' do 395 | expect(network.posts.recent).to include(recent_post) 396 | expect(network.posts.recent).not_to include(not_recent_post) 397 | end 398 | 399 | it 'should restrict' do 400 | network.posts.all.each do |post| 401 | expect(another_network.posts.all.map(&:id)).not_to include(post.id) 402 | end 403 | end 404 | end 405 | 406 | describe 'should find related models on a recursive has_many thru' do 407 | it 'should refine' do 408 | expect(network.comments.approved).to include(approved_comment) 409 | expect(network.comments.approved).not_to include(unapproved_comment) 410 | end 411 | 412 | it 'should restrict' do 413 | expect(network.comments.all).not_to be_empty 414 | network.comments.all.each do |comment| 415 | expect(another_network.comments).not_to include(comment) 416 | end 417 | end 418 | end 419 | 420 | describe 'should find related models a recursive has_many :thru a habtm' do 421 | 422 | it 'should refine' do 423 | expect(network.tags.promoted).to include(promoted_tag) 424 | expect(network.tags.promoted).not_to include(unpromoted_tag) 425 | end 426 | 427 | it 'should restrict' do 428 | expect(network.tags.all).not_to be_empty 429 | expect(another_network.tags.all).not_to be_empty 430 | network.tags.all.each do |tag| 431 | expect(another_network.tags.all).not_to include(tag) 432 | end 433 | end 434 | end 435 | 436 | describe 'should find related nested models through a manual habtm' do 437 | it 'should refine' do 438 | expect(network.categories.special).to include(special_category) 439 | expect(network.categories.special).not_to include(unspecial_category) 440 | end 441 | 442 | it 'should restrict' do 443 | expect(another_network.categories.all).not_to be_empty 444 | expect(network.categories.all).not_to be_empty 445 | another_network.categories.where.all.each do |category| 446 | expect(network.categories.where.all).not_to include(category) 447 | end 448 | end 449 | end 450 | end 451 | end 452 | end 453 | end 454 | 455 | context 'hooks' do 456 | context 'after create hooks' do 457 | it 'should use a symbol to invoke a method' do 458 | expect(Family::Child.create.name).to eq("Alice") 459 | end 460 | 461 | it 'should use a block' do 462 | expect(Family::Dog.create.sound).to eq("bark") 463 | end 464 | 465 | it 'should use an inherited block' do 466 | expect(Family::Parent.create.created_at).to be_a(Time) 467 | end 468 | end 469 | end 470 | 471 | context 'associations' do 472 | context 'one-to-one relationships' do 473 | let(:child) { Family::Child.create } 474 | let(:another_child) { Family::Child.create } 475 | 476 | it 'should create children' do 477 | expect { child.create_toy }.to change { Family::Toy.count }.by(1) 478 | expect(child.toy).to eq(Family::Toy.last) 479 | end 480 | 481 | it 'should have inverse relationships' do 482 | toy = child.create_toy 483 | expect(toy.child).to eq(child) 484 | 485 | another_toy = another_child.create_toy 486 | expect(another_toy.child).to eq(another_child) 487 | end 488 | 489 | it 'should assign parents' do 490 | toy = Family::Toy.create 491 | toy.child = child 492 | expect(child.toy).to eq(toy) 493 | 494 | child.toy = Family::Toy.create 495 | expect(child.toy).not_to eq(toy) 496 | end 497 | end 498 | 499 | context 'one-to-many relationships' do 500 | let(:parent) { Family::Parent.create } 501 | let(:another_parent) { Family::Parent.create(children: [another_child]) } 502 | let(:another_child) { Family::Child.create } 503 | 504 | describe "#xxx<<" do 505 | it 'should create children with <<' do 506 | child = Family::Child.create 507 | expect {parent.children << child}.to change{parent.children.count}.by(1) 508 | expect(parent.children).to include(child) 509 | end 510 | end 511 | 512 | describe "#create_xxx" do 513 | it 'should create children' do 514 | expect { parent.create_child }.to change{ Family::Child.count }.by(1) 515 | expect(parent.children).to all(be_a(Family::Child)) 516 | end 517 | end 518 | 519 | it 'should assign children on creation' do 520 | expect(another_parent.children.all).to match_array([another_child]) 521 | end 522 | 523 | it 'should create inverse relationships' do 524 | child = parent.create_child 525 | expect(child.parent).to eq(parent) 526 | 527 | another_child = parent.create_child 528 | expect(another_child.parent).to eq(parent) 529 | 530 | expect(child.id).not_to eq(another_child.id) 531 | expect(parent.children.all).to eq([child, another_child]) 532 | expect(parent.child_ids).to eq([child.id, another_child.id]) 533 | end 534 | 535 | it 'should provide arithmetic helpers' do 536 | parent.create_child(age: 10) 537 | parent.create_child(age: 10) 538 | parent.create_child(age: 40) 539 | 540 | expect(parent.children.pluck(:age)).to eq([10,10,40]) 541 | expect(parent.children.sum(:age)).to eq(60) 542 | expect(parent.children.average(:age)).to eq(20) 543 | expect(parent.children.mode(:age)).to eq(10) 544 | 545 | expect(parent.children.where(:age => 10).pluck(:age)).to eq([10,10]) 546 | expect(parent.children.where(:age => 10).sum(:age)).to eq(20) 547 | end 548 | end 549 | 550 | context 'one-to-many through relationships' do 551 | let(:parent) { Family::Parent.create } 552 | let(:child) { parent.create_child } 553 | 554 | it 'should collect children of children' do 555 | child.create_dog(breed: 'mutt') 556 | expect(parent.dogs.all).to all(be_a(Family::Dog)) 557 | expect(parent.dogs.count).to eq(1) 558 | expect(parent.dogs.first).to eq(child.dogs.first) 559 | expect(parent.dog_ids).to eq([child.dogs.first.id]) 560 | end 561 | 562 | it 'should chain where clauses' do 563 | mutt = child.create_dog(breed: 'mutt') 564 | pit = child.create_dog(breed: 'pit') 565 | 566 | # another mutt, not the same childs 567 | another_mutt = Family::Dog.create(breed: 'mutt') 568 | 569 | expect(Family::Dog.where(breed: 'mutt').all).to eq([mutt, another_mutt]) 570 | expect(child.dogs.where(breed: 'mutt').all).to eq([mutt]) 571 | expect(child.dogs.where.not(breed: 'mutt').all).to eq([pit]) 572 | 573 | expect( 574 | child.dogs. 575 | where(breed: 'mutt').all 576 | ).to eq( 577 | Family::Dog. 578 | where(child_id: child.id). 579 | where(breed: 'mutt').all 580 | ) 581 | end 582 | 583 | it 'should do the nested query example from the readme' do 584 | child.create_dog 585 | expect(Family::Dog.find_all_by(child: {parent: parent})). 586 | to eq(parent.dogs.all) 587 | end 588 | 589 | it 'should work for has-one intermediary relationships' do 590 | child.create_toy 591 | expect(parent.toys).to all(be_a(Family::Toy)) 592 | expect(parent.toys.count).to eq(1) 593 | expect(parent.toys.first).to eq(child.toy) 594 | end 595 | 596 | it 'should attempt to construct intermediary relations' do 597 | expect { parent.create_toy(child: child) }.to change {Family::Toy.count}.by(1) 598 | expect(Family::Toy.last.child).to eq(child) 599 | expect(Family::Toy.last.child.parent).to eq(parent) 600 | expect { 3.times { parent.toys << Family::Toy.create } }.to change {Family::Toy.count}.by(3) 601 | expect { 3.times { parent.toys << Family::Toy.create } }.to change {parent.toys.count}.by(3) 602 | expect(parent.toys.last.child).not_to be_nil 603 | 604 | expect{parent.create_toy}.to change{Family::Toy.count}.by(1) 605 | end 606 | 607 | it 'should construct intermediary relations with many-through-many' do 608 | expect{parent.dogs << Family::Dog.create}.to change{parent.dogs.count}.by(1) 609 | 610 | expect{parent.create_dog}.to change{parent.dogs.count}.by(1) 611 | 612 | expect{parent.toys << Family::Toy.create}.to change{parent.toys.count}.by(1) 613 | expect{parent.create_dog(child: child)}.to change{child.dogs.count}.by(1) 614 | expect{parent.create_dog(child_id: child.id)}.to change{child.dogs.count}.by(1) 615 | end 616 | 617 | it 'should accept class name' do 618 | post = Post.create 619 | author = Author.create 620 | Comment.create(post: post, author: author) 621 | 622 | expect(post.commenters.all).to eq([author]) 623 | end 624 | end 625 | 626 | context 'many-to-many' do 627 | let(:patient) { Patient.create } 628 | let(:doctor) { Doctor.create } 629 | let!(:appointment) { Appointment.create(patient: patient, doctor: doctor) } 630 | 631 | it 'should manage many-to-many relations' do 632 | expect(appointment.doctor).to eq(doctor) 633 | expect(appointment.patient).to eq(patient) 634 | 635 | expect(patient.doctors.all).to eq([doctor]) 636 | expect(doctor.patients.all).to eq([patient]) 637 | end 638 | 639 | it 'should handle insertion' do 640 | expect{patient.doctors << Doctor.create}.to change{patient.doctors.count}.by(1) 641 | end 642 | end 643 | 644 | context 'self-referential many-to-many' do 645 | let!(:user_a) { User.create } 646 | let!(:user_b) { User.create } 647 | 648 | it 'should permit relations' do 649 | expect(user_a.friends).to be_empty 650 | 651 | # need to create bidirectional friendship 652 | Friendship.create(user: user_a, friend: user_b) 653 | Friendship.create(user: user_b, friend: user_a) 654 | 655 | expect(user_a.friends.all).to eq([user_b]) 656 | expect(user_b.friends.all).to eq([user_a]) 657 | end 658 | end 659 | 660 | context 'manual habtm' do 661 | let!(:resource) { Resource.create } 662 | let!(:user) { User.create } 663 | 664 | it 'should permit relations' do 665 | expect(user.resources).to be_empty 666 | expect(resource.users).to be_empty 667 | 668 | ResourceAllocation.create(user: user, resource: resource) 669 | 670 | expect(user.resources).to include(resource) 671 | expect(resource.users).to include(user) 672 | end 673 | 674 | it 'should permit querying' do 675 | ResourceAllocation.create(user: user, resource: resource) 676 | expect(user.resources.where.all).to include(resource) 677 | end 678 | end 679 | 680 | context 'direct habtm' do 681 | before(:each) { PassiveRecord.drop_all } 682 | let!(:user) { User.create roles: [role] } 683 | let(:role) { Role.create } 684 | let(:another_user) { User.create } 685 | 686 | it 'should manage direct habtm relations' do 687 | expect(role.users).to include(user) 688 | expect(user.roles).to include(role) 689 | expect(role.user_ids).to eq([user.id]) 690 | expect(user.role_ids).to eq([role.id]) 691 | expect {role.users << another_user}.to change{role.users.count}.by(1) 692 | end 693 | 694 | it 'should handle inverse relations' do 695 | expect {role.users << another_user}.to change{another_user.roles.count}.by(1) 696 | end 697 | 698 | it 'should work inside modules' do 699 | child = Family::Child.create 700 | secret_club = child.create_secret_club 701 | 702 | expect(secret_club).to be_a(Family::SecretClub) 703 | expect(secret_club.children.all).to eq([child]) 704 | expect(child.secret_clubs.first.create_child).to be_a(Family::Child) 705 | end 706 | end 707 | 708 | context 'has many through has one' do 709 | it 'should manage relationships' do 710 | child = Family::Child.create 711 | toy_quality = child.create_toy_quality(name: 'fun') 712 | child.create_toy_quality(name: 'cool') 713 | child.create_toy_quality(name: 'radical') 714 | 715 | expect(child.toy_qualities.all).to include(toy_quality) 716 | expect(child.toy_qualities.count).to eq(3) 717 | expect(child.toy_qualities.map(&:name)).to eq(%w[ fun cool radical ]) 718 | end 719 | end 720 | end 721 | end 722 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'pry' 3 | require 'passive_record' 4 | 5 | class Model 6 | include PassiveRecord 7 | attr_accessor :created_at 8 | after_create { @created_at = Time.now } 9 | end 10 | 11 | class SimpleModel < Struct.new(:foo) 12 | include PassiveRecord 13 | attr_reader :updated_at 14 | after_update { @updated_at = Time.now } 15 | end 16 | 17 | module Family 18 | class Dog < Model 19 | attr_reader :sound 20 | attr_accessor :breed, :size 21 | belongs_to :child 22 | before_create { @breed = %w[pom pug].sample } 23 | after_create { @sound = 'bark' } 24 | end 25 | 26 | class ToyQuality < Model 27 | attr_accessor :name 28 | belongs_to :toy 29 | end 30 | 31 | class Toy < Model 32 | belongs_to :child 33 | has_many :toy_qualities 34 | attr_reader :kind 35 | after_create {@kind = %w[ stuffed_animal blocks cards ].sample} 36 | end 37 | 38 | class Child < Model 39 | belongs_to :parent 40 | has_one :toy 41 | has_many :toy_qualities, :through => :toy 42 | has_many :dogs 43 | has_and_belongs_to_many :secret_clubs 44 | 45 | attr_reader :name 46 | after_create :give_name 47 | attr_accessor :age 48 | 49 | def give_name; @name = "Alice" end 50 | end 51 | 52 | class SecretClub < Model 53 | has_and_belongs_to_many :children 54 | end 55 | 56 | class Parent < Model 57 | has_many :children 58 | has_many :dogs, :through => :children 59 | has_many :toys, :through => :children 60 | end 61 | end 62 | 63 | ### 64 | 65 | class Patient < Model 66 | has_many :appointments 67 | has_many :doctors, :through => :appointments 68 | end 69 | 70 | class Appointment < Model 71 | belongs_to :patient 72 | belongs_to :doctor 73 | end 74 | 75 | class Doctor < Model 76 | has_many :appointments 77 | has_many :patients, :through => :appointments 78 | end 79 | 80 | ### 81 | # 82 | # self-referential case 83 | # 84 | class Friendship < Model 85 | belongs_to :user 86 | belongs_to :friend, class_name: "User" 87 | end 88 | 89 | class User < Model 90 | has_many :friendships 91 | has_many :friends, :through => :friendships 92 | 93 | has_and_belongs_to_many :roles 94 | 95 | has_many :resource_allocations 96 | has_many :resources, :through => :resource_allocations 97 | end 98 | 99 | class Role < Model 100 | has_and_belongs_to_many :users 101 | end 102 | 103 | class ResourceAllocation < Model 104 | belongs_to :user 105 | belongs_to :resource 106 | end 107 | 108 | class Resource < Model 109 | # TODO why can't we use a custom class name here??? 110 | has_many :resource_allocations #, class_name: "ResourceAllocation" 111 | has_many :users, through: :resource_allocations 112 | end 113 | 114 | ### 115 | # 116 | 117 | class Network < Model 118 | has_many :streams 119 | has_many :posts, :through => :streams 120 | has_many :comments, :through => :posts 121 | has_many :tags, :through => :posts 122 | has_many :categories, :through => :posts 123 | end 124 | 125 | class Stream < Model 126 | belongs_to :network 127 | has_many :channels 128 | has_many :posts, :through => :channels 129 | end 130 | 131 | class Channel < Model 132 | belongs_to :stream 133 | has_many :feeds 134 | has_many :posts, :through => :feeds 135 | end 136 | 137 | class Feed < Model 138 | belongs_to :channel 139 | has_many :blogs 140 | has_many :posts, :through => :blogs 141 | end 142 | 143 | class Blog < Model 144 | has_many :posts 145 | belongs_to :feed 146 | end 147 | 148 | class Tag < Model 149 | has_and_belongs_to_many :posts 150 | attr_accessor :promoted 151 | def self.promoted; where(promoted: true) end 152 | end 153 | 154 | class Category < Model 155 | attr_accessor :special 156 | has_many :post_categories 157 | has_many :posts, :through => :post_categories 158 | 159 | def self.special; where(special: true) end 160 | end 161 | 162 | class PostCategory < Model 163 | belongs_to :post 164 | belongs_to :category 165 | end 166 | 167 | class Post < Model 168 | belongs_to :author 169 | belongs_to :blog 170 | has_many :comments 171 | has_many :commenters, :through => :comments, :class_name => "Author" 172 | has_and_belongs_to_many :tags 173 | 174 | has_many :post_categories 175 | has_many :categories, :through => :post_categories 176 | 177 | attr_accessor :active, :published_at 178 | before_create { @published_at = Time.now } 179 | 180 | def self.active 181 | where(active: true) 182 | end 183 | 184 | def self.recent 185 | where(:published_at => 3.days.ago..Time.now) 186 | end 187 | 188 | def self.published_within_days(n) 189 | where(:published_at => n.days.ago..Time.now) 190 | end 191 | end 192 | 193 | class Author < Model 194 | has_many :posts 195 | has_many :comments 196 | end 197 | 198 | class Comment < Model 199 | attr_accessor :approved 200 | belongs_to :post 201 | belongs_to :author 202 | 203 | def self.approved; where(approved: true) end 204 | end 205 | --------------------------------------------------------------------------------