├── .github └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── Guardfile ├── LICENSE ├── README.md ├── config └── locales │ └── en.yml ├── lib ├── mongoid │ └── alize │ │ ├── callback.rb │ │ ├── callbacks │ │ └── from │ │ │ ├── many.rb │ │ │ └── one.rb │ │ ├── errors │ │ ├── alize_error.rb │ │ ├── already_defined_field.rb │ │ ├── invalid_configuration.rb │ │ └── invalid_field.rb │ │ ├── from_callback.rb │ │ ├── instance_helpers.rb │ │ ├── macros.rb │ │ └── to_callback.rb └── mongoid_alize.rb ├── mongoid_alize.gemspec └── spec ├── app └── models │ ├── head.rb │ ├── mock_object.rb │ └── person.rb ├── helpers └── macros_helper.rb ├── mongoid └── alize │ ├── callback_spec.rb │ ├── callbacks │ ├── from │ │ ├── many_spec.rb │ │ └── one_spec.rb │ └── to │ │ ├── many_from_many_spec.rb │ │ ├── many_from_one_spec.rb │ │ ├── one_from_many_spec.rb │ │ └── one_from_one_spec.rb │ ├── from_callback_spec.rb │ ├── instance_helpers_spec.rb │ ├── macros_spec.rb │ ├── mongoize_spec.rb │ └── to_callback_spec.rb ├── mongoid_alize_spec.rb └── spec_helper.rb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI RSpec Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: >- 8 | Mongoid ${{ matrix.mongoid }} - Ruby ${{ matrix.ruby }} - Rails ${{ matrix.rails }} 9 | env: 10 | CI: true 11 | TESTOPTS: -v 12 | runs-on: ubuntu-latest 13 | continue-on-error: false 14 | strategy: 15 | matrix: 16 | ruby: [ 2.1, 2.2, 2.4, 2.5, 2.6, 2.7, 3.0, 3.1 ] 17 | rails: [ 3.2, 4.0, 4.2, 5.0, 5.1, 5.2, 6.0, 6.1, 7.0 ] 18 | mongoid: [ 3, 4, 5, 6.1, 6.2, 6.4, 7.5, 8.0 ] 19 | exclude: 20 | - mongoid: 3 21 | - mongoid: 4 22 | - mongoid: 5 23 | - mongoid: 6.1 24 | - mongoid: 6.2 25 | - mongoid: 6.4 26 | - mongoid: 7.5 27 | - mongoid: 8.0 28 | include: 29 | - ruby: 2.1 30 | rails: 3.2 31 | mongoid: 3 32 | bundler: 1 33 | - ruby: 2.1 34 | rails: 4.0 35 | mongoid: 4 36 | bundler: 1 37 | - ruby: 2.2 38 | rails: 4.2 39 | mongoid: 4 40 | bundler: 2 41 | - ruby: 2.2 42 | rails: 4.2 43 | mongoid: 5 44 | bundler: 2 45 | - ruby: 2.4 46 | rails: 5.0 47 | mongoid: 6.1 48 | bundler: 2 49 | - ruby: 2.5 50 | rails: 5.1 51 | mongoid: 6.2 52 | bundler: 2 53 | - ruby: 2.5 54 | rails: 5.2 55 | mongoid: 6.4 56 | bundler: 2 57 | - ruby: 2.6 58 | rails: 6.0 59 | mongoid: 7.5 60 | bundler: 2 61 | - ruby: 2.6 62 | rails: 6.1 63 | mongoid: 7.5 64 | bundler: 2 65 | - ruby: 2.7 66 | rails: 6.1 67 | mongoid: 7.5 68 | bundler: 2 69 | - ruby: 3.0 70 | rails: 6.1 71 | mongoid: 8.0 72 | bundler: 2 73 | - ruby: 3.1 74 | rails: 7.0 75 | mongoid: 8.0 76 | bundler: 2 77 | steps: 78 | - name: repo checkout 79 | uses: actions/checkout@v2 80 | 81 | - name: start mongodb 82 | uses: supercharge/mongodb-github-action@1.6.0 83 | with: 84 | mongodb-version: 3.6 85 | mongodb-replica-set: rs0 86 | 87 | - name: load ruby 88 | uses: ruby/setup-ruby@v1 89 | with: 90 | ruby-version: ${{ matrix.ruby }} 91 | bundler: ${{ matrix.bundler }} 92 | 93 | - name: bundle install 94 | run: bundle install --jobs 4 --retry 3 95 | env: 96 | MONGOID_VERSION: ${{ matrix.mongoid }} 97 | RAILS_VERSION: ${{ matrix.rails }} 98 | 99 | - name: test 100 | timeout-minutes: 10 101 | run: bundle exec rspec spec 102 | continue-on-error: false 103 | env: 104 | MONGOID_VERSION: ${{ matrix.mongoid }} 105 | RAILS_VERSION: ${{ matrix.rails }} 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | log/* 2 | *.gem 3 | Gemfile.lock 4 | Gemfile.mongoid-two.lock 5 | Gemfile.mongoid-three.lock 6 | Gemfile.mongoid-four.lock 7 | bin 8 | .bundle 9 | tmp 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | services: mongodb 3 | script: "bundle exec rspec spec" 4 | rvm: 5 | - 2.5.7 6 | - 2.6.6 7 | gemfile: 8 | - Gemfile 9 | env: 10 | - RAILS_VERSION=3.2 MONGOID_VERSION=3.1 11 | - RAILS_VERSION=4.2.10 MONGOID_VERSION=4.0 12 | - RAILS_VERSION=4.2.10 MONGOID_VERSION=5.0 13 | - RAILS_VERSION=5.0 MONGOID_VERSION=6.1 14 | - RAILS_VERSION=5.1 MONGOID_VERSION=6.2 15 | - RAILS_VERSION=5.2 MONGOID_VERSION=6.4 16 | - RAILS_VERSION=6.0.3 MONGOID_VERSION=7.0.8 17 | - RAILS_VERSION=6.0.3 MONGOID_VERSION=7.1.2 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Mongoid::Alize 2 | ============== 3 | > Comprehensive, flexible denormalization for Mongoid that stays in sync 4 | 5 | ## Changelog 6 | 7 | ### Unreleased 8 | Code cleanup and method renaming for clarity. 9 | 10 | ### Release 0.4.0 11 | Support for Mongoid 3. 12 | 13 | ### Release 0.3.0 14 | 15 | #### Unifying how data is stored 16 | 17 | mongoid_alize 0.3.0 is imcompatible with previous versions for one-to-one relations. Previous versions defined fields of the form `%{relation}_%{field_name}`, e.g. `post_username` to store the username from post. This caused the implementation of one-to-one and one-to-many relations to be quite different, and it made handling polymorphic associations infeasible because fields are different for each related model. There are several other reasons why this setup wasn't optimal: data types for one-to-ones had to be considered up-front, and creating distinct groups of denormalized fields based on the same relation (something planned for in the future) wouldn't be possible. Last but not least, this makes the eventual handling of this JSON by a client more symmetrical (e.g. my code to instantiate nested Backbone.js models from denormalized data became much more concise). 18 | 19 | The bottom line is that it all works the same now. If you're doing a one-to-one from a `user` relation, the denormalized data is stored as a Hash in a `user_fields`. If you are doing a many-to-one, it's still `user_fields` - but as an Array. And if it's polymorphic in either case, it's still `user_fields`, but the fields stored might be different each time. 20 | 21 | #### Polymorphic support 22 | 23 | Polymorphic relations are supported. That said, there are two things to be aware of. 24 | 25 | One is the natural limitation of the `alize` macro when it comes to polymorphic relations - the Class of the object stored by the relation is known only at runtime. So, when you specify `alize` on the polymorphic side (the side with the `:polymorphic => true` argument to the relation), `alize` cannot apply the to-side macro automatically - it doesn't know how to find the inverse(s). To still get to-side behavior, you'll need to add the `alize_to` macro for any class/relation that can be an inverse (i.e. any relation that uses the `:as => :something` parameter to the relation definition.) 26 | 27 | The second challenge is that the fields to denormalize will likely be different on per-inverse basis. Perhaps your `:addressable` relation can store both homes and offices but needs to store different fields for each (e.g. offices have a company name, and homes belong to owners). This can be accomplished by passing a proc to the `:fields` option key when defining the relation. The block will be passed the model instance in question: 28 | 29 | alize :addressable, :fields => lambda { |addressable| 30 | if addressable.is_a?(Home) 31 | [:owner_name] 32 | elsif addressable.is_a?(Office) 33 | [:company_name] 34 | end 35 | } 36 | 37 | Protip - In practice, rather than doing ugly type checking, I implement a method on any class that can be addressable that returns a list of fields: 38 | 39 | class Home 40 | def alize_fields_for_addressable 41 | [:owner_name] 42 | end 43 | end 44 | 45 | class Office 46 | def alize_fields_for_addressable 47 | [:company_name] 48 | end 49 | end 50 | 51 | alize :addressable, :fields => lambda { |addressable| addressable.alize_fields_for_addressable } 52 | 53 | Note the fields option is valid for anything you alize. 54 | 55 | ### denormalize_from_all and denormalize_to_all hooks 56 | Each class where `Mongoid::Alize` is included has two new methods - `denormalize_from_all` and `denormalize_to_all`. These methods run all of the alize callbacks (in the appropriate direction) for that model. 57 | 58 | This comes in handy when you want to trigger denormalization without going through the save callback cycle. Keep in mind that denormalize_from methods do not automatically persist the data that's updated in the model (b/c they're traditionally used in a before save). So if you call `denormalize_from_all` you'll need to handle persistance yourself - usually through atomic mongoid operations like `set`. 59 | 60 | Protip: If you need even more flexibility, you now have access to alize's callback metadata in either direction via the class methods `alize_from_callbacks` and `alize_to_callbacks`. Each is an array of `Mongoid::Alize::Callback` objects. 61 | 62 | Protip #2: Make sure to pair with the `force_denormalization` attr if you want all callbacks to skip dirty checking (appropriate for batch updates, sync-ing stale data, etc) 63 | 64 | Protip #3: I use this to fire `to` denormalizations after `to` denormalizations (and this will be the default behavior soon). If you are denormalizing denormalized data (meta, I know) you can use this to make sure updates to a model trigger denormalization to *it's* model's. 65 | 66 | ### Speed 67 | One-to-one performance is dramatically improved. Updating all fields is accomplished via one `set` operation. 68 | 69 | #### Misc 0.3.0 updates 70 | - `alize_to` and `alize_from` are available separately if you only want one type of behavior for a relation. `alize` still does both (except for polymorphic relations, in which case it acts as `alize_from`) 71 | - You can pass a `:fields` proc to any `alize` to dynamically determine stores fields at the instance level. 72 | 73 | #### Upgrading 74 | You'll need to rewrite the parts of your application that use one-to-one denormalization. Instead of finding data in a `post_title` field, you'll be looking in `post_fields["title"]`. 75 | After updating your code, re-denormalize your data with 0.3.0 installed (loop through objects and call save with the `force_denormalization` attr set to true). 76 | 77 | #### Will the API keep changing? 78 | It's my intent to follow the [Semantic Versioning Spec](http://semver.org). So until 1.0, it's possible that breaking changes may be introduced. I'll do my best to outline the changes each time and give advice on how to respond to changes. The goal is to get to 1.0 as quickly as possible, but there is still some real-world mileage to cover. 79 | 80 | ### Release 0.2.0 81 | 82 | #### denormalize_from callbacks now invoked on save 83 | 84 | These callbacks are now called on save in addition to create. This makes sure that modified relations get picked up and denormalized fields get changed as a result. Where predictable dirty checking is possible it is used to skip unneeded callbacks. Where a dirty status cannot reliably be inferred, the denormalize callback is triggered. While there might be a slight performance hit, I believe the guarantee of consistent data is more important. Future optimizations will be able to skip callbacks more eagerly. 85 | 86 | #### Denormalization of methods (a.k.a lazily computed pseudo-attributes) is now supported for all relations 87 | 88 | Because methods don't have explicit return types, there are a few rules around the type definition of the field that will hold the method's data. 89 | 90 | + If the field isn't defined it's type will be set to `String`. 91 | + If a field is already defined, it will not be redefined. This allows you to define the field in advance and give it the type you like. 92 | 93 | For example, if you are denormalizing a method `User#birthday` and you'd like birthday to be stored as a date, you might do this: 94 | 95 | class Invitation 96 | belongs_to :user 97 | field :user_birthday, Date 98 | alize :user, :birthday 99 | end 100 | 101 | #### Method aliasing 102 | 103 | The generated, public `denormalize_to_foo` callbacks now also have protected aliases that begin with \_. And alize will not override these callbacks if you have already set them. This allows you to define the callbacks yourselves, do whatever you need, and tell alize to do what it otherwise would. This also makes it possible to annotate methods (i.e. `handle_asynchronously`) because can control the place at which they are defined. 104 | 105 | #### Other misc updates 106 | + Several performance boosts via combining field updates and dirty checking. (That said, the big performance gains like advanced dirty checking and bulk updates are coming in 0.3. This release was focused on features and usability.) 107 | + Duplicate callbacks no longer added due to development environment class reloading 108 | + Error classes w/ I18n support. Errors where fields in the alize definition do not exist are raised. 109 | + Denormalize `:id` just like any other attribute in any relation type. 110 | + A `force` param for the aliased denormalize methods (to skip dirty checking) as well as a `force_denormalization` attribute to instruct a class to fire all callbacks regardless of dirty status. 111 | + Updated 'scenario' spec - `mongoid_alize_spec.rb` with new use cases 112 | 113 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | rails_version = ENV['RAILS_VERSION'] || "7.0.3" 4 | gem "rails", "~> #{rails_version}" 5 | 6 | mongoid_version = ENV['MONGOID_VERSION'] || "8.0.2" 7 | gem "mongoid", "~> #{mongoid_version}" 8 | 9 | gem "mongoid-compatibility" 10 | 11 | group :development, :test do 12 | gem 'rspec', '~> 2.99' 13 | gem 'rr', '1.2.1' 14 | 15 | unless ENV['CI'] 16 | gem 'guard' 17 | gem 'guard-rspec' 18 | gem 'ruby_gntp' 19 | gem 'rb-fsevent' 20 | 21 | # irb goodies 22 | gem 'awesome_print' 23 | gem 'wirble' 24 | end 25 | end 26 | 27 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'rspec', :version => 2, :cli => "--format progress" do 2 | watch('spec/spec_helper.rb') { "spec" } 3 | watch(%r{^spec/.+_spec\.rb$}) 4 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 5 | end 6 | 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mongoid::Alize - Copyright (c) 2013 Josh Dzielak 2 | 3 | MIT LICENSE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Mongoid::Alize 2 | ============== 3 | 4 | [![Build Status](https://github.com/dzello/mongoid_alize/actions/workflows/test.yml/badge.svg)](https://github.com/dzello/mongoid_alize/actions) 5 | [![Code Climate](https://codeclimate.com/github/dzello/mongoid_alize.png)](https://codeclimate.com/github/dzello/mongoid\_alize) 6 | 7 | **Comprehensive, flexible denormalization for Mongoid that stays in sync** 8 | 9 | > Everything *and* the kitchen sync... 10 | 11 | Mongoid Alize helps you improve your Mongoid application's read performance by making it easy to store related data together. 12 | 13 | Features of Mongoid Alize 14 | ------------------------- 15 | - Extremely light DSL and easy setup 16 | - Works with one-to-one, one-to-many, and many-to-many relations. 17 | - Callbacks set on both sides of relations keep data in sync. Even on destroys! 18 | - Atomic modifiers are used for superior performance. 19 | - Supports polymorphic relations as of 0.3.0. 20 | - Custom callbacks and exposed metadata provide flexibility and extensibility (e.g. asynchronous denormalization) 21 | - Comprehensive test suite with dozens of examples 22 | 23 | Compatibility 24 | ------------- 25 | As of August 2022, Mongoid Alize supports Mongoid 8 thanks to [this PR](https://github.com/dzello/mongoid_alize/pull/57) from @joe1chen 🎉. You will need to bundle directly from git to get the latest support, a gem is not yet released. 26 | 27 | As of September 2020, Mongoid Alize supports Mongoid up to version 7.0 and 7.1 thanks to [this PR](https://github.com/dzello/mongoid_alize/pull/56). 28 | 29 | Installation 30 | ------------ 31 | Add the gem to your `Gemfile`: 32 | 33 | ``` ruby 34 | gem 'mongoid_alize' 35 | ``` 36 | 37 | Or install with RubyGems: 38 | 39 | ``` shell 40 | $ gem install mongoid_alize 41 | ``` 42 | 43 | Usage 44 | ----- 45 | Here's a simple use case. A `Post` model would like to denormalize some data about its author - the `User`. 46 | 47 | ``` ruby 48 | class Post 49 | include Mongoid::Document 50 | include Mongoid::Alize 51 | field :title 52 | field :category 53 | has_one :user 54 | 55 | # *** 56 | alize :user, :name, :city # denormalize name and city from user 57 | # *** 58 | 59 | end 60 | 61 | # User data now saves into the Post record 62 | @post.user = User.create!(:name => "Josh", :city => "San Francisco") 63 | @post.user_fields["name"] #=> "Josh" 64 | @post.user_fields["city"] #=> "San Francisco" 65 | ``` 66 | 67 | Here's another case - where we'd like to store Post data into the User record. Note there are 'many' posts. 68 | 69 | ``` ruby 70 | class User 71 | include Mongoid::Document 72 | include Mongoid::Alize 73 | field :name 74 | field :city 75 | has_many :posts 76 | 77 | # *** 78 | alize :posts # denormalize all fields from posts (the default w/ no fields specified) 79 | # *** 80 | 81 | end 82 | 83 | # Post data now saves into the User record 84 | @user.posts << Post.create!(:title => "Building a new bike", :category => "Cycling") 85 | @user.posts << Post.create!(:title => "Bay Area Kayaking", :category => "Kayaking") 86 | @user.posts_fields #=> [{ "title" => "Building a new bike", :category => "Cycling" }, 87 | # { "title" => "Bay Area Kayaking", :category => "Kayaking" }] 88 | ``` 89 | 90 | One-to-one, many-to-one, one-to-many, and many-to-many referenced relations are all supported. 91 | 92 | Changes made to the denormalized models will be propagated to the document(s) 93 | where that data has been denormalized for saves *and* destroys. 94 | 95 | Migration / First-time installation 96 | ----------------------------------- 97 | Once you've added your alize configuration you'll need to populate your new fields with data. Here's what a typical migration looks like for one model: 98 | 99 | ``` ruby 100 | User.all.each do |user| 101 | user.force_denormalization = true 102 | user.save! 103 | end 104 | ``` 105 | 106 | Assuming User is the model w/ denormalized relations, this will iterate over your users and cause alize to denormalize data from the relations you have specified. Because the user's relations have not "changed" (in the ActiveModel attributes sense) the `force_denormalization` flag is needed. 107 | 108 | ### Migrating from mongoid_denormalize 109 | 110 | Here's a simple example on how to migrate from another denormalization framework. This code moves the `user_name` field 111 | to `user_fields[name]`. 112 | 113 | ``` ruby 114 | for post in posts 115 | post.set(:user_fields, :name => post["user_name"]) 116 | post.unset(:user_name) 117 | end 118 | ``` 119 | 120 | There's one caveat: If you happened to denormalize an `ObjectId` from another object as a String, you need to convert it to the correct type during the migration. (Thanks [@krismartin](https://github.com/krismartin)!) 121 | 122 | ``` ruby 123 | object.set(:post_fields, :user_id => Moped::BSON::ObjectId(post["user_id"])) 124 | ``` 125 | 126 | Advanced Usage 127 | -------------- 128 | Callbacks are created as instance methods on the model (in the first example above, these would be `denormalize_from_user` on `Post` and `denormalize_to_posts` on `User`. You can override these to extend behavior. To call the original from your override, simply append a `_` to the front of the method name, so `denormalize_from_user` becomes `_denormalize_from_user`. 129 | 130 | This is ideal for say, doing denormalization in the background. The traditional Delayed::Jobs-like approach would be this: 131 | 132 | ``` ruby 133 | def denormalize_from_user 134 | _denormalize_from_user 135 | end 136 | handle_asynchronously :denormalize_from_user 137 | ``` 138 | 139 | (Note: This extra business is needed because it's not always predictable when denormalize methods get defined by a class since callback method definitions can be defined from the inverse side.) 140 | 141 | `default_alize_fields` is the method used to generate the denormalization field list when no fields are passed to `alize`. Override to set an alternative field list for your model. 142 | 143 | Examples and specs 144 | ------------------ 145 | Check out [spec/mongoid_alize_spec.rb](https://github.com/dzello/mongoid_alize/blob/master/spec/mongoid_alize_spec.rb) to see working examples across all types of relations. 146 | 147 | Changelog 148 | --------- 149 | ### Release 0.6.0 150 | June 2018 - Now supporting up to Mongoid 6.4. Thanks to [@joe1chen](https://github.com/joe1chen) for the contribution that made this possible! 151 | 152 | ### Release 0.5.0 153 | Now supporting Mongoid 5. 154 | 155 | ### Release 0.4.2 156 | Several issues and pull requests fixed. Thanks [johnnyshields](https://github.com/johnnyshields)! 157 | 158 | ### Release 0.4.0 159 | Now supporting Mongoid 3. 160 | 161 | ### Release 0.3.0 162 | 163 | #### Unifying how data is stored 164 | 165 | mongoid_alize 0.3.0 is imcompatible with previous versions for one-to-one relations. Previous versions defined fields of the form `%{relation}_%{field_name}`, e.g. `post_username` to store the username from post. This caused the implementation of one-to-one and one-to-many relations to be quite different, and it made handling polymorphic associations infeasible because fields are different for each related model. There are several other reasons why this setup wasn't optimal: data types for one-to-ones had to be considered up-front, and creating distinct groups of denormalized fields based on the same relation (something planned for in the future) wouldn't be possible. Last but not least, this makes the eventual handling of this JSON by a client more symmetrical (e.g. my code to instantiate nested Backbone.js models from denormalized data became much more concise). 166 | 167 | The bottom line is that it all works the same now. If you're doing a one-to-one from a `user` relation, the denormalized data is stored as a Hash in a `user_fields`. If you are doing a many-to-one, it's still `user_fields` - but as an Array. And if it's polymorphic in either case, it's still `user_fields`, but the fields stored might be different each time. 168 | 169 | #### Polymorphic support 170 | 171 | Polymorphic relations are supported. That said, there are two things to be aware of. 172 | 173 | One is the natural limitation of the `alize` macro when it comes to polymorphic relations - the Class of the object stored by the relation is known only at runtime. So, when you specify `alize` on the polymorphic side (the side with the `:polymorphic => true` argument to the relation), `alize` cannot apply the to-side macro automatically - it doesn't know how to find the inverse(s). To still get to-side behavior, you'll need to add the `alize_to` macro for any class/relation that can be an inverse (i.e. any relation that uses the `:as => :something` parameter to the relation definition.) 174 | 175 | The second challenge is that the fields to denormalize will likely be different on per-inverse basis. Perhaps your `:addressable` relation can store both homes and offices but needs to store different fields for each (e.g. offices have a company name, and homes belong to owners). This can be accomplished by passing a proc to the `:fields` option key when defining the relation. The block will be passed the model instance in question: 176 | 177 | ``` ruby 178 | alize :addressable, :fields => lambda { |addressable| 179 | if addressable.is_a?(Home) 180 | [:owner_name] 181 | elsif addressable.is_a?(Office) 182 | [:company_name] 183 | end 184 | } 185 | ``` 186 | 187 | Protip - In practice, rather than doing ugly type checking, I implement a method on any class that can be addressable that returns a list of fields: 188 | 189 | ``` ruby 190 | class Home 191 | def alize_fields_for_addressable 192 | [:owner_name] 193 | end 194 | end 195 | 196 | class Office 197 | def alize_fields_for_addressable 198 | [:company_name] 199 | end 200 | end 201 | 202 | alize :addressable, :fields => lambda { |addressable| addressable.alize_fields_for_addressable } 203 | ``` 204 | 205 | Note the fields option is valid for anything you alize. 206 | 207 | ### denormalize_from_all and denormalize_to_all hooks 208 | Each class where `Mongoid::Alize` is included has two new methods - `denormalize_from_all` and `denormalize_to_all`. These methods run all of the alize callbacks (in the appropriate direction) for that model. 209 | 210 | This comes in handy when you want to trigger denormalization without going through the save callback cycle. Keep in mind that denormalize_from methods do not automatically persist the data that's updated in the model (b/c they're traditionally used in a before save). So if you call `denormalize_from_all` you'll need to handle persistance yourself - usually through atomic mongoid operations like `set`. 211 | 212 | Protip: If you need even more flexibility, you now have access to alize's callback metadata in either direction via the class methods `alize_from_callbacks` and `alize_to_callbacks`. Each is an array of `Mongoid::Alize::Callback` objects. 213 | 214 | Protip #2: Make sure to pair with the `force_denormalization` attr if you want all callbacks to skip dirty checking (appropriate for batch updates, sync-ing stale data, etc) 215 | 216 | Protip #3: I use this to fire `to` denormalizations after `to` denormalizations (and this will be the default behavior soon). If you are denormalizing denormalized data (meta, I know) you can use this to make sure updates to a model trigger denormalization to *it's* model's. 217 | 218 | ### Speed 219 | One-to-one performance is dramatically improved. Updating all fields is accomplished via one `set` operation. 220 | 221 | #### Misc 0.3.0 updates 222 | - `alize_to` and `alize_from` are available separately if you only want one type of behavior for a relation. `alize` still does both (except for polymorphic relations, in which case it acts as `alize_from`) 223 | - You can pass a `:fields` proc to any `alize` to dynamically determine stores fields at the instance level. 224 | 225 | #### Upgrading 226 | You'll need to rewrite the parts of your application that use one-to-one denormalization. Instead of finding data in a `post_title` field, you'll be looking in `post_fields["title"]`. 227 | After updating your code, re-denormalize your data with 0.3.0 installed (loop through objects and call save with the `force_denormalization` attr set to true). 228 | 229 | #### Will the API keep changing? 230 | It's my intent to follow the [Semantic Versioning Spec](http://semver.org). So until 1.0, it's possible that breaking changes may be introduced. I'll do my best to outline the changes each time and give advice on how to respond to changes. The goal is to get to 1.0 as quickly as possible, but there is still some real-world mileage to cover. 231 | 232 | 233 | Tests / Contributing 234 | ------------- 235 | The Gemfile has all you need to run the tests (w/ some extras like Guard and debugger). To run the specs: 236 | 237 | ``` shell 238 | bundle install 239 | bundle exec rspec spec 240 | ``` 241 | 242 | Contributions and bug reports are welcome. 243 | 244 | Todos/Coming Soon 245 | ----------------- 246 | + Performance improvements 247 | + Your feature requests and issues! 248 | 249 | Credits / License 250 | ------- 251 | Mongoid::Alize - Copyright (c) 2012 Josh Dzielak 252 | MIT License 253 | 254 | A big thanks to Durran Jordan for creating [Mongoid](http://mongoid.org). 255 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | mongoid: 3 | errors: 4 | messages: 5 | alize: 6 | invalid_field: 7 | "%{name} does not exist on the %{inverse_klass} model." 8 | already_defined_field: 9 | "%{name} is already defined on the %{klass} model." 10 | invalid_configuration: 11 | 12 | -------------------------------------------------------------------------------- /lib/mongoid/alize/callback.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Alize 3 | class Callback 4 | 5 | attr_accessor :denorm_attrs 6 | 7 | attr_accessor :klass 8 | attr_accessor :relation 9 | attr_accessor :metadata 10 | 11 | attr_accessor :inverse_klass 12 | attr_accessor :inverse_relation 13 | attr_accessor :inverse_metadata 14 | 15 | attr_accessor :debug 16 | 17 | def initialize(_klass, _relation, _denorm_attrs) 18 | self.debug = ENV["ALIZE_DEBUG"] 19 | 20 | self.klass = _klass 21 | self.relation = _relation 22 | self.denorm_attrs = _denorm_attrs 23 | 24 | self.metadata = _klass.relations[_relation.to_s] 25 | if !(self.metadata.polymorphic? && 26 | self.metadata.stores_foreign_key?) 27 | self.inverse_klass = self.metadata.klass 28 | self.inverse_relation = self.metadata.inverse 29 | self.inverse_metadata = self.inverse_klass.relations[inverse_relation.to_s] 30 | end 31 | end 32 | 33 | def attach 34 | # implement in subclasses 35 | end 36 | 37 | def callback_attached?(callback_type, callback_name) 38 | callbacks = klass.send(:"_#{callback_type}_callbacks") 39 | filters = callbacks.map {|callback| callback.respond_to?(:raw_filter) ? callback.raw_filter : callback.filter} 40 | !!filters.include?(callback_name) 41 | end 42 | 43 | def callback_defined?(callback_name) 44 | klass.method_defined?(callback_name) 45 | end 46 | 47 | def alias_callback 48 | unless callback_defined?(aliased_callback_name) 49 | klass.send(:alias_method, aliased_callback_name, callback_name) 50 | klass.send(:public, aliased_callback_name) 51 | end 52 | end 53 | 54 | def callback_name 55 | :"_#{aliased_callback_name}" 56 | end 57 | 58 | def aliased_callback_name 59 | :"denormalize_#{direction}_#{relation}" 60 | end 61 | 62 | def define_denorm_attrs 63 | _denorm_attrs = denorm_attrs 64 | if denorm_attrs.is_a?(Proc) 65 | klass.send(:define_method, denorm_attrs_name) do |inverse| 66 | self.instance_exec(inverse, &_denorm_attrs).map(&:to_s) 67 | end 68 | else 69 | klass.send(:define_method, denorm_attrs_name) do |inverse| 70 | _denorm_attrs.map(&:to_s) 71 | end 72 | end 73 | end 74 | 75 | def denorm_attrs_name 76 | :"#{callback_name}_attrs" 77 | end 78 | 79 | def field_values(source, options={}) 80 | extras = options[:id] ? "['_id']" : "[]" 81 | <<-RUBY 82 | value = (#{denorm_attrs_name}(#{source}) + #{extras}).inject({}) do |hash, name| 83 | hash[name] = #{source}.send(name) 84 | hash 85 | end 86 | value.respond_to?(:mongoize) ? value.mongoize : value 87 | RUBY 88 | end 89 | 90 | def force_param 91 | "(force=false)" 92 | end 93 | 94 | def force_check 95 | "force || self.force_denormalization" 96 | end 97 | 98 | def fields_to_s 99 | if fields.is_a?(Proc) 100 | "Proc Given" 101 | else 102 | fields.join(", ") 103 | end 104 | end 105 | 106 | def to_s 107 | "#{self.class.name}" + 108 | "\nModel: #{self.klass}, Relation: #{self.relation}" + (self.metadata.polymorphic? ? 109 | "\nPolymorphic" : 110 | "\nInverse: #{self.inverse_klass}, Relation: #{self.inverse_relation}") + 111 | "\nFields: #{fields_to_s}" 112 | end 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/mongoid/alize/callbacks/from/many.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Alize 3 | module Callbacks 4 | module From 5 | class Many < FromCallback 6 | 7 | protected 8 | 9 | def define_callback 10 | klass.class_eval <<-CALLBACK, __FILE__, __LINE__ + 1 11 | def #{callback_name}#{force_param} 12 | self.#{prefixed_name} = self.#{relation}.map do |relation| 13 | #{field_values("relation", :id => true)} 14 | end 15 | true 16 | end 17 | 18 | protected :#{callback_name} 19 | CALLBACK 20 | end 21 | 22 | def define_mongoid_field 23 | ensure_field_not_defined!(prefixed_name, klass) 24 | klass.class_eval <<-CALLBACK, __FILE__, __LINE__ + 1 25 | field :#{prefixed_name}, :type => Array, :default => [] 26 | CALLBACK 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/mongoid/alize/callbacks/from/one.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Alize 3 | module Callbacks 4 | module From 5 | class One < FromCallback 6 | 7 | protected 8 | 9 | def define_callback 10 | klass.class_eval <<-CALLBACK, __FILE__, __LINE__ + 1 11 | def #{callback_name}#{force_param} 12 | if #{force_check} || 13 | #{!metadata.stores_foreign_key?} || 14 | self.#{metadata.key}_changed? 15 | 16 | if relation = self.#{relation} 17 | self.#{self.prefixed_name} = #{field_values("relation")} 18 | else 19 | self.#{self.prefixed_name} = nil 20 | end 21 | 22 | end 23 | true 24 | end 25 | 26 | protected :#{callback_name} 27 | CALLBACK 28 | end 29 | 30 | def define_mongoid_field 31 | ensure_field_not_defined!(prefixed_name, klass) 32 | klass.class_eval <<-CALLBACK, __FILE__, __LINE__ + 1 33 | field :#{prefixed_name}, :type => Hash, :default => {} 34 | CALLBACK 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/mongoid/alize/errors/alize_error.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Alize 3 | module Errors 4 | 5 | class AlizeError < Mongoid::Errors::MongoidError 6 | def translate(key, data={}) 7 | super("alize.#{key}", data) 8 | end 9 | end 10 | 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/mongoid/alize/errors/already_defined_field.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Alize 3 | module Errors 4 | 5 | class AlreadyDefinedField < AlizeError 6 | def initialize(name, klass) 7 | super( 8 | translate("already_defined_field", { :name => name, :klass => klass }) 9 | ) 10 | end 11 | end 12 | 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/mongoid/alize/errors/invalid_configuration.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Alize 3 | module Errors 4 | 5 | class InvalidConfiguration < AlizeError 6 | def initialize(reason, klass, relation) 7 | super( 8 | translate("invalid_configuration.#{reason}", 9 | { :klass => klass, :relation => relation }) 10 | ) 11 | end 12 | end 13 | 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/mongoid/alize/errors/invalid_field.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Alize 3 | module Errors 4 | 5 | class InvalidField < AlizeError 6 | def initialize(name, inverse_klass) 7 | super( 8 | translate("invalid_field", { :name => name, :inverse_klass => inverse_klass }) 9 | ) 10 | end 11 | end 12 | 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/mongoid/alize/from_callback.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Alize 3 | class FromCallback < Callback 4 | 5 | def attach 6 | define_mongoid_field 7 | define_denorm_attrs 8 | 9 | define_callback 10 | alias_callback 11 | set_callback 12 | end 13 | 14 | def set_callback 15 | unless callback_attached?("save", aliased_callback_name) 16 | klass.set_callback(:save, :before, aliased_callback_name) 17 | end 18 | end 19 | 20 | def ensure_field_not_defined!(prefixed_name, klass) 21 | if field_defined?(prefixed_name, klass) 22 | raise Mongoid::Alize::Errors::AlreadyDefinedField.new(prefixed_name, klass.name) 23 | end 24 | end 25 | 26 | def field_defined?(prefixed_name, klass) 27 | !!klass.fields[prefixed_name] 28 | end 29 | 30 | def prefixed_name 31 | "#{relation}_fields" 32 | end 33 | 34 | def direction 35 | "from" 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/mongoid/alize/instance_helpers.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Alize 3 | module InstanceHelpers 4 | 5 | attr_accessor :force_denormalization 6 | 7 | def denormalize_from_all 8 | run_alize_callbacks(self.class.alize_from_callbacks) 9 | end 10 | 11 | def denormalize_to_all 12 | run_alize_callbacks(self.class.alize_to_callbacks) 13 | end 14 | 15 | private 16 | 17 | def run_alize_callbacks(callbacks) 18 | callbacks.each do |callback| 19 | self.send(callback.aliased_callback_name) 20 | end 21 | end 22 | 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/mongoid/alize/macros.rb: -------------------------------------------------------------------------------- 1 | module Mongoid 2 | module Alize 3 | module Macros 4 | 5 | attr_accessor :alize_from_callbacks, :alize_to_callbacks 6 | 7 | def alize(relation, *fields) 8 | alize_from(relation, *fields) 9 | metadata = self.relations[relation.to_s] 10 | unless _alize_unknown_inverse?(metadata) 11 | metadata.klass.alize_to(metadata.inverse, *fields) 12 | end 13 | end 14 | 15 | def alize_from(relation, *fields) 16 | one, many = _alize_relation_types 17 | 18 | from_one = Mongoid::Alize::Callbacks::From::One 19 | from_many = Mongoid::Alize::Callbacks::From::Many 20 | 21 | klass = self 22 | metadata = klass.relations[relation.to_s] 23 | relation_superclass = metadata.relation.superclass 24 | 25 | callback_klass = 26 | case [relation_superclass] 27 | when [one] then from_one 28 | when [many] then from_many 29 | end 30 | 31 | options = fields.extract_options! 32 | if options[:fields] 33 | fields = options[:fields] 34 | elsif fields.empty? && !_alize_unknown_inverse?(metadata) 35 | fields = metadata.klass.default_alize_fields 36 | end 37 | 38 | (klass.alize_from_callbacks ||= []) << callback = 39 | callback_klass.new(klass, relation, fields) 40 | callback.attach 41 | 42 | end 43 | 44 | def alize_to(relation, *fields) 45 | one, many = _alize_relation_types 46 | 47 | klass = self 48 | metadata = klass.relations[relation.to_s] 49 | relation_superclass = metadata.relation.superclass 50 | 51 | options = fields.extract_options! 52 | if options[:fields] 53 | fields = options[:fields] 54 | elsif fields.empty? 55 | fields = klass.default_alize_fields 56 | end 57 | 58 | (klass.alize_to_callbacks ||= []) << callback = 59 | Mongoid::Alize::ToCallback.new(klass, relation, fields) 60 | callback.attach 61 | 62 | end 63 | 64 | def default_alize_fields 65 | self.fields.reject { |name, field| 66 | name =~ /^_/ 67 | }.keys 68 | end 69 | 70 | private 71 | 72 | def _alize_unknown_inverse?(metadata) 73 | (metadata.polymorphic? && metadata.stores_foreign_key?) || 74 | metadata.klass.nil? || 75 | metadata.inverse.nil? 76 | end 77 | 78 | def _alize_relation_types 79 | if Mongoid::Compatibility::Version.mongoid7_or_newer? 80 | one = Mongoid::Association::One 81 | many = Mongoid::Association::Many 82 | else 83 | one = Mongoid::Relations::One 84 | many = Mongoid::Relations::Many 85 | end 86 | 87 | def (many).==(klass) 88 | if Mongoid::Compatibility::Version.mongoid7_or_newer? 89 | [Mongoid::Association::Many, 90 | Mongoid::Association::Referenced::HasMany::Proxy].map(&:name).include?(klass.name) 91 | else 92 | [Mongoid::Relations::Many, 93 | Mongoid::Relations::Referenced::Many].map(&:name).include?(klass.name) 94 | end 95 | end 96 | 97 | [one, many] 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/mongoid/alize/to_callback.rb: -------------------------------------------------------------------------------- 1 | require 'mongoid/compatibility' 2 | 3 | module Mongoid 4 | module Alize 5 | class ToCallback < Callback 6 | 7 | def attach 8 | define_denorm_attrs 9 | 10 | define_callback 11 | alias_callback 12 | set_callback 13 | 14 | define_destroy_callback 15 | alias_destroy_callback 16 | set_destroy_callback 17 | end 18 | 19 | def define_callback 20 | klass.class_eval <<-CALLBACK, __FILE__, __LINE__ + 1 21 | 22 | def #{callback_name}#{force_param} 23 | 24 | #{iterable_relation}.each do |relation| 25 | next if relation.attributes.frozen? 26 | 27 | is_one = #{is_one?} 28 | if is_one 29 | field_values = #{field_values("self")} 30 | else 31 | field_values = #{field_values("self", :id => true)} 32 | end 33 | 34 | prefixed_name = #{prefixed_name} 35 | if is_one 36 | #{relation_set('prefixed_name', 'field_values')} 37 | else 38 | #{pull_from_inverse} 39 | #{relation_push('prefixed_name', 'field_values')} 40 | end 41 | 42 | end 43 | 44 | #{debug ? "puts \"#{callback_name}\"": ""} 45 | true 46 | end 47 | protected :#{callback_name} 48 | CALLBACK 49 | end 50 | 51 | def define_destroy_callback 52 | klass.class_eval <<-CALLBACK, __FILE__, __LINE__ + 1 53 | 54 | def #{destroy_callback_name} 55 | #{iterable_relation}.each do |relation| 56 | next if relation.attributes.frozen? 57 | 58 | is_one = #{is_one?} 59 | prefixed_name = #{prefixed_name} 60 | if is_one 61 | #{relation_unset('prefixed_name')} 62 | else 63 | #{pull_from_inverse} 64 | end 65 | end 66 | 67 | #{debug ? "puts \"#{destroy_callback_name}\"": ""} 68 | true 69 | end 70 | protected :#{destroy_callback_name} 71 | CALLBACK 72 | end 73 | 74 | def pull_from_inverse 75 | <<-RUBIES 76 | #{relation_pull('prefixed_name', '{ "_id" => self.id }')} 77 | if _f = relation.send(prefixed_name) 78 | _f.reject! do |hash| 79 | hash["_id"] == self.id 80 | end 81 | end 82 | RUBIES 83 | end 84 | 85 | def prefixed_name 86 | if inverse_relation 87 | ":#{inverse_relation}_fields" 88 | else 89 | <<-RUBIES 90 | (#{find_relation}.name.to_s + '_fields') 91 | RUBIES 92 | end 93 | end 94 | 95 | def relation_set(field, value) 96 | Mongoid::Compatibility::Version.mongoid4_or_newer? ? "relation.set(#{field}.to_sym => #{value})" : "relation.set(#{field}, #{value})" 97 | end 98 | 99 | def relation_unset(field) 100 | "relation.unset(#{field}.to_sym)" 101 | end 102 | 103 | def relation_pull(field, value) 104 | Mongoid::Compatibility::Version.mongoid4_or_newer? ? "relation.pull(#{field}.to_sym => #{value})" : "relation.pull(#{field}, #{value})" 105 | end 106 | 107 | def relation_push(field, value) 108 | Mongoid::Compatibility::Version.mongoid4_or_newer? ? "relation.push(#{field}.to_sym => #{value})" : "relation.push(#{field}, #{value})" 109 | end 110 | 111 | def is_one? 112 | if Mongoid::Compatibility::Version.mongoid7_or_newer? 113 | if inverse_relation 114 | if self.inverse_metadata.relation.superclass == Mongoid::Association::One 115 | "true" 116 | else 117 | "false" 118 | end 119 | else 120 | <<-RUBIES 121 | (#{find_relation}.relation.superclass == Mongoid::Association::One) 122 | RUBIES 123 | end 124 | else 125 | if inverse_relation 126 | if self.inverse_metadata.relation.superclass == Mongoid::Relations::One 127 | "true" 128 | else 129 | "false" 130 | end 131 | else 132 | <<-RUBIES 133 | (#{find_relation}.relation.superclass == Mongoid::Relations::One) 134 | RUBIES 135 | end 136 | end 137 | end 138 | 139 | def find_relation 140 | "relation.class.relations.values.find { |metadata| metadata.inverse(self) == :#{relation} && metadata.class_name == self.class.name }" 141 | end 142 | 143 | def iterable_relation 144 | "[self.#{relation}].flatten.compact" 145 | end 146 | 147 | def set_callback 148 | unless callback_attached?("save", aliased_callback_name) 149 | klass.set_callback(:save, :after, aliased_callback_name) 150 | end 151 | end 152 | 153 | def set_destroy_callback 154 | unless callback_attached?("destroy", aliased_destroy_callback_name) 155 | klass.set_callback(:destroy, :after, aliased_destroy_callback_name) 156 | end 157 | end 158 | 159 | def alias_destroy_callback 160 | unless callback_defined?(aliased_destroy_callback_name) 161 | klass.send(:alias_method, aliased_destroy_callback_name, destroy_callback_name) 162 | klass.send(:public, aliased_destroy_callback_name) 163 | end 164 | end 165 | 166 | def aliased_destroy_callback_name 167 | :"denormalize_destroy_#{direction}_#{relation}" 168 | end 169 | 170 | def destroy_callback_name 171 | :"_#{aliased_destroy_callback_name}" 172 | end 173 | 174 | def direction 175 | "to" 176 | end 177 | 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /lib/mongoid_alize.rb: -------------------------------------------------------------------------------- 1 | require 'mongoid/alize/errors/alize_error' 2 | require 'mongoid/alize/errors/invalid_field' 3 | require 'mongoid/alize/errors/already_defined_field' 4 | require 'mongoid/alize/errors/invalid_configuration' 5 | 6 | require 'mongoid/alize/callback' 7 | 8 | require 'mongoid/alize/from_callback.rb' 9 | require 'mongoid/alize/to_callback.rb' 10 | 11 | require 'mongoid/alize/callbacks/from/one.rb' 12 | require 'mongoid/alize/callbacks/from/many.rb' 13 | 14 | require 'mongoid/alize/macros' 15 | require 'mongoid/alize/instance_helpers' 16 | 17 | I18n.load_path << File.join(File.dirname(__FILE__), "..", "config", "locales", "en.yml") 18 | 19 | module Mongoid 20 | module Alize 21 | extend ActiveSupport::Concern 22 | 23 | included do 24 | extend Mongoid::Alize::Macros 25 | include Mongoid::Alize::InstanceHelpers 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /mongoid_alize.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "mongoid_alize" 3 | s.version = "0.6.0" 4 | s.author = "Josh Dzielak" 5 | s.email = "jdzielak@gmail.com" 6 | s.homepage = "https://github.com/dzello/mongoid_alize" 7 | s.summary = "Comprehensive field denormalization for Mongoid that stays in sync." 8 | s.description = "Keep data in sync as you denormalize across any type of relation." 9 | s.license = "MIT" 10 | 11 | s.files = Dir["{config,lib,spec}/**/*"] - ["Gemfile.lock"] 12 | s.require_path = "lib" 13 | 14 | s.add_dependency 'mongoid', ">= 2.4" 15 | s.add_dependency "mongoid-compatibility" 16 | s.add_development_dependency 'rspec', '~> 2.6.0' 17 | end 18 | -------------------------------------------------------------------------------- /spec/app/models/head.rb: -------------------------------------------------------------------------------- 1 | class Head 2 | include Mongoid::Document 3 | include Mongoid::Alize 4 | 5 | if Mongoid::Compatibility::Version.mongoid4_or_newer? 6 | include Mongoid::Attributes::Dynamic 7 | end 8 | 9 | field :size, type: Integer 10 | field :weight 11 | 12 | if Mongoid::Compatibility::Version.mongoid7_or_newer? 13 | # to whom it's attached 14 | belongs_to :person, :inverse_of => :head, optional: true 15 | 16 | # in whose possession it is 17 | belongs_to :captor, :class_name => "Person", :inverse_of => :heads, optional: true 18 | elsif Mongoid::Compatibility::Version.mongoid6_or_newer? 19 | # to whom it's attached 20 | belongs_to :person, optional: true 21 | 22 | # in whose possession it is 23 | belongs_to :captor, :class_name => "Person", :inverse_of => :heads, optional: true 24 | else 25 | # to whom it's attached 26 | belongs_to :person 27 | 28 | # in whose possession it is 29 | belongs_to :captor, :class_name => "Person", :inverse_of => :heads 30 | end 31 | 32 | # who'd otherwise like to possess it 33 | has_and_belongs_to_many :wanted_by, :class_name => "Person", :inverse_of => :wants 34 | 35 | # who it sees 36 | has_many :sees, :class_name => "Person", :inverse_of => :seen_by 37 | 38 | # a relation with no inverse 39 | has_many :admirer, :class_name => "Person", :inverse_of => nil 40 | 41 | if Mongoid::Compatibility::Version.mongoid6_or_newer? 42 | # a polymorphic one-to-one relation 43 | belongs_to :nearest, :polymorphic => true, optional: true 44 | else 45 | # a polymorphic one-to-one relation 46 | belongs_to :nearest, :polymorphic => true 47 | end 48 | 49 | # a polymorphic one-to-many relation 50 | has_many :below_people, :class_name => "Person", :as => :above 51 | 52 | def density 53 | "low" 54 | end 55 | 56 | # example of one way to handling attribute selection 57 | # for polymorphic associations or generally using the proc fields option 58 | def alize_fields(inverse) 59 | if inverse.is_a?(Person) 60 | [:name, :location] 61 | else 62 | [:id] 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/app/models/mock_object.rb: -------------------------------------------------------------------------------- 1 | # explicit mock object class due to this issue - https://github.com/btakita/rr/issues/44 2 | class MockObject 3 | def to_ary 4 | nil 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/app/models/person.rb: -------------------------------------------------------------------------------- 1 | class Person 2 | include Mongoid::Document 3 | include Mongoid::Alize 4 | 5 | if Mongoid::Compatibility::Version.mongoid4_or_newer? 6 | include Mongoid::Attributes::Dynamic 7 | end 8 | 9 | field :name, type: String 10 | field :created_at, type: Time 11 | 12 | if Mongoid::Compatibility::Version.mongoid4_or_newer? 13 | field :my_date, type: Date 14 | field :my_datetime, type: DateTime 15 | end 16 | 17 | # the attached head 18 | if Mongoid::Compatibility::Version.mongoid7_or_newer? 19 | has_one :head, :inverse_of => :person 20 | else 21 | has_one :head 22 | end 23 | 24 | # the heads taken from others 25 | has_many :heads, :class_name => "Head", :inverse_of => :captor 26 | 27 | # the heads wanted from others 28 | has_and_belongs_to_many :wants, :class_name => "Head", :inverse_of => :wanted_by 29 | 30 | if Mongoid::Compatibility::Version.mongoid6_or_newer? 31 | # the only head that is watching 32 | belongs_to :seen_by, :class_name => "Head", :inverse_of => :sees, optional: true 33 | else 34 | # the only head that is watching 35 | belongs_to :seen_by, :class_name => "Head", :inverse_of => :sees 36 | end 37 | 38 | # a polymorphic one-to-one relation 39 | has_one :nearest_head, :class_name => "Head", :as => :nearest 40 | 41 | if Mongoid::Compatibility::Version.mongoid6_or_newer? 42 | # a polymorphic one-to-many relation 43 | belongs_to :above, :polymorphic => true, optional: true 44 | else 45 | # a polymorphic one-to-many relation 46 | belongs_to :above, :polymorphic => true 47 | end 48 | 49 | def location 50 | "Paris" 51 | end 52 | 53 | # example of one way to handling attribute selection 54 | # for polymorphic associations or generally using the proc fields option 55 | def alize_fields(inverse) 56 | if inverse.is_a?(Head) 57 | [:size] 58 | else 59 | [:id] 60 | end 61 | end 62 | end 63 | 64 | -------------------------------------------------------------------------------- /spec/helpers/macros_helper.rb: -------------------------------------------------------------------------------- 1 | module MacrosHelper 2 | def self.included(base) 3 | base.extend ClassMethods 4 | end 5 | 6 | module ClassMethods 7 | def fns 8 | Mongoid::Alize::Callbacks::From 9 | end 10 | 11 | def tns 12 | Mongoid::Alize::ToCallback 13 | end 14 | 15 | def it_should_set_callbacks(klass, inverse_klass, relation, inverse_relation, fns, tns) 16 | fields = [:fake] 17 | 18 | it "should use #{fns} to pull" do 19 | obj_mock = MockObject.new 20 | obj_stub = MockObject.new 21 | 22 | stub(tns).new { obj_stub } 23 | stub(obj_stub).attach 24 | stub(inverse_klass) 25 | 26 | mock(fns).new(klass, relation, fields) { obj_mock } 27 | mock(obj_mock).attach 28 | klass.send(:alize_from, relation, *fields) 29 | 30 | klass.alize_from_callbacks.should == [obj_mock] 31 | end 32 | 33 | it "should use #{tns} to push" do 34 | obj_stub = MockObject.new 35 | obj_mock = MockObject.new 36 | 37 | stub(fns).new { obj_stub } 38 | stub(obj_stub).attach 39 | stub(klass).set_callback 40 | 41 | mock(tns).new(inverse_klass, inverse_relation, fields) { obj_mock } 42 | mock(obj_mock).attach 43 | inverse_klass.send(:alize_to, inverse_relation, *fields) 44 | 45 | inverse_klass.alize_to_callbacks.should == [obj_mock] 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/mongoid/alize/callback_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class Mongoid::Alize::SpecCallback < Mongoid::Alize::Callback 4 | def attach 5 | klass.class_eval do 6 | def denormalize_spec_person 7 | end 8 | end 9 | end 10 | 11 | def direction 12 | "spec" 13 | end 14 | end 15 | 16 | describe Mongoid::Alize::Callback do 17 | def klass 18 | Mongoid::Alize::SpecCallback 19 | end 20 | 21 | def args 22 | [Head, :person, [:name, :created_at]] 23 | end 24 | 25 | def new_callback 26 | klass.new(*args) 27 | end 28 | 29 | describe "initialize" do 30 | it "should assign class attributes" do 31 | callback = new_callback 32 | callback.klass.should == Head 33 | callback.relation.should == :person 34 | callback.inverse_klass = Person 35 | callback.inverse_relation = :head 36 | callback.inverse_metadata.should == Person.relations["head"] 37 | callback.denorm_attrs.should == [:name, :created_at] 38 | end 39 | 40 | it "should not set inverses for the child in a polymorphic association" do 41 | callback = klass.new(Head, :nearest, [:size]) 42 | callback.inverse_klass.should be_nil 43 | callback.inverse_relation.should be_nil 44 | end 45 | 46 | it "should set inverses for the parent in a polymorphic association" do 47 | callback = klass.new(Person, :nearest_head, [:size]) 48 | callback.inverse_klass.should == Head 49 | callback.inverse_relation.should == :nearest 50 | end 51 | end 52 | 53 | describe "with callback" do 54 | before do 55 | @callback = new_callback 56 | end 57 | 58 | describe "#alias_callback" do 59 | it "should alias the callback on the klass and make it public" do 60 | mock(@callback.klass).alias_method(:denormalize_spec_person, :_denormalize_spec_person) 61 | mock(@callback.klass).public(:denormalize_spec_person) 62 | @callback.send(:alias_callback) 63 | end 64 | 65 | it "should not alias the callback if it's already set" do 66 | @callback.send(:attach) 67 | dont_allow(@callback.klass).alias_method 68 | @callback.send(:alias_callback) 69 | end 70 | end 71 | end 72 | 73 | describe "name helpers" do 74 | before do 75 | @callback = new_callback 76 | end 77 | 78 | it "should have a callback name" do 79 | @callback.callback_name.should == :_denormalize_spec_person 80 | end 81 | 82 | it "should have aliased callback name" do 83 | @callback.aliased_callback_name.should == :denormalize_spec_person 84 | end 85 | 86 | it "should add _attrs to the callback name" do 87 | @callback.denorm_attrs_name.should == :_denormalize_spec_person_attrs 88 | end 89 | end 90 | 91 | describe "#define_denorm_attrs" do 92 | def define_denorm_attrs 93 | @callback.send(:define_denorm_attrs) 94 | end 95 | 96 | describe "when denorm_attrs is an array" do 97 | before do 98 | @callback = new_callback 99 | end 100 | 101 | it "should return the denorm_attrs w/ to_s applied" do 102 | define_denorm_attrs 103 | @head = Head.new 104 | @head.send(:_denormalize_spec_person_attrs, nil).should == ["name", "created_at"] 105 | end 106 | end 107 | 108 | describe "when denorm_attrs is a proc" do 109 | before do 110 | @callback = klass.new(Head, :person, lambda { |inverse| [:name, :created_at] }) 111 | end 112 | 113 | it "should return the denorm_attrs w/ to_s applied" do 114 | define_denorm_attrs 115 | @head = Head.new 116 | @head.send(:_denormalize_spec_person_attrs, Person.new).should == ["name", "created_at"] 117 | end 118 | end 119 | end 120 | end 121 | 122 | -------------------------------------------------------------------------------- /spec/mongoid/alize/callbacks/from/many_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Alize::Callbacks::From::Many do 4 | def klass 5 | Mongoid::Alize::Callbacks::From::Many 6 | end 7 | 8 | def args 9 | [Head, :wanted_by, [:name, :location, :created_at]] 10 | end 11 | 12 | def new_callback 13 | klass.new(*args) 14 | end 15 | 16 | describe "#define_mongoid_field" do 17 | it "should define an Array called {relation}_fields" do 18 | callback = new_callback 19 | callback.send(:define_mongoid_field) 20 | Head.fields["wanted_by_fields"].type.should == Array 21 | end 22 | 23 | it "should default the field to empty" do 24 | callback = new_callback 25 | callback.send(:define_mongoid_field) 26 | Head.new.wanted_by_fields.should == [] 27 | end 28 | 29 | it "should raise an already defined field error if the field already exists" do 30 | Head.class_eval do 31 | field :wanted_by_fields 32 | end 33 | callback = new_callback 34 | expect { 35 | callback.send(:define_mongoid_field) 36 | }.to raise_error(Mongoid::Alize::Errors::AlreadyDefinedField, 37 | "wanted_by_fields is already defined on the Head model.") 38 | end 39 | end 40 | 41 | describe "the defined callback" do 42 | def run_callback 43 | @head.send(:_denormalize_from_wanted_by) 44 | end 45 | 46 | def bob_fields 47 | { "_id" => @person.id, 48 | "name"=> "Bob", 49 | "location" => "Paris", 50 | "created_at" => @now } 51 | end 52 | 53 | before do 54 | @head = Head.create 55 | @person = Person.create(:name => "Bob") 56 | 57 | @head.relations["wanted_by"].should be_stores_foreign_key 58 | end 59 | 60 | describe "valid fields" do 61 | before do 62 | @callback = new_callback 63 | @callback.send(:define_mongoid_field) 64 | @callback.send(:define_denorm_attrs) 65 | @callback.send(:define_callback) 66 | end 67 | 68 | it "should set fields from a changed relation" do 69 | @head.wanted_by = [@person] 70 | run_callback 71 | @head.wanted_by_fields.should == [bob_fields] 72 | end 73 | 74 | it "should still set fields there are no changes" do 75 | @head.should_not be_wanted_by_ids_changed 76 | mock.proxy(@head).wanted_by 77 | run_callback 78 | end 79 | end 80 | 81 | describe "with a field that doesn't exist" do 82 | before do 83 | @callback = klass.new(Head, :wanted_by, [:notreal]) 84 | @callback.send(:define_mongoid_field) 85 | @callback.send(:define_callback) 86 | end 87 | 88 | it "should raise a no method error" do 89 | @head.wanted_by = [@person] 90 | @head.wanted_by_fields.should be_nil 91 | expect { 92 | run_callback 93 | }.to raise_error NoMethodError 94 | end 95 | end 96 | end 97 | 98 | describe "in a one to many case" do 99 | def run_callback 100 | @head.send(:_denormalize_from_sees) 101 | end 102 | 103 | before do 104 | @head = Head.create 105 | @person = Person.create(:name => "Bob") 106 | 107 | @callback = klass.new(Head, :sees, [:name]) 108 | @callback.send(:define_mongoid_field) 109 | @callback.send(:define_denorm_attrs) 110 | @callback.send(:define_callback) 111 | 112 | @head.relations["sees"].should_not be_stores_foreign_key 113 | end 114 | 115 | it "should field from a changed relation" do 116 | @head.sees << @person 117 | run_callback 118 | @head.sees_fields.should == [{ 119 | "_id" => @person.id, 120 | "name" => "Bob" 121 | }] 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/mongoid/alize/callbacks/from/one_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Alize::Callbacks::From::One do 4 | def klass 5 | Mongoid::Alize::Callbacks::From::One 6 | end 7 | 8 | def args 9 | [Head, :person, [:name, :location, :created_at]] 10 | end 11 | 12 | def new_callback 13 | klass.new(*args) 14 | end 15 | 16 | describe "#define_mongoid_field" do 17 | describe "with an array of fields" do 18 | it "should add a field generated from %{relation}_fields" do 19 | callback = new_callback 20 | callback.send(:define_mongoid_field) 21 | Head.fields["person_fields"].type.should == Hash 22 | end 23 | 24 | it "should default the field to empty" do 25 | callback = new_callback 26 | callback.send(:define_mongoid_field) 27 | Head.new.person_fields.should == {} 28 | end 29 | 30 | it "should raise an already defined field error if the field already exists" do 31 | Head.class_eval do 32 | field :person_fields 33 | end 34 | callback = new_callback 35 | expect { 36 | callback.send(:define_mongoid_field) 37 | }.to raise_error(Mongoid::Alize::Errors::AlreadyDefinedField, 38 | "person_fields is already defined on the Head model.") 39 | end 40 | end 41 | end 42 | 43 | describe "the defined callback" do 44 | def run_callback(force=false) 45 | @head.send(:_denormalize_from_person, force) 46 | end 47 | 48 | def person_fields 49 | { "name"=> "Bob", 50 | "location" => "Paris", 51 | "created_at"=> @now } 52 | end 53 | 54 | def create_models 55 | @head = Head.create 56 | @person = Person.create(:name => @name = "Bob") 57 | end 58 | 59 | before do 60 | @callback = new_callback 61 | @callback.send(:define_mongoid_field) 62 | @callback.send(:define_denorm_attrs) 63 | create_models 64 | @callback.send(:define_callback) 65 | end 66 | 67 | it "should set fields from a changed relation" do 68 | @head.person = @person 69 | @head.should be_person_id_changed 70 | run_callback 71 | @head.person_fields.should == person_fields 72 | end 73 | 74 | it "should set no fields from a nil relation" do 75 | @head.person = @person 76 | @head.save! 77 | @head.person = nil 78 | @head.should be_person_id_changed 79 | run_callback 80 | @head.person_fields.should == nil 81 | end 82 | 83 | it "should not run if the relation has not changed" do 84 | @head.relations["person"].should be_stores_foreign_key 85 | @head.should_not be_person_id_changed 86 | dont_allow(@head).person 87 | run_callback 88 | end 89 | 90 | it "should still run if the relation has not changed but force is passed" do 91 | @head.should_not be_person_id_changed 92 | mock.proxy(@head).person 93 | run_callback(true) 94 | end 95 | 96 | it "should still run if the relation has not changed but force_denormalization is set on the class" do 97 | @head.should_not be_person_id_changed 98 | @head.force_denormalization = true 99 | mock.proxy(@head).person 100 | run_callback 101 | end 102 | end 103 | 104 | describe "the defined callback when denormalizing on the has_one side" do 105 | def run_callback 106 | @person.send(:_denormalize_from_head) 107 | end 108 | 109 | before do 110 | @callback = klass.new(Person, :head, [:size]) 111 | @callback.send(:define_mongoid_field) 112 | @callback.send(:define_denorm_attrs) 113 | 114 | @person = Person.create 115 | @head = Head.create(:size => 5) 116 | 117 | @callback.send(:define_callback) 118 | @person.relations["head"].should_not be_stores_foreign_key 119 | end 120 | 121 | it "should set values from a changed relation" do 122 | @person.head = @head 123 | run_callback 124 | @person.head_fields.should == { 125 | "size" => 5 126 | } 127 | end 128 | 129 | it "should set values from a a nil relation" do 130 | @person.head = @head 131 | @person.save! 132 | @person.head = nil 133 | run_callback 134 | @person.head_fields.should be_nil 135 | end 136 | 137 | it "should run even if the relation has not changed" do 138 | mock.proxy(@person).head 139 | run_callback 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /spec/mongoid/alize/callbacks/to/many_from_many_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Alize::ToCallback do 4 | def klass 5 | Mongoid::Alize::ToCallback 6 | end 7 | 8 | def args 9 | [Person, :wants, [:name, :location, :created_at]] 10 | end 11 | 12 | def new_callback 13 | klass.new(*args) 14 | end 15 | 16 | def wanted_by_fields 17 | { "_id" => @person.id, 18 | "name" => "Bob", 19 | "location" => "Paris", 20 | "created_at" => @now } 21 | end 22 | 23 | def other_wanted_by 24 | { "_id" => "SomeObjectId" } 25 | end 26 | 27 | def create_models 28 | @head = Head.create( 29 | :wanted_by => [@person = Person.create(:name => "Bob")]) 30 | @person.wants = [@head] 31 | end 32 | 33 | before do 34 | Head.class_eval do 35 | field :wanted_by_fields, type: Array, :default => [] 36 | end 37 | end 38 | 39 | describe "#define_callback" do 40 | def run_callback 41 | @person.send(:_denormalize_to_wants) 42 | end 43 | 44 | before do 45 | @callback = new_callback 46 | @callback.send(:define_denorm_attrs) 47 | create_models 48 | @callback.send(:define_callback) 49 | end 50 | 51 | it "should push the fields to the relation" do 52 | @head.wanted_by_fields.should == [] 53 | run_callback 54 | @head.wanted_by_fields.should == [wanted_by_fields] 55 | end 56 | 57 | it "should pull first any existing array entries matching the _id" do 58 | @head.wanted_by_fields = [other_wanted_by] 59 | @head.save! 60 | 61 | run_callback 62 | run_callback 63 | 64 | # to make sure persisted in both DB and updated in memory 65 | @head.wanted_by_fields.should == [other_wanted_by, wanted_by_fields] 66 | @head.reload 67 | @head.wanted_by_fields.should == [other_wanted_by, wanted_by_fields] 68 | end 69 | end 70 | 71 | describe "#define_destroy_callback" do 72 | def run_destroy_callback 73 | @person.send(:_denormalize_destroy_to_wants) 74 | end 75 | 76 | before do 77 | @callback = new_callback 78 | @callback.send(:define_denorm_attrs) 79 | create_models 80 | @callback.send(:define_destroy_callback) 81 | end 82 | 83 | it "should pull first any existing array entries matching the _id" do 84 | @head.wanted_by_fields = [wanted_by_fields, other_wanted_by] 85 | @head.save! 86 | 87 | run_destroy_callback 88 | 89 | # to make sure persisted in both DB and updated in memory 90 | @head.wanted_by_fields.should == [other_wanted_by] 91 | @head.reload 92 | @head.wanted_by_fields.should == [other_wanted_by] 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/mongoid/alize/callbacks/to/many_from_one_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Alize::ToCallback do 4 | def klass 5 | Mongoid::Alize::ToCallback 6 | end 7 | 8 | def new_callback 9 | klass.new(*args) 10 | end 11 | 12 | def define_and_create(callback_name=:define_callback) 13 | @callback = new_callback 14 | @callback.send(:define_denorm_attrs) 15 | create_models 16 | @callback.send(callback_name) 17 | end 18 | 19 | describe "with metadata in advance" do 20 | def create_models 21 | @head = Head.create( 22 | :sees => [@person = Person.create(:name => "Bob")]) 23 | @person.seen_by = @head 24 | end 25 | 26 | def args 27 | [Person, :seen_by, [:name, :location, :created_at]] 28 | end 29 | 30 | def sees_fields 31 | { "_id" => @person.id, 32 | "name"=> "Bob", 33 | "location" => "Paris", 34 | "created_at"=> @now } 35 | end 36 | 37 | def other_see 38 | { "_id" => "SomeObjectId" } 39 | end 40 | 41 | before do 42 | Head.class_eval do 43 | field :sees_fields, :type => Array, :default => [] 44 | end 45 | end 46 | 47 | describe "#define_callback" do 48 | def run_callback 49 | @person.send(:_denormalize_to_seen_by) 50 | end 51 | 52 | before do 53 | define_and_create 54 | end 55 | 56 | it "should push the fields to the relation" do 57 | @head.sees_fields.should == [] 58 | run_callback 59 | @head.sees_fields.should == [sees_fields] 60 | end 61 | 62 | it "should pull first any existing array entries matching the _id" do 63 | @head.sees_fields = [other_see] 64 | @head.save! 65 | 66 | run_callback 67 | run_callback 68 | 69 | # to make sure persisted in both DB and updated in memory 70 | @head.sees_fields.should == [other_see, sees_fields] 71 | @head.reload 72 | @head.sees_fields.should == [other_see, sees_fields] 73 | end 74 | 75 | it "should do nothing if the inverse is nil" do 76 | @person.seen_by = nil 77 | run_callback 78 | end 79 | end 80 | 81 | describe "#define_destroy_callback" do 82 | def run_destroy_callback 83 | @person.send(:_denormalize_destroy_to_seen_by) 84 | end 85 | 86 | before do 87 | define_and_create(:define_destroy_callback) 88 | end 89 | 90 | it "should pull first any existing array entries matching the _id" do 91 | @head.sees_fields = [sees_fields, other_see] 92 | @head.save! 93 | 94 | run_destroy_callback 95 | 96 | # to make sure persisted in both DB and updated in memory 97 | @head.sees_fields.should == [other_see] 98 | @head.reload 99 | @head.sees_fields.should == [other_see] 100 | end 101 | 102 | it "should do nothing if the inverse is nil" do 103 | @person.seen_by = nil 104 | run_destroy_callback 105 | end 106 | end 107 | end 108 | 109 | describe "with a polymorphic model" do 110 | def create_models 111 | @person = Person.create(:name => @name = "Bob", :created_at => @now = Time.now) 112 | @head = Head.create(:size => @size = 10) 113 | @person.above = @head 114 | end 115 | 116 | def args 117 | [Person, :above, [:name]] 118 | end 119 | 120 | def below_people_fields 121 | { "_id" => @person.id, 122 | "name"=> @name } 123 | end 124 | 125 | def other_below_people 126 | { "_id" => "SomeObjectId" } 127 | end 128 | 129 | before do 130 | Head.class_eval do 131 | field :below_people_fields, :type => Array, :default => [] 132 | end 133 | end 134 | 135 | describe "#define_callback" do 136 | def run_callback 137 | @person.send(:_denormalize_to_above) 138 | end 139 | 140 | before do 141 | define_and_create 142 | end 143 | 144 | it "should push the fields to the relation" do 145 | @head.below_people_fields.should == [] 146 | run_callback 147 | @head.below_people_fields.should == [below_people_fields] 148 | end 149 | 150 | it "should pull first any existing array entries matching the _id" do 151 | @head.below_people_fields = [other_below_people] 152 | @head.save! 153 | 154 | run_callback 155 | run_callback 156 | 157 | # to make sure persisted in both DB and updated in memory 158 | @head.below_people_fields.should == [other_below_people, below_people_fields] 159 | @head.reload 160 | @head.below_people_fields.should == [other_below_people, below_people_fields] 161 | end 162 | 163 | it "should do nothing if the inverse is nil" do 164 | @person.above = nil 165 | run_callback 166 | end 167 | end 168 | 169 | describe "#define_destroy_callback" do 170 | def run_destroy_callback 171 | @person.send(:_denormalize_destroy_to_above) 172 | end 173 | 174 | before do 175 | define_and_create(:define_destroy_callback) 176 | end 177 | 178 | it "should pull first any existing array entries matching the _id" do 179 | @head.below_people_fields = [below_people_fields, other_below_people] 180 | @head.save! 181 | 182 | run_destroy_callback 183 | 184 | # to make sure persisted in both DB and updated in memory 185 | @head.below_people_fields.should == [other_below_people] 186 | @head.reload 187 | @head.below_people_fields.should == [other_below_people] 188 | end 189 | 190 | it "should do nothing if the inverse is nil" do 191 | @person.above = nil 192 | run_destroy_callback 193 | end 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /spec/mongoid/alize/callbacks/to/one_from_many_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Alize::ToCallback do 4 | def klass 5 | Mongoid::Alize::ToCallback 6 | end 7 | 8 | def new_callback 9 | klass.new(*args) 10 | end 11 | 12 | def define_and_create(callback_name=:define_callback) 13 | @callback = new_callback 14 | @callback.send(:define_denorm_attrs) 15 | create_models 16 | @callback.send(callback_name) 17 | end 18 | 19 | describe "with metadata in advance" do 20 | def create_models 21 | @head = Head.create( 22 | :captor => @person = Person.create(:name => "Bob")) 23 | @person.heads = [@head] 24 | end 25 | 26 | def args 27 | [Person, :heads, [:name, :location, :created_at]] 28 | end 29 | 30 | before do 31 | Head.class_eval do 32 | field :captor_fields, :type => Hash, :default => nil 33 | end 34 | end 35 | 36 | describe "#define_callback" do 37 | def captor_fields 38 | { "name"=> "Bob", 39 | "location" => "Paris", 40 | "created_at"=> @now } 41 | end 42 | 43 | def run_callback 44 | @person.send(:_denormalize_to_heads) 45 | end 46 | 47 | before do 48 | define_and_create 49 | end 50 | 51 | it "should push the fields to the relation" do 52 | @head.captor_fields.should be_nil 53 | run_callback 54 | @head.captor_fields.should == captor_fields 55 | end 56 | end 57 | 58 | describe "#define_destroy_callback" do 59 | def run_destroy_callback 60 | @person.send(:_denormalize_destroy_to_heads) 61 | end 62 | 63 | before do 64 | define_and_create(:define_destroy_callback) 65 | end 66 | 67 | it "should remove the fields from the relation" do 68 | @head.captor_fields = { "hi" => "hello" } 69 | run_destroy_callback 70 | @head.captor_fields.should be_nil 71 | end 72 | 73 | it "should do nothing if the relation doesn't exist" do 74 | @head.captor = nil 75 | run_destroy_callback 76 | end 77 | end 78 | end 79 | 80 | describe "with a polymorphic relationship" do 81 | def create_models 82 | @person = Person.create(:name => "Bob", :created_at => @now = Time.now) 83 | @head = Head.create(:size => @size = 10) 84 | @person.above = @head 85 | end 86 | 87 | def args 88 | [Head, :below_people, [:size]] 89 | end 90 | 91 | before do 92 | Person.class_eval do 93 | field :above_fields, :type => Hash, :default => nil 94 | end 95 | end 96 | 97 | describe "#define_callback" do 98 | def above_fields 99 | { "size"=> @size } 100 | end 101 | 102 | def run_callback 103 | @head.send(:_denormalize_to_below_people) 104 | end 105 | 106 | before do 107 | define_and_create 108 | end 109 | 110 | it "should push the fields to the relation" do 111 | @person.above_fields.should be_nil 112 | run_callback 113 | @person.above_fields.should == above_fields 114 | end 115 | end 116 | 117 | describe "#define_destroy_callback" do 118 | before do 119 | define_and_create(:define_destroy_callback) 120 | end 121 | 122 | def run_destroy_callback 123 | @head.send(:_denormalize_destroy_to_below_people) 124 | end 125 | 126 | it "should remove the fields from the relation" do 127 | @person.above_fields = { "hi" => "hello" } 128 | run_destroy_callback 129 | @person.above_fields.should be_nil 130 | end 131 | 132 | it "should do nothing if the relation doesn't exist" do 133 | @person.above = nil 134 | run_destroy_callback 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /spec/mongoid/alize/callbacks/to/one_from_one_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Alize::ToCallback do 4 | def klass 5 | Mongoid::Alize::ToCallback 6 | end 7 | 8 | def new_callback 9 | klass.new(*args) 10 | end 11 | 12 | def define_and_create(callback_name=:define_callback) 13 | @callback = new_callback 14 | @callback.send(:define_denorm_attrs) 15 | create_models 16 | @callback.send(callback_name) 17 | end 18 | 19 | describe "with metadata in advance" do 20 | def args 21 | [Person, :head, [:name, :location, :created_at]] 22 | end 23 | 24 | def create_models 25 | @head = Head.create( 26 | :person => @person = Person.create(:name => "Bob")) 27 | end 28 | 29 | before do 30 | Head.class_eval do 31 | field :person_fields, :type => Hash, :default => nil 32 | end 33 | end 34 | 35 | describe "define_callback" do 36 | def person_fields 37 | { "name"=> "Bob", 38 | "location" => "Paris", 39 | "created_at"=> @now } 40 | end 41 | 42 | def run_callback 43 | @person.send(:_denormalize_to_head) 44 | end 45 | 46 | before do 47 | define_and_create 48 | end 49 | 50 | it "should push the fields to the relation" do 51 | @head.person_fields.should == nil 52 | run_callback 53 | @head.person_fields.should == person_fields 54 | end 55 | end 56 | 57 | describe "define_destroy_callback" do 58 | def run_destroy_callback 59 | @person.send(:_denormalize_destroy_to_head) 60 | end 61 | 62 | before do 63 | define_and_create(:define_destroy_callback) 64 | end 65 | 66 | it "should nillify the fields in the relation" do 67 | @head.person_fields = { "hi" => "hello" } 68 | run_destroy_callback 69 | @head.person_fields.should be_nil 70 | end 71 | 72 | it "should do nothing if the relation doesn't exist" do 73 | @head.person = nil 74 | run_destroy_callback 75 | end 76 | end 77 | end 78 | 79 | describe "to a polymorphic child" do 80 | def args 81 | [Person, :nearest_head, [:name, :location, :created_at]] 82 | end 83 | 84 | def create_models 85 | @head = Head.create( 86 | :nearest => @person = Person.create(:name => @name = "Bob")) 87 | end 88 | 89 | before do 90 | Head.class_eval do 91 | field :nearest_fields, :type => Hash, :default => nil 92 | end 93 | end 94 | 95 | describe "define_callback" do 96 | def nearest_fields 97 | { "name"=> "Bob", 98 | "location" => "Paris", 99 | "created_at"=> @now } 100 | end 101 | 102 | def run_callback 103 | @person.send(:_denormalize_to_nearest_head) 104 | end 105 | 106 | before do 107 | define_and_create 108 | end 109 | 110 | it "should push the fields to the relation" do 111 | @head.nearest_fields.should be_nil 112 | run_callback 113 | @head.nearest_fields.should == nearest_fields 114 | end 115 | end 116 | 117 | describe "define_destroy_callback" do 118 | def run_destroy_callback 119 | @person.send(:_denormalize_destroy_to_nearest_head) 120 | end 121 | 122 | before do 123 | define_and_create(:define_destroy_callback) 124 | end 125 | 126 | it "should nillify the fields in the relation" do 127 | @head.nearest_fields = { "hi" => "hello" } 128 | run_destroy_callback 129 | @head.nearest_fields.should be_nil 130 | end 131 | 132 | it "should do nothing if the relation doesn't exist" do 133 | @head.nearest = nil 134 | run_destroy_callback 135 | end 136 | end 137 | end 138 | 139 | describe "to a polymorphic parent" do 140 | def args 141 | [Head, :nearest, [:size]] 142 | end 143 | 144 | def create_models 145 | @head = Head.create( 146 | :nearest => @person = Person.create(:name => @name = "Bob", 147 | :created_at => @now = Time.now)) 148 | end 149 | 150 | before do 151 | Person.class_eval do 152 | field :nearest_head_fields, :type => Hash, :default => nil 153 | end 154 | end 155 | 156 | describe "define_callback" do 157 | def nearest_head_fields 158 | { "size"=> @size } 159 | end 160 | 161 | def run_callback 162 | @head.send(:_denormalize_to_nearest) 163 | end 164 | 165 | before do 166 | define_and_create 167 | end 168 | 169 | it "should push the fields to the relation" do 170 | @person.nearest_head_fields.should be_nil 171 | run_callback 172 | @person.nearest_head_fields.should == nearest_head_fields 173 | end 174 | end 175 | 176 | describe "define_destroy_callback" do 177 | def run_destroy_callback 178 | @head.send(:_denormalize_destroy_to_nearest) 179 | end 180 | 181 | before do 182 | define_and_create(:define_destroy_callback) 183 | end 184 | 185 | it "should nillify the fields in the relation" do 186 | @person.nearest_head_fields = { "hi" => "hello" } 187 | run_destroy_callback 188 | @person.nearest_head_fields.should be_nil 189 | end 190 | 191 | it "should do nothing if the relation doesn't exist" do 192 | @person.reload 193 | @person.nearest_head = nil 194 | run_destroy_callback 195 | end 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /spec/mongoid/alize/from_callback_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class Mongoid::Alize::SpecFromCallback < Mongoid::Alize::FromCallback 4 | def define_mongoid_field 5 | end 6 | 7 | def define_callback 8 | klass.class_eval <<-CALLBACK 9 | def _denormalize_from_person 10 | end 11 | CALLBACK 12 | end 13 | end 14 | 15 | describe Mongoid::Alize::FromCallback do 16 | def klass 17 | Mongoid::Alize::SpecFromCallback 18 | end 19 | 20 | def args 21 | [Head, :person, [:name, :location, :created_at]] 22 | end 23 | 24 | def new_callback 25 | klass.new(*args) 26 | end 27 | 28 | before do 29 | @callback = new_callback 30 | end 31 | 32 | describe "#set_callback" do 33 | it "should set a callback on the klass" do 34 | mock(@callback.klass).set_callback(:save, :before, :denormalize_from_person) 35 | @callback.send(:set_callback) 36 | end 37 | 38 | it "should not set the callback if it's already set" do 39 | @callback.send(:attach) 40 | dont_allow(@callback.klass).set_callback 41 | @callback.send(:set_callback) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/mongoid/alize/instance_helpers_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Alize::InstanceHelpers do 4 | 5 | def from_klass 6 | Mongoid::Alize::Callbacks::From::One 7 | end 8 | 9 | def to_klass 10 | Mongoid::Alize::ToCallback 11 | end 12 | 13 | before do 14 | @head = 15 | Head.new(person: 16 | @person = Person.create(:name => @name = "Bob")) 17 | end 18 | 19 | describe "#denormalize_from_all" do 20 | it "should run the alize callbacks" do 21 | Head.alize_from_callbacks << 22 | callback = from_klass.new(Head, :person, [:name]) 23 | mock(@head).denormalize_from_person 24 | @head.denormalize_from_all 25 | end 26 | end 27 | 28 | describe "#denormalize_to_all" do 29 | it "should run the alize callbacks" do 30 | Person.alize_to_callbacks << 31 | callback = to_klass.new(Person, :head, [:size]) 32 | mock(@person).denormalize_to_head 33 | @person.denormalize_to_all 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/mongoid/alize/macros_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Alize::Macros do 4 | def person_default_fields 5 | if Mongoid::Compatibility::Version.mongoid3? 6 | ["name", "created_at", "want_ids", "seen_by_id", "above_type", "above_field", "above_id"] 7 | else 8 | ["name", "created_at", "my_date", "my_datetime", "want_ids", "seen_by_id", "above_type", "above_id"] 9 | end 10 | end 11 | 12 | def head_default_fields 13 | if Mongoid::Compatibility::Version.mongoid3? 14 | ["size", "weight", "person_id", "captor_id", "wanted_by_ids", "nearest_type", "nearest_field", "nearest_id"] 15 | else 16 | ["size", "weight", "person_id", "captor_id", "wanted_by_ids", "nearest_type", "nearest_id"] 17 | end 18 | end 19 | 20 | describe "#alize_to and #alize_from" do 21 | describe "with a belongs_to" do 22 | it_should_set_callbacks(Head, Person, 23 | :person, :head, 24 | fns::One, tns) 25 | end 26 | 27 | describe "with a has_one" do 28 | it_should_set_callbacks(Person, Head, 29 | :head, :person, 30 | fns::One, tns) 31 | end 32 | 33 | describe "with a has_many from the belongs_to side" do 34 | it_should_set_callbacks(Head, Person, 35 | :captor, :heads, 36 | fns::One, tns) 37 | end 38 | 39 | describe "with a has_many from the has_many side" do 40 | it_should_set_callbacks(Head, Person, 41 | :sees, :seen_by, 42 | fns::Many, tns) 43 | end 44 | 45 | describe "with a has_and_belongs_to_many" do 46 | it_should_set_callbacks(Head, Person, 47 | :wanted_by, :wants, 48 | fns::Many, tns) 49 | end 50 | 51 | describe "#alize" do 52 | it "should call alize_from" do 53 | mock(Head).alize_from(:person, :name) 54 | Head.alize(:person, :name) 55 | end 56 | 57 | it "should call alize_to if relation is not polymorphic" do 58 | mock(Person).alize_to(:head, :name) 59 | Head.alize(:person, :name) 60 | end 61 | 62 | describe "with a polymorphic association" do 63 | it "should attach an inverse callback to the parent side" do 64 | Person.relations["nearest_head"].should be_polymorphic 65 | Person.relations["nearest_head"].should_not be_stores_foreign_key 66 | Person.relations["nearest_head"].klass.should == Head 67 | mock.proxy(Mongoid::Alize::ToCallback).new(Head, :nearest, head_default_fields) 68 | Person.alize(:nearest_head) 69 | end 70 | 71 | it "should not attach a callback on the child side" do 72 | Head.relations["nearest"].should be_polymorphic 73 | Head.relations["nearest"].should be_stores_foreign_key 74 | dont_allow(Mongoid::Alize::ToCallback).new 75 | Head.alize(:nearest) 76 | end 77 | end 78 | 79 | describe "when no inverse is present" do 80 | it "should add only a from callback" do 81 | Head.relations["admirer"].inverse.should be_nil 82 | dont_allow(Mongoid::Alize::ToCallback).new 83 | Head.alize(:admirer) 84 | end 85 | end 86 | end 87 | 88 | describe "#alize_to" do 89 | describe "with fields supplied" do 90 | it "should use them" do 91 | mock.proxy(Mongoid::Alize::ToCallback).new(Person, :head, [:foo, :bar]) 92 | Person.alize_to(:head, :foo, :bar) 93 | end 94 | end 95 | 96 | describe "with no fields supplied" do 97 | it "should use the default alize fields" do 98 | mock.proxy(Mongoid::Alize::ToCallback).new(Person, :head, person_default_fields) 99 | Person.alize_to(:head) 100 | end 101 | end 102 | 103 | describe "with a block supplied" do 104 | it "should use the block supplied as fields in the options hash" do 105 | blk = lambda {} 106 | mock.proxy(Mongoid::Alize::ToCallback).new(Person, :head, blk) 107 | Person.alize_to(:head, :foo, :fields => blk) 108 | end 109 | end 110 | end 111 | 112 | describe "#alize_from" do 113 | describe "with fields supplied" do 114 | it "should use them" do 115 | mock.proxy(Mongoid::Alize::Callbacks::From::One).new( 116 | Head, :person, [:foo, :bar]) 117 | Head.alize_from(:person, :foo, :bar) 118 | end 119 | end 120 | 121 | describe "with no fields supplied" do 122 | it "should use the default alize fields" do 123 | mock.proxy(Mongoid::Alize::Callbacks::From::One).new( 124 | Head, :person, person_default_fields) 125 | Head.alize_from(:person) 126 | end 127 | end 128 | 129 | describe "with a block supplied" do 130 | it "should use the block supplied as fields in the options hash" do 131 | blk = lambda {} 132 | mock.proxy(Mongoid::Alize::Callbacks::From::One).new( 133 | Head, :person, blk) 134 | Head.alize_from(:person, :foo, :fields => blk) 135 | end 136 | end 137 | end 138 | 139 | describe "default_alize_fields" do 140 | it "should return an array of all non-internal field names (e.g. not _type or _id)" do 141 | Head.default_alize_fields.should == head_default_fields 142 | Person.default_alize_fields.should == person_default_fields 143 | end 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /spec/mongoid/alize/mongoize_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Alize::ToCallback do 4 | 5 | before do 6 | @now = Time.parse('2013-01-05T12:00:22-700') 7 | 8 | Head.class_eval do 9 | field :sees_fields, :type => Array, :default => [] 10 | end 11 | Person.class_eval do 12 | fields = [:name, :location, :created_at] 13 | fields += [:my_date, :my_datetime] if Mongoid::Compatibility::Version.mongoid4_or_newer? 14 | alize_to :seen_by, fields: fields 15 | end 16 | 17 | @head = Head.create(:sees => [@person = Person.create(sees_fields_without_id)]) 18 | @person.seen_by = @head 19 | end 20 | 21 | def sees_fields_without_id 22 | fields = { "name"=> "Bob", 23 | "location" => "Paris", 24 | "created_at" => @now } 25 | 26 | fields.merge!( "my_date" => @now.to_date, 27 | "my_datetime" => @now.to_datetime ) if Mongoid::Compatibility::Version.mongoid4_or_newer? 28 | 29 | fields 30 | end 31 | 32 | def sees_fields_with_id 33 | sees_fields_without_id.merge!( "_id" => @person.id ) 34 | end 35 | 36 | def sees_fields_mongoized 37 | fields = sees_fields_with_id.merge!( "created_at" => @now.utc ) 38 | 39 | fields.merge!( "my_date" => @now.utc.to_date, 40 | "my_datetime" => @now.utc ) if Mongoid::Compatibility::Version.mongoid4_or_newer? 41 | 42 | fields 43 | end 44 | 45 | it "should push the mongoized values to the relation" do 46 | @head.sees_fields.should == [sees_fields_mongoized] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/mongoid/alize/to_callback_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Alize::ToCallback do 4 | def klass 5 | Mongoid::Alize::ToCallback 6 | end 7 | 8 | def args 9 | [Person, :head, [:name]] 10 | end 11 | 12 | def new_callback 13 | klass.new(*args) 14 | end 15 | 16 | def define_and_create(callback_name=:define_callback) 17 | @callback = new_callback 18 | @callback.send(:define_denorm_attrs) 19 | create_models 20 | @callback.send(callback_name) 21 | end 22 | 23 | before do 24 | @callback = new_callback 25 | end 26 | 27 | describe "names" do 28 | it "should assign a destroy callback name" do 29 | @callback.destroy_callback_name.should == :_denormalize_destroy_to_head 30 | end 31 | 32 | it "should assign an aliased destroy callback name" do 33 | @callback.aliased_destroy_callback_name.should == :denormalize_destroy_to_head 34 | end 35 | 36 | it "should assign a prefixed name from the inverse if present" do 37 | @callback.inverse_klass.should == Head 38 | @callback.inverse_relation.should == :person 39 | @callback.prefixed_name.should == ":person_fields" 40 | end 41 | 42 | it "should compute the name on the fly if the inverse is not present" do 43 | @callback = klass.new(Head, :nearest, [:name]) 44 | @callback.inverse_klass.should be_nil 45 | @callback.inverse_relation.should be_nil 46 | @callback.prefixed_name.should =~ /relation/ 47 | end 48 | end 49 | 50 | describe "#define_denorm_attrs" do 51 | it "should define the denorm attrs method" do 52 | mock(@callback).define_denorm_attrs 53 | @callback.send(:define_denorm_attrs) 54 | end 55 | end 56 | 57 | describe "#set_callback" do 58 | it "should set a callback on the klass" do 59 | mock(@callback.klass).set_callback(:save, :after, :denormalize_to_head) 60 | @callback.send(:set_callback) 61 | end 62 | 63 | it "should not set the callback if it's already set" do 64 | @callback.send(:attach) 65 | dont_allow(@callback.klass).set_callback 66 | @callback.send(:set_callback) 67 | end 68 | end 69 | 70 | describe "#set_destroy_callback" do 71 | it "should set a destroy callback on the klass" do 72 | mock(@callback.klass).set_callback(:destroy, :after, :denormalize_destroy_to_head) 73 | @callback.send(:set_destroy_callback) 74 | end 75 | 76 | it "should not set the destroy callback if it's already set" do 77 | @callback.send(:attach) 78 | dont_allow(@callback.klass).set_callback 79 | @callback.send(:set_destroy_callback) 80 | end 81 | end 82 | 83 | describe "#alias_destroy_callback" do 84 | it "should alias the destroy callback on the klass" do 85 | mock(@callback.klass).alias_method(:denormalize_destroy_to_head, :_denormalize_destroy_to_head) 86 | mock(@callback.klass).public(:denormalize_destroy_to_head) 87 | @callback.send(:alias_destroy_callback) 88 | end 89 | 90 | it "should not alias the destroy callback if it's already set" do 91 | @callback.send(:attach) 92 | dont_allow(@callback.klass).alias_method 93 | @callback.send(:alias_destroy_callback) 94 | end 95 | end 96 | 97 | describe "not modifying frozen hashes" do 98 | def create_models 99 | @person = Person.create!(:name => @name = "George") 100 | @head = Head.create(:person => @person) 101 | end 102 | 103 | def person_fields 104 | { :name => @name } 105 | end 106 | 107 | before do 108 | Head.class_eval do 109 | field :person_fields, :type => Hash, :default => {} 110 | end 111 | define_and_create(:define_destroy_callback) 112 | end 113 | 114 | describe "#define_callback" do 115 | def run_callback 116 | @person.send(:_denormalize_to_head) 117 | end 118 | 119 | before do 120 | define_and_create(:define_callback) 121 | end 122 | 123 | it "should not modify object frozen for deletion" do 124 | @head.destroy 125 | run_callback 126 | @head.person_fields.should == {} 127 | end 128 | end 129 | 130 | describe "#define_destroy_callback" do 131 | def run_callback 132 | @person.send(:_denormalize_destroy_to_head) 133 | end 134 | 135 | it "should not modify object frozen for deletion" do 136 | @head.person_fields = person_fields 137 | @head.destroy 138 | run_callback 139 | @head.person_fields.should == person_fields 140 | end 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /spec/mongoid_alize_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mongoid::Alize do 4 | def person_fields 5 | [:name, :location] 6 | end 7 | 8 | def head_fields 9 | [:size] 10 | end 11 | 12 | before do 13 | @now = Time.now 14 | @head = Head.new(:size => @size = 10, created_at: @now) 15 | @person = Person.new(:name => @name = "Bob", created_at: @now) 16 | end 17 | 18 | describe "one-to-one" do 19 | describe "from belongs_to side" do 20 | before do 21 | Head.send(:alize, :person, *person_fields) 22 | @head.person = @person 23 | end 24 | 25 | def assert_head 26 | @head.person_fields.should == { 27 | "name" => @name, 28 | "location" => "Paris" 29 | } 30 | end 31 | 32 | it "should pull data from person on create" do 33 | @head.save! 34 | assert_head 35 | end 36 | 37 | it "should pull data from a changed person on save" do 38 | @head.save! 39 | @head.person = Person.create(:name => @name = "Bill") 40 | @head.save! 41 | assert_head 42 | end 43 | 44 | it "should not pull data from an unchanged person on save" do 45 | @head.save! 46 | @head.person_fields["name"] = "Cowboy" 47 | @head.save! 48 | @head.person_fields["name"].should == "Cowboy" 49 | end 50 | 51 | it "should push data to head" do 52 | @person.update_attributes!(:name => @name = "Bill") 53 | assert_head 54 | end 55 | 56 | it "should nillify person fields in head when person is destroyed" do 57 | @head.update_attributes!(:person_fields => { "name" => "Old Gregg", "location" => "Paris" }) 58 | @person.destroy 59 | @head.person_fields.should be_nil 60 | end 61 | end 62 | 63 | describe "from has_one side" do 64 | before do 65 | Person.send(:alize, :head, *head_fields) 66 | @person.head = @head 67 | end 68 | 69 | def assert_person 70 | @person.head_fields.should == { "size" => @size } 71 | end 72 | 73 | it "should pull data from head on create" do 74 | @person.save! 75 | assert_person 76 | end 77 | 78 | it "should pull data from head on save" do 79 | @person.save! 80 | @person.head = Head.create(:size => @size = 18) 81 | @person.save! 82 | assert_person 83 | end 84 | 85 | it "should push data to person" do 86 | @head.update_attributes!(:size => @size = 20) 87 | assert_person 88 | end 89 | 90 | it "should nillify head fields in person when head is destroyed" do 91 | @person.update_attributes!(:head_fields => { "size" => "1000 balloons"}) 92 | @head.destroy 93 | @person.head_fields.should be_nil 94 | end 95 | end 96 | end 97 | 98 | describe "one-to-many" do 99 | describe "from belongs_to side" do 100 | before do 101 | Head.send(:alize, :captor, *person_fields) 102 | @head.captor = @person 103 | end 104 | 105 | def assert_captor 106 | @head.captor_fields.should == { "name" => @name, "location" => "Paris" } 107 | end 108 | 109 | it "should pull data from captor on create" do 110 | @head.save! 111 | assert_captor 112 | end 113 | 114 | it "should pull data from captor on save" do 115 | @head.save! 116 | @head.captor = Person.create(:name => @name = "Bill") 117 | @head.save! 118 | assert_captor 119 | end 120 | 121 | it "should push data to person" do 122 | @person.update_attributes!(:name => @name = "Bill") 123 | assert_captor 124 | end 125 | 126 | it "should nillify captor fields when person is destroyed" do 127 | @head.update_attributes!(:captor => { "name" => "Old Gregg"}) 128 | @person.destroy 129 | @head.captor_fields.should be_nil 130 | end 131 | end 132 | 133 | describe "from has_many side" do 134 | before do 135 | Head.send(:alize, :sees, *person_fields) 136 | @head.sees = [@person] 137 | end 138 | 139 | def assert_sees 140 | @head.sees_fields.should == [{ 141 | "_id" => @person.id, 142 | "location" => "Paris", 143 | "name" => @name }] 144 | end 145 | 146 | it "should pull data from sees on create" do 147 | @head.save! 148 | assert_sees 149 | end 150 | 151 | it "should pull data from a sees on save" do 152 | @head.save! 153 | @head.sees = [@person = Person.create(:name => @name = "Bill")] 154 | @head.save! 155 | assert_sees 156 | end 157 | 158 | it "should push data to seen_by" do 159 | @person.update_attributes!(:name => @name = "Bill") 160 | assert_sees 161 | end 162 | 163 | it "should remove sees_fields entries in head when person is destroyed" do 164 | @head.save! 165 | assert_sees 166 | @person.destroy 167 | @head.sees_fields.should == [] 168 | @head.reload.sees_fields.should == [] 169 | end 170 | end 171 | end 172 | 173 | describe "many-to-many" do 174 | describe "has_and_belongs_to_many" do 175 | before do 176 | Head.send(:alize, :wanted_by, *person_fields) 177 | end 178 | 179 | def assert_wanted_by 180 | @head.wanted_by_fields.should == [{ 181 | "_id" => @person.id, 182 | "location" => "Paris", 183 | "name" => @name }] 184 | end 185 | 186 | it "should pull data from wanted_by on create" do 187 | @head.wanted_by = [@person] 188 | @head.save! 189 | assert_wanted_by 190 | end 191 | 192 | it "should pull data from wanted_by on save" do 193 | @head.save! 194 | @person = Person.create(:name => @name = "Bill") 195 | @head.wanted_by = [@person] 196 | @head.save! 197 | assert_wanted_by 198 | end 199 | 200 | it "should push data to wants" do 201 | @person.wants = [@head] 202 | @person.update_attributes!(:name => @name = "Bill") 203 | assert_wanted_by 204 | end 205 | 206 | it "should remove wanted_by_fields entries in head when person is destroyed" do 207 | @head.wanted_by = [@person] 208 | @head.save! 209 | assert_wanted_by 210 | @person.destroy 211 | @head.reload 212 | @head.wanted_by_fields.should == [] 213 | @head.reload.wanted_by_fields.should == [] 214 | end 215 | end 216 | end 217 | 218 | describe "without specifying fields" do 219 | describe "for non-polymorphic" do 220 | before do 221 | @head.person = @person 222 | end 223 | 224 | it "should denormalize all non-internal fields" do 225 | Head.send(:alize, :person) 226 | @head.save! 227 | if Mongoid::Compatibility::Version.mongoid3? 228 | @head.person_fields.should == { 229 | "name" => @name, 230 | "created_at" => @person.created_at, 231 | "seen_by_id" => nil, 232 | "want_ids" => [], 233 | "above_id" => nil, 234 | "above_field" => nil, 235 | "above_type" => nil 236 | } 237 | else 238 | @head.person_fields.should == { 239 | "name" => @name, 240 | "created_at" => @person.created_at, 241 | "my_date" => nil, 242 | "my_datetime" => nil, 243 | "seen_by_id" => nil, 244 | "want_ids" => [], 245 | "above_id" => nil, 246 | "above_type" => nil 247 | } 248 | end 249 | end 250 | 251 | it "should denormalize all non-internal fields" do 252 | Head.send(:alize, :person) 253 | @person.save! 254 | if Mongoid::Compatibility::Version.mongoid3? 255 | @head.person_fields.should == { 256 | "name" => @name, 257 | "created_at" => @person.created_at, 258 | "seen_by_id" => nil, 259 | "want_ids" => [], 260 | "above_id" => nil, 261 | "above_field" => nil, 262 | "above_type" => nil 263 | } 264 | else 265 | @head.person_fields.should == { 266 | "name" => @name, 267 | "created_at" => @person.created_at, 268 | "my_date" => nil, 269 | "my_datetime" => nil, 270 | "seen_by_id" => nil, 271 | "want_ids" => [], 272 | "above_id" => nil, 273 | "above_type" => nil 274 | } 275 | end 276 | end 277 | end 278 | 279 | describe "for polymorphic" do 280 | before do 281 | @head.person = @person 282 | end 283 | 284 | it "should denormalize no default fields for a polymorphic child side" do 285 | @head.nearest = @person 286 | Head.send(:alize_from, :nearest) 287 | @head.save! 288 | @head.nearest_fields.should == {} 289 | end 290 | end 291 | end 292 | 293 | describe "overriding denormalize methods for custom behavior on the from side" do 294 | before do 295 | class Person 296 | def denormalize_update_name_first 297 | self.name = "Overrider" 298 | end 299 | end 300 | 301 | class Head 302 | def denormalize_from_person 303 | self.person.denormalize_update_name_first 304 | _denormalize_from_person 305 | end 306 | end 307 | 308 | Head.send(:alize, :person, *person_fields) 309 | @head.person = @person 310 | end 311 | 312 | it "should be possible to define a method before alize and call the alize version within it" do 313 | @head.save! 314 | @head.person_fields["name"].should == "Overrider" 315 | end 316 | end 317 | 318 | describe "overriding denormalize methods for custom behavior on the to side" do 319 | before do 320 | class Person 321 | def denormalize_to_head 322 | self.name = "Overrider" 323 | _denormalize_to_head 324 | end 325 | end 326 | 327 | Head.send(:alize, :person, *person_fields) 328 | @head.person = @person 329 | @head.save! 330 | end 331 | 332 | it "should be possible to define a method before alize and call the alize version within it" do 333 | @person.update_attributes!(:name => @name = "Bill") 334 | @head.person_fields["name"].should == "Overrider" 335 | end 336 | end 337 | 338 | describe "forcing denormalization" do 339 | before do 340 | Head.send(:alize, :person, *person_fields) 341 | @head.person = @person 342 | @head.save! 343 | end 344 | 345 | it "should allow using the force flag to force denormalization" do 346 | class Head 347 | def denormalize_from_person 348 | _denormalize_from_person(true) 349 | end 350 | end 351 | 352 | @head.person_fields["name"] = "Misty" 353 | @head.save! 354 | @head.person_fields["name"] = @name 355 | end 356 | 357 | it "should allow using the force_denormalization attribute to force denormalization" do 358 | @head.person_fields["name"] = "Misty" 359 | @head.force_denormalization = true 360 | @head.save! 361 | @head.person_fields["name"] = @name 362 | end 363 | end 364 | 365 | describe "using a proc to define fields" do 366 | before do 367 | Head.send(:alize, :person, :fields => lambda { |inverse| 368 | self.alize_fields(inverse) }) 369 | @head.person = @person 370 | end 371 | 372 | def assert_head 373 | @head.person_fields.should == { 374 | "name" => @name, 375 | "location" => "Paris" 376 | } 377 | end 378 | 379 | it "should work the same way as it does with fields specified" do 380 | @head.save! 381 | assert_head 382 | end 383 | end 384 | 385 | describe "using a proc to define fields for a one-to-one polymorphic association from the belongs to side" do 386 | before do 387 | Head.send(:alize, :nearest, :fields => lambda { |inverse| 388 | self.alize_fields(inverse) }) 389 | @head.nearest = @person 390 | end 391 | 392 | def assert_head 393 | @head.nearest_fields.should == { 394 | "name" => @name, 395 | "location" => "Paris" 396 | } 397 | end 398 | 399 | it "should work the same way as it does with fields specified" do 400 | @head.save! 401 | assert_head 402 | end 403 | end 404 | 405 | describe "using a proc to define fields for a one-to-one polymorphic association from the has one side" do 406 | before do 407 | Person.send(:alize, :nearest_head, :fields => lambda { |inverse| 408 | self.alize_fields(inverse) }) 409 | @person.nearest_head = @head 410 | end 411 | 412 | def assert_person 413 | @person.nearest_head_fields.should == { 414 | "size" => @size 415 | } 416 | end 417 | 418 | it "should work the same way as it does with fields specified" do 419 | @person.save! 420 | assert_person 421 | end 422 | end 423 | 424 | describe "using a proc to define fields for a has many polymorphic association from the :as side" do 425 | before do 426 | Head.send(:alize, :below_people, :fields => lambda { |inverse| 427 | self.alize_fields(inverse) }) 428 | @head.below_people << @person 429 | end 430 | 431 | def assert_head 432 | @head.below_people_fields.should == [{ 433 | "_id" => @person.id, 434 | "name" => @name, 435 | "location" => "Paris" 436 | }] 437 | end 438 | 439 | it "should work the same way as it does with fields specified" do 440 | @head.save! 441 | assert_head 442 | end 443 | end 444 | 445 | describe "using a proc to define fields for a has many polymorphic association from the belongs_to side" do 446 | before do 447 | Person.send(:alize, :above, :fields => lambda { |inverse| 448 | self.alize_fields(inverse) }) 449 | @person.above = @head 450 | end 451 | 452 | def assert_person 453 | @person.above_fields.should == { 454 | "size" => @size, 455 | } 456 | end 457 | 458 | it "should work the same way as it does with fields specified" do 459 | @person.save! 460 | assert_person 461 | end 462 | end 463 | 464 | describe "the push on the child side of a one-to-one polymorphic" do 465 | before do 466 | fields = { :fields => lambda { |person| [:name, :location] } } 467 | Head.send(:alize, :nearest, fields) 468 | Person.send(:alize_to, :nearest_head, fields) 469 | @head.nearest = @person 470 | end 471 | 472 | def assert_head 473 | @head.nearest_fields.should == { 474 | "name" => @name, 475 | "location" => "Paris" 476 | } 477 | end 478 | 479 | it "should push the new fields" do 480 | @head.save! 481 | assert_head 482 | @person.update_attributes!(:name => @name = "George") 483 | assert_head 484 | end 485 | end 486 | 487 | describe "the push on the child side of a one-to-many polymorphic" do 488 | before do 489 | fields = { :fields => lambda { |head| [:size] } } 490 | Person.send(:alize, :above, fields) 491 | Head.send(:alize_to, :below_people, fields) 492 | @person.above = @head 493 | end 494 | 495 | def assert_person 496 | @person.above_fields.should == { 497 | "size" => @size 498 | } 499 | end 500 | 501 | it "should push the new fields" do 502 | @person.save! 503 | assert_person 504 | @head.update_attributes!(:size => @size = 5) 505 | assert_person 506 | end 507 | end 508 | 509 | describe "the push on the parent side of a one-to-one polymorphic" do 510 | before do 511 | fields = { :fields => lambda { |inverse| [:size] } } 512 | Person.send(:alize, :nearest_head, fields) 513 | @person.nearest_head = @head 514 | @person.save! 515 | end 516 | 517 | def assert_person 518 | @person.nearest_head_fields.should == { 519 | "size" => @size 520 | } 521 | end 522 | 523 | it "should push the new fields" do 524 | assert_person 525 | @head.update_attributes!(:size => @size = 5) 526 | assert_person 527 | end 528 | end 529 | 530 | describe "the push on the parent side of a one-to-many polymorphic" do 531 | before do 532 | fields = { :fields => lambda { |inverse| [:name, :location] } } 533 | Head.send(:alize, :below_people, fields) 534 | @head.below_people << @person 535 | @head.save! 536 | end 537 | 538 | def assert_head 539 | @head.below_people_fields.should == [{ 540 | "_id" => @person.id, 541 | "name" => @name, 542 | "location" => "Paris" 543 | }] 544 | end 545 | 546 | it "should push the new fields" do 547 | assert_head 548 | @person.update_attributes!(:name => @name = "George") 549 | assert_head 550 | end 551 | end 552 | end 553 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'mongoid' 3 | require 'mongoid/compatibility' 4 | require 'rr' 5 | 6 | unless ENV['CI'] 7 | require 'awesome_print' 8 | require 'wirble' 9 | end 10 | 11 | Mongoid.configure do |config| 12 | 13 | if defined?(Moped) 14 | Moped.logger = Logger.new($stdout) 15 | Moped.logger.level = Logger::INFO 16 | else 17 | logger = Logger.new($stdout) 18 | logger.level = Logger::INFO 19 | end 20 | 21 | name = "mongoid_alize_test" 22 | config.respond_to?(:connect_to) ? config.connect_to(name) : config.master = Mongo::Connection.new.db(name) 23 | end 24 | 25 | require File.expand_path("../../lib/mongoid_alize", __FILE__) 26 | Dir["#{File.dirname(__FILE__)}/app/models/*.rb"].each { |f| require f } 27 | Dir["#{File.dirname(__FILE__)}/helpers/*.rb"].each { |f| require f } 28 | 29 | SAVED_FIELDS = { 30 | } 31 | 32 | RSpec.configure do |config| 33 | config.include(MacrosHelper) 34 | 35 | puts "MongoidVersion - #{Mongoid::VERSION}" 36 | 37 | config.mock_with :rr 38 | config.before :each do 39 | Mongoid.purge! 40 | 41 | [Head, Person].each do |klass| 42 | if !SAVED_FIELDS[klass] 43 | SAVED_FIELDS[klass] = klass.fields.keys | ['_id', '_type'] 44 | end 45 | 46 | klass.alize_from_callbacks = [] 47 | klass.alize_to_callbacks = [] 48 | klass.reset_callbacks(:save) 49 | klass.reset_callbacks(:create) 50 | klass.reset_callbacks(:destroy) 51 | klass.fields.reject! do |field, value| 52 | !SAVED_FIELDS[klass].include?(field) 53 | end 54 | klass.instance_methods.each do |method| 55 | if method =~ /^_?denormalize_/ && method !~ /_all$/ 56 | klass.send(:undef_method, method) 57 | end 58 | end 59 | end 60 | end 61 | end 62 | --------------------------------------------------------------------------------