├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── CHANGELOG ├── Gemfile ├── LICENSE ├── Manifest ├── README.md ├── Rakefile ├── TODO ├── benchmarks └── sortable_benchmark.rb ├── lib ├── rails │ └── generators │ │ └── redis_orm │ │ └── model │ │ ├── model_generator.rb │ │ └── templates │ │ └── model.rb.erb ├── redis_orm.rb └── redis_orm │ ├── associations │ ├── belongs_to.rb │ ├── has_many.rb │ ├── has_many_helper.rb │ ├── has_many_proxy.rb │ └── has_one.rb │ ├── redis_orm.rb │ └── utils.rb ├── redis_orm.gemspec └── spec ├── association_indices_spec.rb ├── associations_spec.rb ├── classes ├── album.rb ├── article.rb ├── article_with_comments.rb ├── book.rb ├── catalog_item.rb ├── category.rb ├── city.rb ├── comment.rb ├── country.rb ├── custom_user.rb ├── cutout.rb ├── cutout_aggregator.rb ├── default_user.rb ├── empty_person.rb ├── expire_user.rb ├── expire_user_with_predicate.rb ├── giftcard.rb ├── jigsaw.rb ├── location.rb ├── message.rb ├── note.rb ├── omni_user.rb ├── person.rb ├── photo.rb ├── profile.rb ├── sortable_user.rb ├── timestamp.rb ├── user.rb ├── uuid_default_user.rb ├── uuid_timestamp.rb └── uuid_user.rb ├── expire_records_spec.rb ├── generators └── model_generator_spec.rb ├── models ├── association_indices_spec.rb ├── associations_spec.rb ├── atomicity_spec.rb ├── basic_functionality_spec.rb ├── callbacks_spec.rb ├── changes_array_spec.rb ├── dynamic_finders_spec.rb ├── exceptions_spec.rb ├── expire_records_spec.rb ├── has_one_has_many_spec.rb ├── indices_spec.rb ├── options_spec.rb ├── sortable_spec.rb ├── uuid_as_id_spec.rb └── validations_spec.rb ├── modules ├── belongs_to_model_within_module.rb └── has_many_model_within_module.rb ├── polymorphic_spec.rb ├── redis.conf ├── spec_helper.rb ├── test_helper.rb └── uuid_as_id_spec.rb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ master ] 13 | pull_request: 14 | branches: [ master ] 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | ruby-version: ['3.0'] 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Ruby 27 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 28 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 29 | # uses: ruby/setup-ruby@v1 30 | uses: ruby/setup-ruby@473e4d8fe5dd94ee328fdfca9f8c9c7afc9dae5e 31 | with: 32 | ruby-version: ${{ matrix.ruby-version }} 33 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 34 | - name: Start Redis 35 | uses: supercharge/redis-github-action@1.2.0 36 | with: 37 | redis-version: '5.0' 38 | auto-start: "true" 39 | - name: Run tests 40 | run: bundle exec rspec 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dump.rdb 2 | redis.sock 3 | Gemfile.lock 4 | tmp/ 5 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | v0.7 [03-05-2013] 2 | FEATURES 3 | * implemented Array and Hash properties types 4 | * added ability to specify an *expire* value for the record via method of the class and added inline *expire_in* key that can be used while saving objects (referencial keys in expireable record also expireables) 5 | * Add model generator [Tatsuya Sato] 6 | BUGS 7 | * fixed a bug with Date property implementation 8 | * refactored *save* method 9 | 10 | v0.6.2 [23-05-2012] 11 | * adds an ability to specify/create indices on *has_one* and *belongs_to* associations 12 | * fixed error with updating indices in *belongs_to* association with :as option 13 | * tests refactoring, now all tests are run with Rake::TestTask 14 | * moved all classes and modules from test cases to special folders (test/classes, test/modules) 15 | * fixed bug: :default values should be properly transformed to the right classes (if :default values are wrong) so when comparing them to other/stored instances they'll be the same 16 | 17 | v0.6.1 [05-12-2011] 18 | * rewritten sortable functionality for attributes which values are strings 19 | * added Gemfile to the project, improved tests 20 | 21 | v0.6 [12-09-2011] 22 | * added equality operator for object, #to_s method for inspecting objects, #find! which could throw RecordNotFound error 23 | * added self.descendants class method which returns all inherited from RedisOrm::Base classes 24 | * introduced :sortable option (in property declaration and #find conditions hash) - rudimentary ability to sort records by any property (not just by default 'created_at') 25 | * now handling models withing modules definitions (test for this in associations_test.rb) 26 | * properly handling :as parameter in options for has_many/belongs_to self-references 27 | * binding related models while creating model instance (like this: Article.create(:comment => comment)) 28 | * bunch of small fixes, updated tests and README.md 29 | 30 | v0.5.1 [27-07-2011] 31 | * added support of uuid as an id/primary key 32 | * added documentation on uuid support and connection to the redis server 33 | 34 | v0.5 [02-07-2011] 35 | * added support of *:conditions* hash in *:options* hash for has_many association in #find/#all methods 36 | * made keys order-independent in *:conditions* hash 37 | 38 | v0.4.2 [25-06-2011] 39 | * fixed bug with wrong saving of :default value/index for boolean type, fixed bug with #find(:all), #find(:first), #find(:last) function calls, added test for it 40 | * added simple test to ensure correct search on boolean properties 41 | * properly destroy dependent records 42 | * delete polymorphic records properly along with their backlinks 43 | 44 | v0.4.1 [23-06-2011] 45 | * fixed clitical bug: records after #destroy still available (added test for it) 46 | * added simple atomicity test 47 | * README.md: added link to my article "how to integrate redis_orm with paperclip" 48 | 49 | v0.4 [16-06-2011] 50 | * added :conditions key to the options hash in #find/#all methods 51 | * added #{property_name}_changed? instance method 52 | * fixed self-reference link for has_one association/added test for it 53 | * added :case_insensitive option to index declaration 54 | * fixed bug with no output when installed rspec > 2.6 55 | * added more tests and refactored old ones, updated documentation 56 | 57 | v0.3 [06-06-2011] 58 | * fixed #find functionality both for model itself and for has_many proxy 59 | * made sure ORM correctly resets associations when nil/[] provided 60 | * improved documentation, test for has_many proxy methods :+= and :<< added 61 | 62 | v0.2 [04-06-2011] 63 | * added polymorphic association 64 | * added *timestamps* declaration for the model 65 | * fixed several bugs and improved test coverage 66 | 67 | v0.1. [02-06-2011] 68 | * first release, w00t! 69 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2011 by Dmitrii Samoilov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Manifest: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | Gemfile 3 | LICENSE 4 | Manifest 5 | README.md 6 | Rakefile 7 | TODO 8 | benchmarks/sortable_benchmark.rb 9 | lib/rails/generators/redis_orm/model/model_generator.rb 10 | lib/rails/generators/redis_orm/model/templates/model.rb.erb 11 | lib/redis_orm.rb 12 | lib/redis_orm/active_model_behavior.rb 13 | lib/redis_orm/associations/belongs_to.rb 14 | lib/redis_orm/associations/has_many.rb 15 | lib/redis_orm/associations/has_many_helper.rb 16 | lib/redis_orm/associations/has_many_proxy.rb 17 | lib/redis_orm/associations/has_one.rb 18 | lib/redis_orm/redis_orm.rb 19 | lib/redis_orm/utils.rb 20 | redis_orm.gemspec 21 | spec/generators/model_generator_spec.rb 22 | spec/spec_helper.rb 23 | test/association_indices_test.rb 24 | test/associations_test.rb 25 | test/atomicity_test.rb 26 | test/basic_functionality_test.rb 27 | test/callbacks_test.rb 28 | test/changes_array_test.rb 29 | test/classes/album.rb 30 | test/classes/article.rb 31 | test/classes/article_with_comments.rb 32 | test/classes/book.rb 33 | test/classes/catalog_item.rb 34 | test/classes/category.rb 35 | test/classes/city.rb 36 | test/classes/comment.rb 37 | test/classes/country.rb 38 | test/classes/custom_user.rb 39 | test/classes/cutout.rb 40 | test/classes/cutout_aggregator.rb 41 | test/classes/default_user.rb 42 | test/classes/dynamic_finder_user.rb 43 | test/classes/empty_person.rb 44 | test/classes/expire_user.rb 45 | test/classes/expire_user_with_predicate.rb 46 | test/classes/giftcard.rb 47 | test/classes/jigsaw.rb 48 | test/classes/location.rb 49 | test/classes/message.rb 50 | test/classes/note.rb 51 | test/classes/omni_user.rb 52 | test/classes/person.rb 53 | test/classes/photo.rb 54 | test/classes/profile.rb 55 | test/classes/sortable_user.rb 56 | test/classes/timestamp.rb 57 | test/classes/user.rb 58 | test/classes/uuid_default_user.rb 59 | test/classes/uuid_timestamp.rb 60 | test/classes/uuid_user.rb 61 | test/dynamic_finders_test.rb 62 | test/exceptions_test.rb 63 | test/expire_records_test.rb 64 | test/has_one_has_many_test.rb 65 | test/indices_test.rb 66 | test/modules/belongs_to_model_within_module.rb 67 | test/modules/has_many_model_within_module.rb 68 | test/options_test.rb 69 | test/polymorphic_test.rb 70 | test/redis.conf 71 | test/sortable_test.rb 72 | test/test_helper.rb 73 | test/uuid_as_id_test.rb 74 | test/validations_test.rb 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | RedisOrm supposed to be *almost* drop-in replacement of ActiveRecord 2.x. It's based on the [Redis](http://redis.io) - advanced key-value store and is work in progress. 2 | 3 | Here's the standard model definition: 4 | 5 | ```ruby 6 | class User < RedisOrm::Base 7 | property :first_name, String 8 | property :last_name, String 9 | 10 | timestamps 11 | 12 | # OR 13 | # property :created_at, Time 14 | # property :modified_at, Time 15 | 16 | index :last_name 17 | index [:first_name, :last_name] 18 | 19 | has_many :photos 20 | has_one :profile 21 | 22 | after_create :create_mailboxes 23 | 24 | def create_mailboxes 25 | # ... 26 | end 27 | end 28 | ``` 29 | 30 | ## Installing redis_orm 31 | 32 | stable release: 33 | 34 | ```sh 35 | gem install redis_orm 36 | ``` 37 | 38 | or edge version: 39 | 40 | ```sh 41 | git clone git://github.com/german/redis_orm.git 42 | cd redis_orm 43 | bundle install 44 | ``` 45 | 46 | To run the tests you should have redis installed already. Please check [Redis download/installation page](http://redis.io/download). 47 | 48 | ```sh 49 | rspec 50 | ``` 51 | 52 | ## Setting up a connection to the redis server 53 | 54 | If you are using Rails you should initialize redis and set up global $redis variable in *config/initializers/redis.rb* file: 55 | 56 | ```ruby 57 | require 'redis' 58 | $redis = Redis.new(:host => 'localhost', :port => 6379) 59 | ``` 60 | 61 | ## Defining a model and specifing properties 62 | 63 | To specify properties for your model you should use the following syntax: 64 | 65 | ```ruby 66 | class User < RedisOrm::Base 67 | property :first_name, String 68 | property :last_name, String 69 | property :created_at, Time 70 | property :modified_at, Time 71 | end 72 | ``` 73 | 74 | Supported property types: 75 | 76 | * **Integer** 77 | 78 | * **String** 79 | 80 | * **Float** 81 | 82 | * **RedisOrm::Boolean** 83 | there is no Boolean class in Ruby so it's a special class to store TrueClass or FalseClass objects 84 | 85 | * **Time** or **DateTime** 86 | 87 | * **Array** or **Hash** 88 | RedisOrm automatically will handle serializing/deserializing arrays and hashes into strings using Marshal class 89 | 90 | Following options are available in property declaration: 91 | 92 | * **:default** 93 | 94 | The default value of the attribute when it's getting saved w/o any. 95 | 96 | * **:sortable** 97 | 98 | if *true* is specified then you could sort records by this property later 99 | 100 | *Note* that when you're using :sortable option redis_orm maintains one additional list per attribute. Also note that the #create method could be 3 times slower in some cases (this will be improved in future releases), while the #find performance is basically the same (see the "benchmarks/sortable_benchmark.rb"). 101 | 102 | ## Expiring record after certain period of time 103 | 104 | You could expire record stored in Redis by specifying TTL in seconds invoking *expire* method of the class like this: 105 | 106 | ```ruby 107 | class PhantomUser < RedisOrm::Base 108 | property :name, String 109 | property :persist, RedisOrm::Boolean, :default => true 110 | 111 | expire 15.minutes.from_now 112 | end 113 | ``` 114 | 115 | Also you could specify a condition when *expire* would be set on record's key: 116 | 117 | ```ruby 118 | expire 15.minutes.from_now, :if => Proc.new {|r| !r.persist?} 119 | ``` 120 | 121 | Also you could override class method *expire* by using *expire_in* key when saving object: 122 | 123 | ```ruby 124 | ExpireUser.create :name => "Ghost record", :expire_in => 50.minutes.from_now 125 | ``` 126 | 127 | ## Searching records by the value 128 | 129 | Usually it's done via declaring an index and using *:conditions* hash or dynamic finders. For example: 130 | 131 | ```ruby 132 | class User < RedisOrm::Base 133 | property :name, String 134 | 135 | index :name 136 | end 137 | 138 | User.create :name => "germaninthetown" 139 | 140 | # via dynamic finders: 141 | User.find_by_name "germaninthetown" # => found 1 record 142 | User.find_all_by_name "germaninthetown" # => array with 1 record 143 | 144 | # via *:conditions* hash: 145 | User.find(:all, :conditions => {:name => "germaninthetown"}) # => array with 1 record 146 | User.all(:conditions => {:name => "germaninthetown"}) # => array with 1 record 147 | ``` 148 | 149 | Dynamic finders work mostly the way they work in ActiveRecord. The only difference is if you didn't specified index or compound index on the attributes you are searching on the exception will be raised. So you should make an initial analysis of model and determine properties that should be searchable. 150 | 151 | ## Options for #find/#all 152 | 153 | To extract all or part of the associated records you could use 4 options: 154 | 155 | * :limit 156 | 157 | * :offset 158 | 159 | * :order 160 | 161 | Either :desc or :asc (default), since records are stored with *Time.now.to_f* scores, by default they could be fetched only in that (or reversed) order. To order by different property you should: 162 | 163 | 1. specify *:sortable => true* as option in property declaration 164 | 165 | 2. specify the property by which you wish to order *:order => [:name, :desc]* or *:order => [:name]* (:asc order is default) 166 | 167 | * :conditions 168 | 169 | Hash where keys must be equal to the existing property name (there must be index for this property too). 170 | 171 | ```ruby 172 | # for example we associate 2 photos with the album 173 | @album.photos << Photo.create(:image_type => "image/png", :image => "boobs.png") 174 | @album.photos << Photo.create(:image_type => "image/jpeg", :image => "facepalm.jpg") 175 | 176 | @album.photos.all(:limit => 0, :offset => 0) # => [] 177 | @album.photos.all(:limit => 1, :offset => 0).size # => 1 178 | @album.photos.all(:limit => 2, :offset => 0) # [...] 179 | @album.photos.all(:limit => 1, :offset => 1, :conditions => {:image_type => "image/png"}) 180 | @album.photos.find(:all, :order => "asc") 181 | 182 | Photo.find(:first, :order => "desc") 183 | Photo.all(:order => "asc", :limit => 5) 184 | Photo.all(:order => "desc", :limit => 10, :offset => 50) 185 | Photo.all(:order => "desc", :offset => 10, :conditions => {:image_type => "image/jpeg"}) 186 | 187 | Photo.find(:all, :conditions => {:image => "facepalm.jpg"}) # => [...] 188 | Photo.find(:first, :conditions => {:image => "boobs.png"}) # => [...] 189 | ``` 190 | 191 | ## Using UUID instead of numeric id 192 | 193 | You could use universally unique identifiers (UUIDs) instead of a monotone increasing sequence of numbers as id/primary key for your models. 194 | 195 | Example of UUID: b57525b09a69012e8fbe001d61192f09. 196 | 197 | To enable UUIDs you should invoke *use_uuid_as_id* class method: 198 | 199 | ```ruby 200 | class User < RedisOrm::Base 201 | use_uuid_as_id 202 | 203 | property :name, String 204 | 205 | property :created_at, Time 206 | end 207 | ``` 208 | 209 | [UUID](https://rubygems.org/gems/uuid) gem is installed as a dependency. 210 | 211 | An excerpt from https://github.com/assaf/uuid : 212 | 213 | UUID (universally unique identifier) are guaranteed to be unique across time and space. 214 | 215 | A UUID is 128 bit long, and consists of a 60-bit time value, a 16-bit sequence number and a 48-bit node identifier. 216 | 217 | Note: when using a forking server (Unicorn, Resque, Pipemaster, etc) you don’t want your forked processes using the same sequence number. Make sure to increment the sequence number each time a worker forks. 218 | 219 | For example, in config/unicorn.rb: 220 | 221 | ```ruby 222 | after_fork do |server, worker| 223 | UUID.generator.next_sequence 224 | end 225 | ``` 226 | 227 | ## Indices 228 | 229 | Indices are used in a different way then they are used in relational databases. In redis_orm they are used to find record by they value rather then to quick access them. 230 | 231 | You could add index to any attribute of the model (index also could be compound): 232 | 233 | ```ruby 234 | class User < RedisOrm::Base 235 | property :first_name, String 236 | property :last_name, String 237 | 238 | index :first_name 239 | index [:first_name, :last_name] 240 | end 241 | ``` 242 | 243 | With index defined for the property (or properties) the id of the saved object is stored in the sorted set with special name, so it could be found later by the value. For example with defined User model from the above code: 244 | 245 | ```ruby 246 | user = User.new :first_name => "Robert", :last_name => "Pirsig" 247 | user.save 248 | 249 | # 2 redis keys are created "user:first_name:Robert" and "user:first_name:Robert:last_name:Pirsig" so we could search records like this: 250 | 251 | User.find_by_first_name("Robert") # => user 252 | User.find_all_by_first_name("Robert") # => [user] 253 | User.find_by_first_name_and_last_name("Robert", "Pirsig") # => user 254 | User.find_all_by_first_name_and_last_name("Chris", "Pirsig") # => [] 255 | ``` 256 | 257 | Indices on associations are also created/deleted/updated when objects with has_many/belongs_to associations are created/deleted/updated (excerpt from association_indices_test.rb): 258 | 259 | ```ruby 260 | class Article < RedisOrm::Base 261 | property :title, String 262 | has_many :comments 263 | end 264 | 265 | class Comment < RedisOrm::Base 266 | property :body, String 267 | property :moderated, RedisOrm::Boolean, :default => false 268 | index :moderated 269 | belongs_to :article 270 | end 271 | 272 | article = Article.create :title => "DHH drops OpenID on 37signals" 273 | comment1 = Comment.create :body => "test" 274 | comment2 = Comment.create :body => "test #2", :moderated => true 275 | 276 | article.comments << [comment1, comment2] 277 | 278 | # here besides usual indices for each comment, 2 association indices are created so #find with *:conditions* on comments should work 279 | 280 | article.comments.find(:all, :conditions => {:moderated => true}) 281 | article.comments.find(:all, :conditions => {:moderated => false}) 282 | ``` 283 | 284 | Index definition supports following options: 285 | 286 | * **:unique** Boolean default: false 287 | 288 | If true is specified then value is stored in ordinary key-value structure with index as the key, otherwise the values are added to sorted set with index as the key and *Time.now.to_f* as a score. 289 | 290 | * **:case_insensitive** Boolean default: false 291 | 292 | If true is specified then property values are saved downcased (and then are transformed to downcase form when searching). Works for compound indices too. 293 | 294 | ## Associations 295 | 296 | RedisOrm provides 3 association types: 297 | 298 | * has_one 299 | 300 | * has_many 301 | 302 | * belongs_to 303 | 304 | HABTM association could be emulated with 2 has_many declarations in related models. 305 | 306 | ### has_many/belongs_to associations 307 | 308 | ```ruby 309 | class Article < RedisOrm::Base 310 | property :title, String 311 | has_many :comments 312 | end 313 | 314 | class Comment < RedisOrm::Base 315 | property :body, String 316 | belongs_to :article 317 | end 318 | 319 | article = Article.create :title => "DHH drops OpenID support on 37signals" 320 | comment1 = Comment.create :body => "test" 321 | comment2 = Comment.create :body => "test #2" 322 | 323 | article.comments << [comment1, comment2] 324 | 325 | # or rewrite associations 326 | article.comments = [comment1, comment2] 327 | 328 | article.comments # => [comment1, comment2] 329 | comment1.article # => article 330 | comment2.article # => article 331 | ``` 332 | 333 | Backlinks are automatically created. 334 | 335 | ### has_one/belongs_to associations 336 | 337 | ```ruby 338 | class User < RedisOrm::Base 339 | property :name, String 340 | has_one :profile 341 | end 342 | 343 | class Profile < RedisOrm::Base 344 | property :age, Integer 345 | 346 | validates_presence_of :age 347 | belongs_to :user 348 | end 349 | 350 | user = User.create :name => "Haruki Murakami" 351 | profile = Profile.create :age => 26 352 | user.profile = profile 353 | 354 | user.profile # => profile 355 | profile.user # => user 356 | ``` 357 | 358 | Backlink is automatically created. 359 | 360 | ### has_many/has_many associations (HABTM) 361 | 362 | ```ruby 363 | class Article < RedisOrm::Base 364 | property :title, String 365 | has_many :categories 366 | end 367 | 368 | class Category < RedisOrm::Base 369 | property :name, String 370 | has_many :articles 371 | end 372 | 373 | article = Article.create :title => "DHH drops OpenID support on 37signals" 374 | 375 | cat1 = Category.create :name => "Nature" 376 | cat2 = Category.create :name => "Art" 377 | cat3 = Category.create :name => "Web" 378 | 379 | article.categories << [cat1, cat2, cat3] 380 | 381 | article.categories # => [cat1, cat2, cat3] 382 | cat1.articles # => [article] 383 | cat2.articles # => [article] 384 | cat3.articles # => [article] 385 | ``` 386 | 387 | Backlinks are automatically created. 388 | 389 | ### Self-referencing association 390 | 391 | ```ruby 392 | class User < RedisOrm::Base 393 | property :name, String 394 | index :name 395 | has_many :users, :as => :friends 396 | end 397 | 398 | me = User.create :name => "german" 399 | friend1 = User.create :name => "friend1" 400 | friend2 = User.create :name => "friend2" 401 | 402 | me.friends << [friend1, friend2] 403 | 404 | me.friends # => [friend1, friend2] 405 | friend1.friends # => [] 406 | friend2.friends # => [] 407 | ``` 408 | 409 | As an exception if *:as* option for the association is provided the backlinks aren't created. 410 | 411 | ### Polymorphic associations 412 | 413 | Polymorphic associations work the same way they do in ActiveRecord (2 keys are created to store type and id of the record) 414 | 415 | ```ruby 416 | class CatalogItem < RedisOrm::Base 417 | property :title, String 418 | 419 | belongs_to :resource, :polymorphic => true 420 | end 421 | 422 | class Book < RedisOrm::Base 423 | property :price, Integer 424 | property :title, String 425 | 426 | has_one :catalog_item 427 | end 428 | 429 | class Giftcard < RedisOrm::Base 430 | property :price, Integer 431 | property :title, String 432 | 433 | has_one :catalog_item 434 | end 435 | 436 | book = Book.create :title => "Permutation City", :author => "Egan Greg", :price => 1529 437 | giftcard = Giftcard.create :title => "Happy New Year!" 438 | 439 | ci1 = CatalogItem.create :title => giftcard.title 440 | ci1.resource = giftcard 441 | 442 | ci2 = CatalogItem.create :title => book.title 443 | ci2.resource = book 444 | ``` 445 | 446 | All associations supports following options: 447 | 448 | * *:as* 449 | 450 | Symbol Association could be accessed by provided name 451 | 452 | * *:dependent* 453 | 454 | Symbol could be either :destroy or :nullify (default value) 455 | 456 | ### Clearing/reseting associations 457 | 458 | You could clear/reset associations by assigning appropriately nil/[] to it: 459 | 460 | ```ruby 461 | # has_many association 462 | @article.comments << [@comment1, @comment2] 463 | @article.comments.count # => 2 464 | @comment1.article # => @article 465 | 466 | # clear 467 | @article.comments = [] 468 | @article.comments.count # => 0 469 | @comment1.article # => nil 470 | 471 | # belongs_to (same for has_one) 472 | @article.comments << [@comment1, @comment2] 473 | @article.comments.count # => 2 474 | @comment1.article # => @article 475 | 476 | # clear 477 | @comment1.article = nil 478 | @article.comments.count # => 1 479 | @comment1.article # => nil 480 | ``` 481 | 482 | For more examples please check test/associations_test.rb and test/polymorphic_test.rb 483 | 484 | ## Validation 485 | 486 | RedisOrm includes ActiveModel::Validations. So all well-known validation callbacks are already in. An excerpt from test/validations_test.rb: 487 | 488 | ```ruby 489 | class Photo < RedisOrm::Base 490 | property :image, String 491 | 492 | validates_presence_of :image 493 | validates_length_of :image, :in => 7..32 494 | validates_format_of :image, :with => /\w*\.(gif|jpe?g|png)/ 495 | end 496 | ``` 497 | 498 | ## Callbacks 499 | 500 | RedisOrm provides 6 standard callbacks: 501 | 502 | ```ruby 503 | after_save :callback 504 | before_save :callback 505 | after_create :callback 506 | before_create :callback 507 | after_destroy :callback 508 | before_destroy :callback 509 | ``` 510 | 511 | They are implemented differently than in ActiveModel though work as expected: 512 | 513 | ```ruby 514 | class Comment < RedisOrm::Base 515 | property :text, String 516 | 517 | belongs_to :user 518 | 519 | before_save :trim_whitespaces 520 | 521 | def trim_whitespaces 522 | self.text = self.text.strip 523 | end 524 | end 525 | ``` 526 | 527 | ## Saving records 528 | 529 | When saving object standard ActiveModel's #valid? method is invoked at first. Then appropriate callbacks are run. Then new Hash in Redis is created with keys/values equal to the properties/values of the saving object. 530 | 531 | The object's id is stored in "model_name:ids" sorted set with Time.now.to_f as a score. So records are ordered by created_at time by default. Then record's indices are created/updated. 532 | 533 | ## Dirty 534 | 535 | Redis_orm also provides dirty methods to check whether the property has changed and what are these changes. To check it you could use 2 methods: #property_changed? (returns true or false) and #property_changes (returns array with changed values). 536 | 537 | ## File attachment management with paperclip and redis 538 | 539 | [3 simple steps](http://def-end.com/post/6669884103/file-attachment-management-with-paperclip-and-redis) you should follow to manage your file attachments with redis and paperclip. 540 | 541 | ## Tests 542 | 543 | Though I'm a big fan of the Test::Unit all tests are based on RSpec. And the only reason I use RSpec is possibility to define *before(:all)* and *after(:all)* hooks. So I could spawn/kill redis-server's process (from test_helper.rb): 544 | 545 | ```ruby 546 | RSpec.configure do |config| 547 | config.before(:all) do 548 | path_to_conf = File.dirname(File.expand_path(__FILE__)) + "/redis.conf" 549 | $redis_pid = spawn 'redis-server ' + path_to_conf, :out => "/dev/null" 550 | sleep(0.3) # must be some delay otherwise "Connection refused - Unable to connect to Redis" 551 | path_to_socket = File.dirname(File.expand_path(__FILE__)) + "/../redis.sock" 552 | $redis = Redis.new(:host => 'localhost', :path => path_to_socket) 553 | end 554 | 555 | config.before(:each) do 556 | $redis.flushall if $redis 557 | end 558 | 559 | config.after(:each) do 560 | $redis.flushall if $redis 561 | end 562 | 563 | config.after(:all) do 564 | Process.kill 9, $redis_pid.to_i if $redis_pid 565 | end 566 | end 567 | ``` 568 | 569 | To run all tests just invoke *rake test* 570 | 571 | ## Contributors 572 | 573 | [Tatsuya Sato](https://github.com/satoryu) 574 | 575 | Copyright © 2011 Dmitrii Samoilov, released under the MIT license 576 | 577 | Permission is hereby granted, free of charge, to any person obtaining a copy 578 | of this software and associated documentation files (the "Software"), to deal 579 | in the Software without restriction, including without limitation the rights 580 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 581 | copies of the Software, and to permit persons to whom the Software is 582 | furnished to do so, subject to the following conditions: 583 | 584 | The above copyright notice and this permission notice shall be included in 585 | all copies or substantial portions of the Software. 586 | 587 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 588 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 589 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 590 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 591 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 592 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 593 | THE SOFTWARE. 594 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake' 3 | require 'rake/testtask' 4 | 5 | =begin 6 | require 'echoe' 7 | 8 | Echoe.new('redis_orm', '0.7') do |p| 9 | p.description = "ORM for Redis (advanced key-value storage) with ActiveRecord API" 10 | p.url = "https://github.com/german/redis_orm" 11 | p.author = "Dmitrii Samoilov" 12 | p.email = "germaninthetown@gmail.com" 13 | p.dependencies = ["activesupport >=3.0.0", "activemodel >=3.0.0", "redis >=2.2.0", "uuid >=2.3.2"] 14 | p.development_dependencies = ["rspec >=2.5.0"] 15 | end 16 | =end 17 | 18 | task :default => :test 19 | 20 | desc 'Test the redis_orm functionality' 21 | Rake::TestTask.new(:test) do |t| 22 | t.libs << 'lib' 23 | t.test_files = FileList['test/**/*_test.rb', 'spec/**/*_spec.rb'] 24 | t.verbose = true 25 | end 26 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * add rake task to create index keys to existing records after the index was added to the column, something like *rake redis_orm:update_index_on zip* 2 | * add named_scopes 3 | * Sinatra based admin interface to overview all redis_orm keys in redis 4 | * ActiveRecord 3.x API 5 | * refactoring ;) 6 | -------------------------------------------------------------------------------- /benchmarks/sortable_benchmark.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/../lib/redis_orm') 2 | require 'benchmark' 3 | 4 | class User < RedisOrm::Base 5 | property :name, String 6 | property :age, Integer 7 | property :wage, Float 8 | 9 | index :name 10 | index :age 11 | end 12 | 13 | class SortableUser < RedisOrm::Base 14 | property :name, String, :sortable => true 15 | property :age, Integer, :sortable => true 16 | property :wage, Float, :sortable => true 17 | 18 | index :name 19 | index :age 20 | end 21 | 22 | path_to_conf = File.dirname(File.expand_path(__FILE__)) + "/../test/redis.conf" 23 | $redis_pid = spawn 'redis-server ' + path_to_conf, :out => "/dev/null" 24 | sleep(0.3) # must be some delay otherwise "Connection refused - Unable to connect to Redis" 25 | path_to_socket = File.dirname(File.expand_path(__FILE__)) + "/../redis.sock" 26 | begin 27 | $redis = Redis.new(:host => 'localhost', :path => path_to_socket) 28 | rescue => e 29 | puts 'Unable to create connection to the redis server: ' + e.message.inspect 30 | Process.kill 9, $redis_pid.to_i if $redis_pid 31 | end 32 | 33 | n = 100 34 | Benchmark.bmbm do |x| 35 | x.report("creating regular user:") { for i in 1..n; u = User.create(:name => "user#{i}", :age => i, :wage => 100*i); end} 36 | x.report("creating user w/ sortable attrs:") { for i in 1..n; u = SortableUser.create(:name => "user#{i}", :age => i, :wage => 100*i); end } 37 | end 38 | 39 | Benchmark.bmbm do |x| 40 | x.report("finding regular users:") { User.find(:all, :limit => 5, :offset => 10) } 41 | x.report("finding users w/ sortable attrs:") { SortableUser.find(:all, :limit => 5, :offset => 10, :order => [:name, :asc]) } 42 | end 43 | 44 | $redis.flushall if $redis 45 | Process.kill 9, $redis_pid.to_i if $redis_pid 46 | -------------------------------------------------------------------------------- /lib/rails/generators/redis_orm/model/model_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/named_base' 3 | 4 | module RedisOrm 5 | module Generators 6 | class ModelGenerator < ::Rails::Generators::NamedBase 7 | source_root File.expand_path('../templates', __FILE__) 8 | 9 | desc "Creates a RedisOrm model" 10 | argument :attributes, type: :array, default: [], banner: "field:type field:type" 11 | 12 | check_class_collision 13 | 14 | def create_model_file 15 | template "model.rb.erb", File.join('app/models', class_path, "#{file_name}.rb") 16 | end 17 | 18 | hook_for :test_framework 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rails/generators/redis_orm/model/templates/model.rb.erb: -------------------------------------------------------------------------------- 1 | class <%= class_name %> < RedisOrm::Base 2 | <% attributes.each do |attr| %> 3 | property :<%= attr.name %>, <%= attr.type.to_s.camelcase %> 4 | <% end -%> 5 | end 6 | -------------------------------------------------------------------------------- /lib/redis_orm.rb: -------------------------------------------------------------------------------- 1 | require 'active_model' 2 | require 'redis' 3 | require 'uuid' 4 | require_relative 'redis_orm/associations/belongs_to' 5 | require_relative 'redis_orm/associations/has_many_helper' 6 | require_relative 'redis_orm/associations/has_many_proxy' 7 | require_relative 'redis_orm/associations/has_many' 8 | require_relative 'redis_orm/associations/has_one' 9 | require_relative 'redis_orm/utils' 10 | require_relative 'redis_orm/redis_orm' 11 | -------------------------------------------------------------------------------- /lib/redis_orm/associations/belongs_to.rb: -------------------------------------------------------------------------------- 1 | module RedisOrm 2 | module Associations 3 | module BelongsTo 4 | # class Avatar < RedisOrm::Base 5 | # belongs_to :user 6 | # end 7 | # 8 | # class User < RedisOrm::Base 9 | # has_many :avatars 10 | # end 11 | # 12 | # avatar.user => avatar:234:user => 1 => User.find(1) 13 | def belongs_to(foreign_model, options = {}) 14 | class_associations = class_variable_get(:"@@associations") 15 | belongs_to_hash = { 16 | type: :belongs_to, 17 | foreign_model: foreign_model, 18 | options: options 19 | } 20 | class_variable_get(:"@@associations")[model_name.singular] << belongs_to_hash 21 | 22 | foreign_model_name = options[:as] ? options[:as].to_sym : foreign_model.to_sym 23 | 24 | if options[:index] 25 | index = Index.new(foreign_model_name, {reference: true}) 26 | class_variable_get(:"@@indices")[model_name.singular] << index 27 | end 28 | 29 | define_method foreign_model_name do 30 | __key__ = "#{model_name.singular}:#{@id}:#{foreign_model_name}" 31 | 32 | if options[:polymorphic] 33 | model_type = $redis.get("#{model_name.singular}:#{id}:#{foreign_model_name}_type") 34 | if model_type 35 | model_type.to_s.camelize.constantize.find($redis.get "#{__key__}_id") 36 | end 37 | else 38 | # find model even if it's in some module 39 | full_model_scope = RedisOrm::Base.descendants.detect{|desc| desc.to_s.split('::').include?(foreign_model.to_s.camelize) } 40 | if full_model_scope 41 | full_model_scope.find($redis.get __key__) 42 | else 43 | foreign_model.to_s.camelize.constantize.find($redis.get __key__) 44 | end 45 | end 46 | end 47 | 48 | # look = Look.create :title => 'test' 49 | # look.user = User.find(1) => look:23:user => 1 50 | define_method "#{foreign_model_name}=" do |assoc_with_record| 51 | # we need to store this to clear old association later 52 | old_assoc = self.send(foreign_model_name) 53 | __key__ = "#{model_name.singular}:#{id}:#{foreign_model_name}" 54 | 55 | # find model even if it's in some module 56 | full_model_scope = RedisOrm::Base.descendants.detect do |desc| 57 | desc.to_s.split('::').include?(foreign_model.to_s.camelize) 58 | end 59 | 60 | if options[:polymorphic] 61 | $redis.set("#{__key__}_type", assoc_with_record.model_name.singular) 62 | $redis.set("#{__key__}_id", assoc_with_record.id) 63 | else 64 | if assoc_with_record.nil? 65 | $redis.del(__key__) 66 | elsif [foreign_model.to_s, full_model_scope.model_name.singular].include?(assoc_with_record.model_name.singular) 67 | $redis.set(__key__, assoc_with_record.id) 68 | else 69 | raise TypeMismatchError 70 | end 71 | end 72 | 73 | # handle indices for references 74 | self.get_indices.select{|index| index.options[:reference]}.each do |index| 75 | # delete old reference that points to the old associated record 76 | if !old_assoc.nil? 77 | prepared_index = [model_name.singular, index.name, old_assoc.id].join(':') 78 | prepared_index.downcase! if index.options[:case_insensitive] 79 | 80 | if index.options[:unique] 81 | $redis.del(prepared_index, id) 82 | else 83 | $redis.zrem(prepared_index, id) 84 | end 85 | end 86 | 87 | # if new associated record is nil then skip to next index (since old associated record was already unreferenced) 88 | next if assoc_with_record.nil? 89 | 90 | prepared_index = [model_name.singular, index.name, assoc_with_record.id].join(':') 91 | 92 | prepared_index.downcase! if index.options[:case_insensitive] 93 | 94 | if index.options[:unique] 95 | $redis.set(prepared_index, id) 96 | else 97 | $redis.zadd(prepared_index, Time.now.to_f, id) 98 | end 99 | end 100 | 101 | # we should have an option to delete created earlier associasion (like 'node.owner = nil') 102 | if assoc_with_record.nil? 103 | # remove old assoc 104 | $redis.zrem("#{old_assoc.model_name.singular}:#{old_assoc.id}:#{model_name.plural}", self.id) if old_assoc 105 | else 106 | # check whether *assoc_with_record* object has *has_many* declaration and 107 | # TODO it states *self.model_name* in plural 108 | # and there is no record yet from the *assoc_with_record*'s side 109 | # (in order not to provoke recursion) 110 | if class_associations[assoc_with_record.model_name.singular].detect{|h| h[:type] == :has_many && h[:foreign_models] == model_name.plural.to_sym} && !$redis.zrank("#{assoc_with_record.model_name.singular}:#{assoc_with_record.id}:#{model_name.plural}", self.id) 111 | # remove old assoc 112 | $redis.zrem("#{old_assoc.model_name.singular}:#{old_assoc.id}:#{model_name.plural}", self.id) if old_assoc 113 | assoc_with_record.send(model_name.plural.to_sym).send(:"<<", self) 114 | 115 | # check whether *assoc_with_record* object has *has_one* declaration and TODO it states *self.model_name* and there is no record yet from the *assoc_with_record*'s side (in order not to provoke recursion) 116 | elsif class_associations[assoc_with_record.model_name.singular].detect{|h| h[:type] == :has_one && h[:foreign_model] == model_name.singular.to_sym} && assoc_with_record.send(model_name.singular.to_sym).nil? 117 | # old association is being rewritten here automatically so we don't have to worry about it 118 | assoc_with_record.send("#{model_name.singular}=", self) 119 | end 120 | end 121 | end 122 | end 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/redis_orm/associations/has_many.rb: -------------------------------------------------------------------------------- 1 | module RedisOrm 2 | module Associations 3 | module HasMany 4 | # user.avatars => user:1:avatars => [1, 22, 234] => Avatar.find([1, 22, 234]) 5 | # options 6 | # *:dependant* key: either *destroy* or *nullify* (default) 7 | def has_many(foreign_models, options = {}) 8 | class_associations = class_variable_get(:"@@associations") 9 | class_associations[model_name.singular] << {:type => :has_many, :foreign_models => foreign_models, :options => options} 10 | 11 | foreign_models_name = options[:as] ? options[:as].to_sym : foreign_models.to_sym 12 | 13 | define_method foreign_models_name.to_sym do 14 | Associations::HasManyProxy.new(model_name.singular, id, foreign_models, options) 15 | end 16 | 17 | # user = User.find(1) 18 | # user.avatars = Avatar.find(23) => user:1:avatars => [23] 19 | define_method "#{foreign_models_name}=" do |records| 20 | if !options[:as] 21 | # clear old assocs from related models side 22 | old_records = self.send(foreign_models).to_a 23 | if !old_records.empty? 24 | # cache here which association with current model have old record's model 25 | has_many_assoc = old_records[0].get_associations.detect do |h| 26 | h[:type] == :has_many && h[:foreign_models] == model_name.plural.to_sym 27 | end 28 | 29 | has_one_or_belongs_to_assoc = old_records[0].get_associations.detect do |h| 30 | [:has_one, :belongs_to].include?(h[:type]) && h[:foreign_model] == model_name.singular.to_sym 31 | end 32 | 33 | # delete ONLY associations (not any REAL models) 34 | old_records.each do |record| 35 | if has_many_assoc 36 | $redis.zrem "#{record.model_name.singular}:#{record.id}:#{model_name.plural}", id 37 | elsif has_one_or_belongs_to_assoc 38 | $redis.del "#{record.model_name.singular}:#{record.id}:#{model_name.singular}" 39 | end 40 | end 41 | end 42 | 43 | # clear old assocs from this model side 44 | $redis.zremrangebyscore "#{model_name.singular}:#{id}:#{foreign_models_name}", 0, Time.now.to_f 45 | end 46 | 47 | records.to_a.each do |record| 48 | # we use here *foreign_models_name* not *record.model_name.pluralize* because of the :as option 49 | key = "#{model_name.singular}:#{id}:#{foreign_models_name}" 50 | $redis.zadd(key, Time.now.to_f, record.id) 51 | set_expire_on_reference_key(key) 52 | 53 | record.get_indices.each do |index| 54 | # record.model_name.pluralize => foreign_models_name 55 | # TODO record.save_index 56 | save_index_for_associated_record(index, record, [model_name, id, record.model_name.plural]) 57 | end 58 | 59 | # article.comments = [comment1, comment2] 60 | # iterate through the array of comments and create backlink 61 | # check whether *record* object has *has_many* declaration and it states *self.model_name* in plural 62 | if assoc = class_associations[record.model_name.singular].detect{|h| h[:type] == :has_many && h[:foreign_models] == record.model_name.plural.to_sym} #&& !$redis.zrank("#{record.model_name}:#{record.id}:#{model_name.pluralize}", id)#record.model_name.to_s.camelize.constantize.find(id).nil? 63 | key = "#{record.model_name.singular}:#{record.id}:#{model_name.singular}" 64 | $redis.zadd(key, Time.now.to_f, id) if !$redis.zrank(key, id) 65 | set_expire_on_reference_key(key) 66 | end 67 | 68 | # check whether *record* object has *has_one* declaration and it states *self.model_name* 69 | if assoc = record.get_associations.detect{|h| [:has_one, :belongs_to].include?(h[:type]) && h[:foreign_model] == model_name.singular.to_sym} 70 | foreign_model_name = assoc[:options][:as] ? assoc[:options][:as] : model_name.singular 71 | key = "#{record.model_name.singular}:#{record.id}:#{foreign_model_name}" 72 | 73 | # overwrite assoc anyway so we don't need to check record.send(model_name.to_sym).nil? here 74 | $redis.set(key, id) 75 | set_expire_on_reference_key(key) 76 | end 77 | end 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/redis_orm/associations/has_many_helper.rb: -------------------------------------------------------------------------------- 1 | module RedisOrm 2 | module Associations 3 | module HasManyHelper 4 | private 5 | def save_index_for_associated_record(index, record, index_prefix) 6 | index_name = if index.name.is_a?(Array) # TODO sort alphabetically 7 | index.name.inject(index_prefix) do |sum, index_part| 8 | sum += [index_part, record.public_send(index_part.to_sym)] 9 | end.join(':') 10 | else 11 | index_prefix += [index.name, record.public_send(index.name.to_sym)] 12 | index_prefix.join(':') 13 | end 14 | 15 | index_name.downcase! if index.options[:case_insensitive] 16 | 17 | if index.options[:unique] 18 | $redis.set(index_name, record.id) 19 | else 20 | $redis.zadd(index_name, Time.now.to_f, record.id) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/redis_orm/associations/has_many_proxy.rb: -------------------------------------------------------------------------------- 1 | module RedisOrm 2 | module Associations 3 | class HasManyProxy 4 | include HasManyHelper 5 | 6 | def initialize(receiver_model_name, reciever_id, foreign_models, options) 7 | @records = [] 8 | @reciever_model_name = receiver_model_name.to_s.downcase 9 | @reciever_id = reciever_id 10 | @foreign_models = foreign_models 11 | @options = options 12 | @fetched = false 13 | end 14 | 15 | def receiver_instance 16 | @receiver_instance ||= @reciever_model_name.camelize.constantize.find(@reciever_id) 17 | end 18 | 19 | def fetch 20 | ids = $redis.zrevrangebyscore __key__, Time.now.to_f, 0 21 | @records = @foreign_models.to_s.singularize.camelize.constantize.find(ids) 22 | @fetched = true 23 | end 24 | 25 | def [](index) 26 | fetch if !@fetched 27 | @records[index] 28 | end 29 | 30 | def to_a 31 | fetch if !@fetched 32 | @records 33 | end 34 | 35 | # user = User.find(1) 36 | # user.avatars << Avatar.find(23) => user:1:avatars => [23] 37 | def <<(new_records) 38 | new_records.to_a.each do |record| 39 | $redis.zadd(__key__, Time.now.to_f, record.id) 40 | 41 | receiver_instance.set_expire_on_reference_key(__key__) 42 | 43 | record.get_indices.each do |index| 44 | save_index_for_associated_record(index, record, [@reciever_model_name, @reciever_id, record.model_name.plural]) 45 | end 46 | 47 | if !@options[:as] 48 | record_associations = record.get_associations 49 | 50 | # article.comments << [comment1, comment2] 51 | # iterate through the array of comments and create backlink 52 | # check whether *record* object has *has_many* declaration and TODO it states *self.model_name* in plural and there is no record yet from the *record*'s side (in order not to provoke recursion) 53 | if has_many_assoc = record_associations.detect{|h| h[:type] == :has_many && h[:foreign_models] == @reciever_model_name.pluralize.to_sym} 54 | pluralized_reciever_model_name = if has_many_assoc[:options][:as] 55 | has_many_assoc[:options][:as].pluralize 56 | else 57 | @reciever_model_name.pluralize 58 | end 59 | 60 | reference_key = "#{record.model_name.singular}:#{record.id}:#{pluralized_reciever_model_name}" 61 | 62 | if !$redis.zrank(reference_key, @reciever_id) 63 | $redis.zadd(reference_key, Time.now.to_f, @reciever_id) 64 | receiver_instance.set_expire_on_reference_key(reference_key) 65 | end 66 | # check whether *record* object has *has_one* declaration and TODO it states *self.model_name* and there is no record yet from the *record*'s side (in order not to provoke recursion) 67 | elsif has_one_assoc = record_associations.detect{|h| [:has_one, :belongs_to].include?(h[:type]) && h[:foreign_model] == @reciever_model_name.to_sym} 68 | reciever_model_name = if has_one_assoc[:options][:as] 69 | has_one_assoc[:options][:as].to_sym 70 | else 71 | @reciever_model_name 72 | end 73 | if record.public_send(reciever_model_name).nil? 74 | key = "#{record.model_name.singular}:#{record.id}:#{reciever_model_name}" 75 | $redis.set(key, @reciever_id) 76 | receiver_instance.set_expire_on_reference_key(key) 77 | end 78 | end 79 | end 80 | end 81 | 82 | # return *self* here so calls could be chained 83 | self 84 | end 85 | 86 | def all(options = {}) 87 | if options.is_a?(Hash) && (options[:limit] || options[:offset] || options[:order] || options[:conditions]) 88 | limit = if options[:limit] && options[:offset] 89 | [options[:offset].to_i, options[:limit].to_i] 90 | elsif options[:limit] 91 | [0, options[:limit].to_i] 92 | end 93 | 94 | prepared_index = if options[:conditions] && options[:conditions].is_a?(Hash) 95 | properties = options[:conditions].collect{|key, value| key} 96 | 97 | index = @foreign_models.to_s.singularize.camelize.constantize.find_indices(properties, :first => true) 98 | 99 | raise NotIndexFound if !index 100 | 101 | construct_prepared_index(index, options[:conditions]) 102 | else 103 | __key__ 104 | end 105 | 106 | @records = [] 107 | 108 | # to DRY things up I use here check for index but *else* branch also imply that the index might have be used 109 | # since *prepared_index* vary whether options[:conditions] are present or not 110 | if index && index.options[:unique] 111 | id = $redis.get prepared_index 112 | @records << @foreign_models.to_s.singularize.camelize.constantize.find(id) 113 | else 114 | ids = if options[:order].to_s == 'desc' 115 | $redis.zrevrangebyscore(prepared_index, Time.now.to_f, 0, :limit => limit) 116 | else 117 | $redis.zrangebyscore(prepared_index, 0, Time.now.to_f, :limit => limit) 118 | end 119 | arr = @foreign_models.to_s.singularize.camelize.constantize.find(ids) 120 | @records += arr 121 | end 122 | @fetched = true 123 | @records 124 | else 125 | fetch if !@fetched 126 | @records 127 | end 128 | end 129 | 130 | def find(token = nil, options = {}) 131 | if token.is_a?(String) || token.is_a?(Integer) 132 | record_id = $redis.zrank(__key__, token.to_i) 133 | if record_id 134 | @fetched = true 135 | @records = @foreign_models.to_s.singularize.camelize.constantize.find(token) 136 | else 137 | nil 138 | end 139 | elsif token == :all 140 | all(options) 141 | elsif token == :first 142 | all(options.merge({:limit => 1}))[0] 143 | elsif token == :last 144 | reversed = options[:order] == 'desc' ? 'asc' : 'desc' 145 | all(options.merge({limit: 1, order: reversed}))[0] 146 | end 147 | end 148 | 149 | def delete(id) 150 | $redis.zrem(__key__, id.to_i) 151 | end 152 | 153 | def count 154 | $redis.zcard __key__ 155 | end 156 | 157 | def method_missing(method_name, *args, &block) 158 | fetch if !@fetched 159 | @records.send(method_name, *args, &block) 160 | end 161 | 162 | protected 163 | 164 | # helper method 165 | def __key__ 166 | (@options && @options[:as]) ? "#{@reciever_model_name}:#{@reciever_id}:#{@options[:as]}" : "#{@reciever_model_name}:#{@reciever_id}:#{@foreign_models}" 167 | end 168 | 169 | # "article:1:comments:moderated:true" 170 | def construct_prepared_index(index, conditions_hash) 171 | prepared_index = [@reciever_model_name, @reciever_id, @foreign_models].join(':') 172 | 173 | # in order not to depend on order of keys in *:conditions* hash we rather interate over the index itself and find corresponding values in *:conditions* hash 174 | if index.name.is_a?(Array) 175 | index.name.each do |key| 176 | # raise if User.find_by_firstname_and_castname => there's no *castname* in User's properties 177 | #raise ArgumentsMismatch if !@@properties[model_name].detect{|p| p[:name] == key.to_sym} # TODO 178 | prepared_index += ":#{key}:#{conditions_hash[key]}" 179 | end 180 | else 181 | prepared_index += ":#{index.name}:#{conditions_hash[index.name]}" 182 | end 183 | 184 | prepared_index.downcase! if index.options[:case_insensitive] 185 | 186 | prepared_index 187 | end 188 | end 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/redis_orm/associations/has_one.rb: -------------------------------------------------------------------------------- 1 | module RedisOrm 2 | module Associations 3 | module HasOne 4 | # user.avatars => user:1:avatars => [1, 22, 234] => Avatar.find([1, 22, 234]) 5 | # *options* is a hash and can hold: 6 | # *:as* key 7 | # *:dependant* key: either *destroy* or *nullify* (default) 8 | def has_one(foreign_model, options = {}) 9 | class_associations = class_variable_get(:"@@associations") 10 | class_associations[model_name.singular] << { 11 | type: :has_one, 12 | foreign_model: foreign_model, 13 | options: options 14 | } 15 | 16 | foreign_model_name = if options[:as] 17 | options[:as].to_sym 18 | else 19 | foreign_model.to_sym 20 | end 21 | 22 | if options[:index] 23 | index = Index.new(foreign_model_name, {reference: true}) 24 | class_variable_get(:"@@indices")[model_name.singular] << index 25 | end 26 | 27 | define_method foreign_model_name do 28 | foreign_model.to_s.camelize.constantize.find($redis.get "#{model_name.singular}:#{@id}:#{foreign_model_name}") 29 | end 30 | 31 | # profile = Profile.create :title => 'test' 32 | # user.profile = profile => user:23:profile => 1 33 | define_method "#{foreign_model_name}=" do |assoc_with_record| 34 | # we need to store this to clear old associations later 35 | old_assoc = self.send(foreign_model_name) 36 | 37 | reference_key = "#{model_name.singular}:#{id}:#{foreign_model_name}" 38 | 39 | if assoc_with_record.nil? 40 | $redis.del(reference_key) 41 | elsif assoc_with_record.model_name.singular == foreign_model.to_s 42 | $redis.set(reference_key, assoc_with_record.id) 43 | set_expire_on_reference_key(reference_key) 44 | else 45 | raise TypeMismatchError 46 | end 47 | 48 | # handle indices for references 49 | self.get_indices.select{|index| index.options[:reference]}.each do |index| 50 | # delete old reference that points to the old associated record 51 | if !old_assoc.nil? 52 | prepared_index = [model_name.singular, index.name, old_assoc.id].join(':') 53 | prepared_index.downcase! if index.options[:case_insensitive] 54 | 55 | if index.options[:unique] 56 | $redis.del(prepared_index, id) 57 | else 58 | $redis.zrem(prepared_index, id) 59 | end 60 | end 61 | 62 | # if new associated record is nil then skip to next index (since old associated record was already unreferenced) 63 | next if assoc_with_record.nil? 64 | 65 | prepared_index = [model_name.singular, index.name, assoc_with_record.id].join(':') 66 | 67 | prepared_index.downcase! if index.options[:case_insensitive] 68 | 69 | if index.options[:unique] 70 | $redis.set(prepared_index, id) 71 | else 72 | $redis.zadd(prepared_index, Time.now.to_f, id) 73 | end 74 | end 75 | 76 | if !options[:as] 77 | if assoc_with_record.nil? 78 | # remove old assoc 79 | $redis.zrem("#{old_assoc.model_name.singular}:#{old_assoc.id}:#{model_name.plural}", id) if old_assoc 80 | else 81 | # check whether *assoc_with_record* object has *belongs_to* declaration and TODO it states *self.model_name* and there is no record yet from the *assoc_with_record*'s side (in order not to provoke recursion) 82 | assoc_with_record_has_belongs_to_deslaration = class_associations[assoc_with_record.model_name.singular].detect{|h| [:belongs_to, :has_one].include?(h[:type]) && h[:foreign_model] == model_name.singular.to_sym} && assoc_with_record.send(model_name.singular.to_sym).nil? 83 | 84 | if assoc_with_record_has_belongs_to_deslaration 85 | # old association is being rewritten here automatically so we don't have to worry about it 86 | assoc_with_record.send("#{model_name.singular}=", self) 87 | elsif class_associations[assoc_with_record.model_name.singular].detect{|h| :has_many == h[:type] && h[:foreign_models] == model_name.plural.to_sym} && !$redis.zrank("#{assoc_with_record.model_name.singular}:#{assoc_with_record.id}:#{model_name.plural}", self.id) 88 | # remove old assoc 89 | $redis.zrem("#{old_assoc.model_name.singular}:#{old_assoc.id}:#{model_name.plural}", id) if old_assoc 90 | 91 | # create/add new ones 92 | assoc_with_record.send(model_name.plural.to_sym).send(:"<<", self) 93 | end 94 | end 95 | end 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/redis_orm/redis_orm.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/inflector/inflections' 2 | require 'active_support/inflector/transliterate' 3 | require 'active_support/inflector/methods' 4 | require 'active_support/inflections' 5 | require 'active_support/core_ext/string/inflections' 6 | 7 | require 'active_support/core_ext/time/acts_like' 8 | require 'active_support/core_ext/time/calculations' 9 | require 'active_support/core_ext/time/conversions' 10 | #require 'active_support/core_ext/time/marshal' 11 | require 'active_support/core_ext/time/zones' 12 | 13 | require 'active_support/core_ext/numeric' 14 | require 'active_support/core_ext/time/calculations' # local_time for to_time(:local) 15 | require 'active_support/core_ext/string/conversions' # to_time 16 | 17 | module RedisOrm 18 | # there is no Boolean class in Ruby so defining a special class to specify TrueClass or FalseClass objects 19 | class Boolean; end 20 | 21 | # it's raised when found request was initiated on the property/properties which have no index on it 22 | class NotIndexFound < StandardError 23 | end 24 | 25 | class RecordNotFound < StandardError 26 | end 27 | 28 | class TypeMismatchError < StandardError 29 | end 30 | 31 | class ArgumentsMismatch < StandardError 32 | end 33 | 34 | class Index 35 | attr_accessor :name, :options 36 | 37 | def initialize(name, options) 38 | @name = name 39 | @options = options 40 | end 41 | end 42 | 43 | class Base 44 | include ActiveModel::API 45 | include ActiveModel::Validations 46 | include ActiveModel::Dirty 47 | include ActiveModel::Conversion 48 | 49 | include Utils 50 | include Associations::HasManyHelper 51 | 52 | extend Associations::BelongsTo 53 | extend Associations::HasMany 54 | extend Associations::HasOne 55 | 56 | @@properties = Hash.new{|h,k| h[k] = []} 57 | @@indices = Hash.new{|h,model_name| h[model_name] = []} # compound indices are available too 58 | @@associations = Hash.new{|h,model_name| h[model_name] = []} 59 | @@use_uuid_as_id = {} 60 | @@descendants = [] 61 | @@expire = Hash.new{|h,k| h[k] = {}} 62 | 63 | class << self 64 | extend ActiveModel::Callbacks 65 | define_model_callbacks :create, :update, :destroy, :save 66 | 67 | def inherited(from) 68 | @@descendants << from 69 | end 70 | 71 | def descendants 72 | @@descendants 73 | end 74 | 75 | # *options* currently supports 76 | # *unique* Boolean 77 | # *case_insensitive* Boolean 78 | def index(name, options = {}) 79 | @@indices[model_name.singular] << Index.new(name, options) 80 | end 81 | 82 | def property(property_name, class_name, options = {}) 83 | @@properties[model_name.singular] << { 84 | name: property_name, class: class_name.to_s, options: options 85 | } 86 | 87 | attr_accessor property_name 88 | 89 | define_method "#{property_name}=".to_sym do |val| 90 | public_send("#{property_name}_will_change!") unless val == instance_variable_get(:"@#{property_name}") 91 | instance_variable_set(:"@#{property_name}", val) 92 | end 93 | 94 | define_attribute_methods property_name # for ActiveModel::Dirty 95 | end 96 | 97 | def timestamps 98 | if !instance_methods.include?(:created_at) && !instance_methods.include?(:"created_at=") 99 | property :created_at, DateTime, default: ->{ Time.now } 100 | end 101 | 102 | if !instance_methods.include?(:modified_at) && !instance_methods.include?(:"modified_at=") 103 | property :modified_at, DateTime, default: ->{ Time.now } 104 | end 105 | end 106 | 107 | def expire(seconds, options = {}) 108 | @@expire[model_name.singular] = {seconds: seconds, options: options} 109 | end 110 | 111 | def use_uuid_as_id 112 | @@use_uuid_as_id[model_name.singular] = true 113 | @@uuid = UUID.new 114 | end 115 | 116 | def count 117 | $redis.zcard("#{model_name.singular}:ids").to_i 118 | end 119 | 120 | def first(options = {}) 121 | find(:first, options) 122 | end 123 | 124 | def last(options = {}) 125 | find(:last, options) 126 | end 127 | 128 | def find_indices(properties, options = {}) 129 | properties.map!{|p| p.to_sym} 130 | method_name = options[:first] ? :detect : :select 131 | 132 | @@indices[model_name.singular].public_send(method_name) do |index| 133 | if index.name.is_a?(Array) && index.name.size == properties.size 134 | # check the elements not taking into account their order 135 | (index.name & properties).size == properties.size 136 | elsif !index.name.is_a?(Array) && properties.size == 1 137 | index.name == properties[0] 138 | end 139 | end 140 | end 141 | 142 | def construct_prepared_index(index, conditions_hash) 143 | prepared_index = model_name.singular.to_s 144 | 145 | # in order not to depend on order of keys in *:conditions* hash we rather interate over 146 | # the index itself and find corresponding values in *:conditions* hash 147 | if index.name.is_a?(Array) 148 | index.name.each do |key| 149 | # raise if User.find_by_firstname_and_castname => there's no *castname* in User's properties 150 | raise ArgumentsMismatch if !@@properties[model_name.singular].detect{|p| p[:name] == key.to_sym} 151 | prepared_index += ":#{key}:#{conditions_hash[key]}" 152 | end 153 | else 154 | prepared_index += ":#{index.name}:#{conditions_hash[index.name]}" 155 | end 156 | 157 | prepared_index.downcase! if index.options[:case_insensitive] 158 | 159 | prepared_index 160 | end 161 | 162 | # TODO refactor this messy function 163 | def all(options = {}) 164 | limit = if options[:limit] && options[:offset] 165 | [options[:offset].to_i, options[:limit].to_i] 166 | elsif options[:limit] 167 | [0, options[:limit].to_i] 168 | else 169 | [0, -1] 170 | end 171 | 172 | order_max_limit = Time.now.to_f 173 | ids_key = "#{model_name.singular}:ids" 174 | index = nil 175 | 176 | prepared_index = if !options[:conditions].blank? && options[:conditions].is_a?(Hash) 177 | properties = options[:conditions].collect{|key, value| key} 178 | 179 | # if some condition includes object => get only the id of this object 180 | conds = options[:conditions].inject({}) do |sum, item| 181 | key, value = item 182 | if value.respond_to?(:model_name) 183 | sum.merge!({key => value.id}) 184 | else 185 | sum.merge!({key => value}) 186 | end 187 | end 188 | 189 | index = find_indices(properties, {first: true}) 190 | 191 | raise NotIndexFound if !index 192 | 193 | construct_prepared_index(index, conds) 194 | else 195 | if options[:order] && options[:order].is_a?(Array) 196 | model_name.singular 197 | else 198 | ids_key 199 | end 200 | end 201 | 202 | order_by_property_is_string = false 203 | 204 | # if not array => created_at native order (in which ids were pushed to "#{model_name}:ids" set by default) 205 | direction = if !options[:order].blank? 206 | property = {} 207 | dir = if options[:order].is_a?(Array) 208 | property = @@properties[model_name.singular].detect{|prop| prop[:name].to_s == options[:order].first.to_s} 209 | # for String values max limit for search key could be 1.0, but for Numeric values there's actually no limit 210 | order_max_limit = 100_000_000_000 211 | ids_key = "#{prepared_index}:#{options[:order].first}_ids" 212 | options[:order].size == 2 ? options[:order].last : 'asc' 213 | else 214 | property = @@properties[model_name.singular].detect{|prop| prop[:name].to_s == options[:order].to_s} 215 | ids_key = prepared_index 216 | options[:order] 217 | end 218 | if property && property[:class].eql?("String") && property[:options][:sortable] 219 | order_by_property_is_string = true 220 | end 221 | dir 222 | else 223 | ids_key = prepared_index 224 | 'asc' 225 | end 226 | 227 | if order_by_property_is_string 228 | if direction.to_s == 'desc' 229 | ids_length = $redis.llen(ids_key) 230 | limit = if options[:offset] && options[:limit] 231 | [(ids_length - options[:offset].to_i - options[:limit].to_i), (ids_length - options[:offset].to_i - 1)] 232 | elsif options[:limit] 233 | [ids_length - options[:limit].to_i, ids_length] 234 | elsif options[:offset] 235 | [0, (ids_length - options[:offset].to_i - 1)] 236 | else 237 | [0, -1] 238 | end 239 | $redis.lrange(ids_key, *limit).reverse.compact.collect{|id| find(id.split(':').last)} 240 | else 241 | limit = if options[:offset] && options[:limit] 242 | [options[:offset].to_i, (options[:offset].to_i + options[:limit].to_i)] 243 | elsif options[:limit] 244 | [0, options[:limit].to_i - 1] 245 | elsif options[:offset] 246 | [options[:offset].to_i, -1] 247 | else 248 | [0, -1] 249 | end 250 | $redis.lrange(ids_key, *limit).compact.collect{|id| find(id.split(':').last)} 251 | end 252 | else 253 | if index && index.options[:unique] 254 | id = $redis.get prepared_index 255 | model_name.to_s.camelize.constantize.find(id) 256 | else 257 | if direction.to_s == 'desc' 258 | ids = $redis.zrevrangebyscore(ids_key, order_max_limit, 0, limit: limit).compact 259 | ids.collect{|id| find(id)} 260 | else 261 | $redis.zrangebyscore(ids_key, 0, order_max_limit, limit: limit).compact.collect{|id| find(id)} 262 | end 263 | end 264 | end 265 | end 266 | 267 | def find(*args) 268 | if args.first.is_a?(Array) 269 | return [] if args.first.empty? 270 | args.first.map do |id| 271 | record = $redis.hgetall "#{model_name.singular}:#{id}" 272 | if record && !record.empty? 273 | new(record, id, true) 274 | end 275 | end.compact 276 | else 277 | return nil if args.empty? || args.first.nil? 278 | case first = args.shift 279 | when :all 280 | options = args.last 281 | options = {} if !options.is_a?(Hash) 282 | all(options) 283 | when :first 284 | options = args.last 285 | options = {} if !options.is_a?(Hash) 286 | all(options.merge({limit: 1}))[0] 287 | when :last 288 | options = args.last 289 | options = {} if !options.is_a?(Hash) 290 | reversed = options[:order] == 'desc' ? 'asc' : 'desc' 291 | all(options.merge({ limit: 1, order: reversed }))[0] 292 | else 293 | id = first 294 | record = $redis.hgetall "#{model_name.singular}:#{id}" 295 | record && record.empty? ? nil : new(record, id, true) 296 | end 297 | end 298 | end 299 | 300 | def find!(*args) 301 | result = find(*args) 302 | if result.nil? 303 | raise RecordNotFound 304 | else 305 | result 306 | end 307 | end 308 | 309 | def create(options = {}) 310 | run_callbacks :create do 311 | obj = new(options, nil, false) 312 | obj.save 313 | 314 | # make possible binding related models while creating class instance 315 | options.each do |k, v| 316 | if @@associations[model_name.singular].detect{|h| h[:foreign_model] == k.to_sym || h[:options][:as] == k.to_sym} 317 | obj.send("#{k}=", v) 318 | end 319 | end 320 | 321 | $redis.expire(obj.__redis_record_key, options[:expire_in].to_i) if !options[:expire_in].blank? 322 | obj 323 | end 324 | end 325 | 326 | # dynamic finders 327 | def method_missing(method_name, *args, &block) 328 | if method_name =~ /^find_(all_)?by_(\w*)/ 329 | 330 | index = if $2 331 | properties = $2.split('_and_') 332 | raise ArgumentsMismatch if properties.size != args.size 333 | properties_hash = {} 334 | properties.each_with_index do |prop, i| 335 | properties_hash.merge!({prop.to_sym => args[i]}) 336 | end 337 | find_indices(properties, :first => true) 338 | end 339 | 340 | raise NotIndexFound if !index 341 | 342 | prepared_index = construct_prepared_index(index, properties_hash) 343 | 344 | if method_name =~ /^find_by_(\w*)/ 345 | id = if index.options[:unique] 346 | $redis.get prepared_index 347 | else 348 | $redis.zrangebyscore(prepared_index, 0, Time.now.to_f, :limit => [0, 1])[0] 349 | end 350 | model_name.to_s.camelize.constantize.find(id) 351 | elsif method_name =~ /^find_all_by_(\w*)/ 352 | records = [] 353 | 354 | if index.options[:unique] 355 | id = $redis.get prepared_index 356 | records << model_name.to_s.camelize.constantize.find(id) 357 | else 358 | ids = $redis.zrangebyscore(prepared_index, 0, Time.now.to_f) 359 | records += model_name.to_s.camelize.constantize.find(ids) 360 | end 361 | 362 | records 363 | else 364 | nil 365 | end 366 | end 367 | end 368 | 369 | end 370 | 371 | # could be invoked from has_many module (<< method) 372 | def to_a 373 | [self] 374 | end 375 | 376 | def persisted? 377 | self.id.present? 378 | end 379 | 380 | def __redis_record_key 381 | "#{model_name.singular}:#{id}" 382 | end 383 | 384 | def set_expire_on_reference_key(key) 385 | class_expire = @@expire[model_name.singular] 386 | 387 | # if class method *expire* was invoked and number of seconds was specified then set expiry date on the HSET record key 388 | if class_expire[:seconds] 389 | set_expire = true 390 | 391 | if class_expire[:options][:if] && class_expire[:options][:if].class == Proc 392 | # *self* here refers to the instance of class which has_one association 393 | set_expire = class_expire[:options][:if][self] # invoking specified *:if* Proc with current record as *self* 394 | end 395 | 396 | $redis.expire(key, class_expire[:seconds].to_i) if set_expire 397 | end 398 | end 399 | 400 | # is called from RedisOrm::Associations::HasMany to save backlinks to saved records 401 | def get_associations 402 | @@associations[self.model_name.singular] 403 | end 404 | 405 | # is called from RedisOrm::Associations::HasMany to correctly save indices for associated records 406 | def get_indices 407 | @@indices[self.model_name.singular] 408 | end 409 | 410 | def cast_to(type:, value:) 411 | case type 412 | when 'Time' 413 | value.to_s.to_time(:local) rescue Time.now 414 | when 'DateTime' 415 | ::DateTime.parse(value.to_s, false) rescue DateTime.now 416 | when 'Integer' 417 | value.to_i 418 | when 'Float' 419 | value.to_f 420 | when 'RedisOrm::Boolean' 421 | (value == "false" || value == false) ? false : true 422 | when 'Array' 423 | JSON.parse(value) rescue [] 424 | when 'Hash' 425 | JSON.parse(value) rescue {} 426 | else 427 | value.to_s 428 | end 429 | end 430 | 431 | def initialize(attributes = {}, id = nil, was_persisted = false) 432 | aligned_attributes = attributes.dup 433 | clear_changes_information 434 | 435 | if was_persisted # then we need to normalize values from Strings 436 | attributes.map do |attribute, value| 437 | property = @@properties[model_name.singular].find { |prop| prop[:name].to_s == attribute.to_s } 438 | 439 | prop_value = if property && property[:class].to_s != value.class.to_s 440 | cast_to(type: property[:class], value: value) 441 | else 442 | value 443 | end 444 | aligned_attributes[attribute] = prop_value 445 | end 446 | end 447 | 448 | if ! was_persisted 449 | @@properties[model_name.singular].each do |prop| 450 | calculated_value = if aligned_attributes[prop[:name]].nil? && !prop[:options][:default].nil? 451 | if prop[:options][:default].is_a?(Proc) 452 | prop[:options][:default].call 453 | else 454 | prop[:options][:default] 455 | end 456 | elsif aligned_attributes[prop[:name]].nil? 457 | cast_to(type: prop[:class], value: attributes[prop[:name]]) 458 | else 459 | attributes[prop[:name]] 460 | end 461 | 462 | aligned_attributes[prop[:name]] = calculated_value 463 | end 464 | end 465 | 466 | super(aligned_attributes) 467 | 468 | @persisted = was_persisted 469 | 470 | # # if this model uses uuid then id is a string otherwise it should be casted to Integer class 471 | id = @@use_uuid_as_id[model_name.singular] ? id : id.to_i 472 | 473 | instance_variable_set(:"@id", id) if id 474 | 475 | self 476 | end 477 | 478 | def id 479 | @id 480 | end 481 | 482 | alias :to_key :id 483 | 484 | def to_s 485 | inspected = "<#{model_name.name} id: #{@id}, " 486 | inspected += @@properties[model_name.singular].inject([]) do |sum, prop| 487 | property_value = instance_variable_get(:"@#{prop[:name]}") 488 | property_value = '"' + property_value.to_s + '"' if prop[:class].eql?("String") 489 | property_value = 'nil' if property_value.nil? 490 | sum << "#{prop[:name]}: " + property_value.to_s 491 | end.join(', ') 492 | inspected += ">" 493 | inspected 494 | end 495 | 496 | def ==(other) 497 | raise "this object could be comparable only with object of the same class" if other.class != self.class 498 | 499 | same = true 500 | @@properties[model_name.singular].each do |prop| 501 | other_val = other.public_send(prop[:name]) 502 | self_val = public_send(prop[:name]) 503 | # The issue occurs because ruby Time makes comparison with fractions of seconds. 504 | # > t1.to_f 505 | # => 1395955158.547284 506 | # > t2.to_f 507 | # => 1395955158.547298 508 | if ['Time', 'DateTime'].include?(prop[:class].to_s) 509 | same = false if other_val.to_i != self_val.to_i 510 | else 511 | same = false if other_val != self_val 512 | end 513 | end 514 | same = false if self.id != other.id 515 | same 516 | end 517 | 518 | def persisted? 519 | @persisted 520 | end 521 | 522 | def get_next_id 523 | if @@use_uuid_as_id[model_name.singular] 524 | @@uuid.generate(:compact) 525 | else 526 | $redis.incr("#{model_name.singular}:id") 527 | end 528 | end 529 | 530 | def save 531 | run_callbacks :save do 532 | # return false if !valid? 533 | 534 | _check_mismatched_types_for_values 535 | 536 | # store here initial persisted flag so we could invoke :after_create callbacks in the end of *save* function 537 | was_persisted = persisted? 538 | 539 | if persisted? # then there might be old indices 540 | _check_indices_for_persisted # remove old indices if needed 541 | else # !persisted? 542 | @id = get_next_id 543 | $redis.zadd "#{model_name.singular}:ids", Time.now.to_f, @id 544 | @persisted = true 545 | self.created_at = Time.now if respond_to?(:created_at) && self.created_at.nil? 546 | end 547 | 548 | # automatically update *modified_at* property if it was defined 549 | self.modified_at = Time.now if respond_to?(:modified_at) 550 | 551 | _save_to_redis # main work done here 552 | _save_new_indices 553 | 554 | changes_applied # for ActiveModel::Dirty 555 | 556 | true # if there were no errors just return true, so *if obj.save* conditions would work 557 | end 558 | end 559 | 560 | def find_position_to_insert(sortable_key, value) 561 | end_index = $redis.llen(sortable_key) 562 | 563 | return 0 if end_index == 0 564 | 565 | start_index = 0 566 | pivot_index = end_index / 2 567 | 568 | start_el = $redis.lindex(sortable_key, start_index) 569 | end_el = $redis.lindex(sortable_key, end_index - 1) 570 | pivot_el = $redis.lindex(sortable_key, pivot_index) 571 | 572 | while start_index != end_index 573 | # aa..ab..ac..bd <- ad 574 | if start_el.split(':').first > value # Michael > Abe 575 | return 0 576 | elsif end_el.split(':').first < value # Abe < Todd 577 | return end_el 578 | elsif start_el.split(':').first == value # Abe == Abe 579 | return start_el 580 | elsif pivot_el.split(':').first == value # Todd == Todd 581 | return pivot_el 582 | elsif end_el.split(':').first == value 583 | return end_el 584 | elsif (start_el.split(':').first < value) && (pivot_el.split(':').first > value) 585 | start_index = start_index 586 | prev_pivot_index = pivot_index 587 | pivot_index = start_index + ((end_index - pivot_index) / 2) 588 | end_index = prev_pivot_index 589 | elsif (pivot_el.split(':').first < value) && (end_el.split(':').first > value) # M < V && Y > V 590 | start_index = pivot_index 591 | pivot_index = pivot_index + ((end_index - pivot_index) / 2) 592 | end_index = end_index 593 | end 594 | start_el = $redis.lindex(sortable_key, start_index) 595 | end_el = $redis.lindex(sortable_key, end_index - 1) 596 | pivot_el = $redis.lindex(sortable_key, pivot_index) 597 | end 598 | start_el 599 | end 600 | 601 | def update_attributes(attributes) 602 | if attributes.is_a?(Hash) 603 | attributes.each do |key, value| 604 | self.send("#{key}=".to_sym, value) if self.respond_to?("#{key}=".to_sym) 605 | end 606 | end 607 | save 608 | end 609 | 610 | def update_attribute(attribute_name, attribute_value) 611 | self.send("#{attribute_name}=".to_sym, attribute_value) if self.respond_to?("#{attribute_name}=".to_sym) 612 | save 613 | end 614 | 615 | def destroy 616 | run_callbacks :destroy do 617 | @@properties[model_name.singular].each do |prop| 618 | property_value = instance_variable_get(:"@#{prop[:name]}").to_s 619 | $redis.hdel("#{model_name.singular}:#{@id}", prop[:name].to_s) 620 | 621 | if prop[:options][:sortable] 622 | if prop[:class].eql?("String") 623 | $redis.lrem "#{model_name.singular}:#{prop[:name]}_ids", 1, "#{property_value}:#{@id}" 624 | else 625 | $redis.zrem "#{model_name.singular}:#{prop[:name]}_ids", @id 626 | end 627 | end 628 | end 629 | 630 | $redis.zrem "#{model_name.singular}:ids", @id 631 | 632 | # also we need to delete *indices* of associated records 633 | if !@@associations[model_name.singular].empty? 634 | @@associations[model_name.singular].each do |assoc| 635 | if :belongs_to == assoc[:type] 636 | # if assoc has :as option 637 | foreign_model_name = assoc[:options][:as] ? assoc[:options][:as].to_sym : assoc[:foreign_model].to_sym 638 | 639 | if !self.send(foreign_model_name).nil? 640 | @@indices[model_name.singular].each do |index| 641 | keys_to_delete = if index.name.is_a?(Array) 642 | full_index = index.name.inject([]){|sum, index_part| sum << index_part}.join(':') 643 | $redis.keys "#{foreign_model_name}:#{self.public_send(foreign_model_name).id}:#{model_name.plural}:#{full_index}:*" 644 | else 645 | ["#{foreign_model_name}:#{self.send(foreign_model_name).id}:#{model_name.plural}:#{index.name}:#{self.public_send(index.name)}"] 646 | end 647 | keys_to_delete.each do |key| 648 | index.options[:unique] ? $redis.del(key) : $redis.zrem(key, @id) 649 | end 650 | end 651 | end 652 | end 653 | end 654 | end 655 | 656 | # also we need to delete *links* to associated records 657 | if !@@associations[model_name.singular].empty? 658 | @@associations[model_name.singular].each do |assoc| 659 | 660 | foreign_model = "" 661 | records = [] 662 | 663 | case assoc[:type] 664 | when :belongs_to 665 | foreign_model = assoc[:foreign_model].to_s 666 | foreign_model_name = assoc[:options][:as] ? assoc[:options][:as] : assoc[:foreign_model] 667 | if assoc[:options][:polymorphic] 668 | records << self.send(foreign_model_name) 669 | # get real foreign_model's name in order to delete backlinks properly 670 | foreign_model = $redis.get("#{model_name.singular}:#{id}:#{foreign_model_name}_type") 671 | $redis.del("#{model_name.singular}:#{id}:#{foreign_model_name}_type") 672 | $redis.del("#{model_name.singular}:#{id}:#{foreign_model_name}_id") 673 | else 674 | records << self.send(foreign_model_name) 675 | $redis.del "#{model_name.singular}:#{@id}:#{assoc[:foreign_model]}" 676 | end 677 | when :has_one 678 | foreign_model = assoc[:foreign_model].to_s 679 | foreign_model_name = assoc[:options][:as] ? assoc[:options][:as] : assoc[:foreign_model] 680 | records << self.send(foreign_model_name) 681 | 682 | $redis.del "#{model_name.singular}:#{@id}:#{assoc[:foreign_model]}" 683 | when :has_many 684 | foreign_model = assoc[:foreign_models].to_s.singularize 685 | foreign_models_name = assoc[:options][:as] ? assoc[:options][:as] : assoc[:foreign_models] 686 | records += self.send(foreign_models_name) 687 | 688 | # delete all members 689 | $redis.zremrangebyscore "#{model_name.singular}:#{@id}:#{assoc[:foreign_models]}", 0, Time.now.to_f 690 | end 691 | 692 | # check whether foreign_model also has an assoc to the destroying record 693 | # and remove an id of destroing record from each of associated sets 694 | if !records.compact.empty? 695 | records.compact.each do |record| 696 | # we make 3 different checks rather then 1 with elsif to ensure that all associations will be processed 697 | # it's covered in test/option_test in "should delete link to associated record when record was deleted" scenario 698 | # for if class Album; has_one :photo, :as => :front_photo; has_many :photos; end 699 | # end some photo from the album will be deleted w/o these checks only first has_one will be triggered 700 | if @@associations[foreign_model].detect{|h| h[:type] == :belongs_to && h[:foreign_model] == model_name.singular.to_sym} 701 | $redis.del "#{foreign_model}:#{record.id}:#{model_name.singular}" 702 | end 703 | 704 | if @@associations[foreign_model].detect{|h| h[:type] == :has_one && h[:foreign_model] == model_name.singular.to_sym} 705 | $redis.del "#{foreign_model}:#{record.id}:#{model_name.singular}" 706 | end 707 | 708 | if @@associations[foreign_model].detect{|h| h[:type] == :has_many && h[:foreign_models] == model_name.plural.to_sym} 709 | $redis.zrem "#{foreign_model}:#{record.id}:#{model_name.plural}", @id 710 | end 711 | end 712 | end 713 | 714 | if assoc[:options][:dependent] == :destroy 715 | if !records.compact.empty? 716 | records.compact.each do |r| 717 | r.destroy 718 | end 719 | end 720 | end 721 | end 722 | end 723 | 724 | # remove all associated indices 725 | @@indices[model_name.singular].each do |index| 726 | prepared_index = _construct_prepared_index(index) # instance method not class one! 727 | 728 | if index.options[:unique] 729 | $redis.del(prepared_index) 730 | else 731 | $redis.zremrangebyscore(prepared_index, 0, Time.now.to_f) 732 | end 733 | end 734 | 735 | true # if there were no errors just return true, so *if* conditions would work 736 | end 737 | end 738 | 739 | protected 740 | def _construct_prepared_index(index) 741 | index_name = if index.name.is_a?(Array) # TODO sort alphabetically 742 | index.name.inject([model_name.singular]) do |sum, index_part| 743 | sum += [index_part, self.instance_variable_get(:"@#{index_part}")] 744 | end.join(':') 745 | else 746 | [model_name.singular, index.name, self.instance_variable_get(:"@#{index.name}")].join(':') 747 | end 748 | 749 | index_name.downcase! if index.options[:case_insensitive] 750 | index_name 751 | end 752 | 753 | def _check_mismatched_types_for_values 754 | # an exception should be raised before all saving procedures if wrong value type is specified (especcially true for Arrays and Hashes) 755 | @@properties[model_name.singular].each do |prop| 756 | prop_value = self.send(prop[:name].to_sym) 757 | if prop_value && prop[:class] != prop_value.class.to_s && ['Array', 'Hash'].include?(prop[:class].to_s) 758 | raise TypeMismatchError 759 | end 760 | end 761 | end 762 | 763 | def _check_indices_for_persisted 764 | # check whether there's old indices exists and if yes - delete them 765 | @@properties[model_name.singular].each do |prop| 766 | # if there were no changes for current property skip it (indices remains the same) 767 | next if ! self.send(:"#{prop[:name]}_changed?") 768 | 769 | prev_prop_value = public_send("#{prop[:name]}_change").first 770 | prop_value = instance_variable_get(:"@#{prop[:name]}") 771 | 772 | # TODO DRY in destroy also 773 | if prop[:options][:sortable] 774 | if prop[:class].eql?("String") 775 | $redis.lrem "#{model_name.singular}:#{prop[:name]}_ids", 1, "#{prev_prop_value}:#{@id}" 776 | # remove id from every indexed property 777 | @@indices[model_name.singular].each do |index| 778 | $redis.lrem "#{_construct_prepared_index(index)}:#{prop[:name]}_ids", 1, "#{prop_value}:#{@id}" 779 | end 780 | else 781 | $redis.zrem "#{model_name.singular}:#{prop[:name]}_ids", @id 782 | # remove id from every indexed property 783 | @@indices[model_name.singular].each do |index| 784 | $redis.zrem "#{_construct_prepared_index(index)}:#{prop[:name]}_ids", @id 785 | end 786 | end 787 | end 788 | 789 | indices = @@indices[model_name.singular].inject([]) do |sum, index| 790 | if index.name.is_a?(Array) 791 | if index.name.include?(prop[:name]) 792 | sum << index 793 | else 794 | sum 795 | end 796 | else 797 | if index.name == prop[:name] 798 | sum << index 799 | else 800 | sum 801 | end 802 | end 803 | end 804 | 805 | if !indices.empty? 806 | indices.each do |index| 807 | if index.name.is_a?(Array) 808 | keys_to_delete = if index.name.index(prop) == 0 809 | $redis.keys "#{model_name.singular}:#{prop[:name]}#{prev_prop_value}*" 810 | else 811 | $redis.keys "#{model_name.singular}:*#{prop[:name]}:#{prev_prop_value}*" 812 | end 813 | 814 | keys_to_delete.each{|key| $redis.del(key)} 815 | else 816 | key_to_delete = "#{model_name.singular}:#{prop[:name]}:#{prev_prop_value}" 817 | $redis.del key_to_delete 818 | end 819 | 820 | # also we need to delete associated records *indices* 821 | if !@@associations[model_name.singular].empty? 822 | @@associations[model_name.singular].each do |assoc| 823 | if :belongs_to == assoc[:type] 824 | # if association has :as option use it, otherwise use standard :foreign_model 825 | foreign_model_name = assoc[:options][:as] ? assoc[:options][:as].to_sym : assoc[:foreign_model].to_sym 826 | if !self.public_send(foreign_model_name).nil? 827 | if index.name.is_a?(Array) 828 | keys_to_delete = if index.name.index(prop) == 0 829 | $redis.keys "#{assoc[:foreign_model]}:#{self.public_send(assoc[:foreign_model]).id}:#{model_name.plural}:#{prop[:name]}#{prev_prop_value}*" 830 | else 831 | $redis.keys "#{assoc[:foreign_model]}:#{self.public_send(assoc[:foreign_model]).id}:#{model_name.plural}:*#{prop[:name]}:#{prev_prop_value}*" 832 | end 833 | 834 | keys_to_delete.each{|key| $redis.del(key)} 835 | else 836 | beginning_of_the_key = "#{assoc[:foreign_model]}:#{self.public_send(assoc[:foreign_model]).id}:#{model_name.plural}:#{prop[:name]}:" 837 | 838 | $redis.del(beginning_of_the_key + prev_prop_value.to_s) 839 | 840 | index.options[:unique] ? $redis.set((beginning_of_the_key + prop_value.to_s), @id) : $redis.zadd((beginning_of_the_key + prop_value.to_s), Time.now.to_f, @id) 841 | end 842 | end 843 | end 844 | end 845 | end # deleting associated records *indices* 846 | end 847 | end 848 | end 849 | end 850 | 851 | # keys 852 | def model_prop_ids(prop) 853 | "#{model_name.singular}:#{prop[:name]}_ids" 854 | end 855 | 856 | def model_ids 857 | "#{model_name.singular}:_ids" 858 | end 859 | 860 | def __sortable_key(prop, index) 861 | "#{_construct_prepared_index(index)}:#{prop[:name]}_ids" 862 | end 863 | 864 | def _save_to_redis 865 | @@properties[model_name.singular].each do |prop| 866 | prop_value = self.send(prop[:name].to_sym) 867 | 868 | if prop_value.nil? && !prop[:options][:default].nil? 869 | prop_value = prop[:options][:default] 870 | end 871 | 872 | # cast prop_value to proper class 873 | prop_value = case prop[:class] 874 | when /Time|DateTime/ 875 | prop_value.to_s 876 | when 'Integer' 877 | prop_value.to_i 878 | when 'Float' 879 | prop_value.to_f 880 | when 'RedisOrm::Boolean' 881 | (prop_value == "false" || prop_value == false) ? 'false' : 'true' 882 | when /Array|Hash/ 883 | prop_value.to_json 884 | else 885 | prop_value.to_s 886 | end 887 | 888 | #TODO put out of loop 889 | $redis.hset(__redis_record_key, prop[:name].to_s, prop_value) 890 | 891 | set_expire_on_reference_key(__redis_record_key) 892 | 893 | # if some property need to be sortable add id of the record to the appropriate sorted set 894 | if prop[:options][:sortable] 895 | property_value = instance_variable_get(:"@#{prop[:name]}").to_s 896 | if prop[:class].eql?("String") 897 | sortable_key = model_prop_ids(prop) 898 | el_or_position_to_insert = find_position_to_insert(sortable_key, property_value) 899 | el_or_position_to_insert == 0 ? $redis.lpush(sortable_key, "#{property_value}:#{@id}") : $redis.linsert(sortable_key, "AFTER", el_or_position_to_insert, "#{property_value}:#{@id}") 900 | # add to every indexed property 901 | @@indices[model_name.singular].each do |index| 902 | sortable_key = __sortable_key(prop, index) 903 | el_or_position_to_insert == 0 ? $redis.lpush(sortable_key, "#{property_value}:#{@id}") : $redis.linsert(sortable_key, "AFTER", el_or_position_to_insert, "#{property_value}:#{@id}") 904 | end 905 | else 906 | score = case prop[:class] 907 | when "Integer"; property_value.to_f 908 | when "Float"; property_value.to_f 909 | when "RedisOrm::Boolean"; (property_value == true ? 1.0 : 0.0) 910 | when /Time|DateTime/; property_value.to_f 911 | end 912 | $redis.zadd model_prop_ids(prop), score, @id 913 | # add to every indexed property 914 | @@indices[model_name.singular].each do |index| 915 | $redis.zadd __sortable_key(prop, index), score, @id 916 | end 917 | end 918 | end 919 | end 920 | end 921 | 922 | def _save_new_indices 923 | # save new indices (not *reference* onces (for example not these *belongs_to :note, :index => true*)) in order to sort by finders 924 | # city:name:Chicago => 1 925 | @@indices[model_name.singular].reject{|index| index.options[:reference]}.each do |index| 926 | prepared_index = _construct_prepared_index(index) # instance method not class one! 927 | 928 | if index.options[:unique] 929 | $redis.set(prepared_index, @id) 930 | else 931 | $redis.zadd(prepared_index, Time.now.to_f, @id) 932 | end 933 | end 934 | end 935 | end 936 | end 937 | -------------------------------------------------------------------------------- /lib/redis_orm/utils.rb: -------------------------------------------------------------------------------- 1 | module RedisOrm 2 | module Utils 3 | def calculate_key_for_zset(string) 4 | return 0.0 if string.nil? 5 | sum = "" 6 | string.codepoints.each do |codepoint| 7 | sum += ("%05i" % codepoint.to_s) # 5 because 65536 => 2 bytes UTF-8 8 | end 9 | "0.#{sum}".to_f 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /redis_orm.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "redis_orm" 3 | s.version = "0.9" 4 | s.authors = ["Dmitrii Samoilov"] 5 | s.date = "2024-07-19" 6 | s.description = "ORM for Redis (advanced key-value storage) with ActiveRecord API" 7 | s.email = "germaninthetown@gmail.com" 8 | s.extra_rdoc_files = ["CHANGELOG", "LICENSE", "README.md", "TODO", "lib/rails/generators/redis_orm/model/model_generator.rb", "lib/rails/generators/redis_orm/model/templates/model.rb.erb", "lib/redis_orm.rb", "lib/redis_orm/active_model_behavior.rb", "lib/redis_orm/associations/belongs_to.rb", "lib/redis_orm/associations/has_many.rb", "lib/redis_orm/associations/has_many_helper.rb", "lib/redis_orm/associations/has_many_proxy.rb", "lib/redis_orm/associations/has_one.rb", "lib/redis_orm/redis_orm.rb", "lib/redis_orm/utils.rb"] 9 | s.license = 'MIT' 10 | s.homepage = "https://github.com/german/redis_orm" 11 | s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Redis_orm", "--main", "README.md"] 12 | s.require_paths = ["lib"] 13 | s.rubyforge_project = "redis_orm" 14 | s.summary = "ORM for Redis (advanced key-value storage) with ActiveRecord API" 15 | 16 | s.add_runtime_dependency(%q, ["> 5.1"]) 17 | s.add_runtime_dependency(%q, ["> 5.1"]) 18 | s.add_runtime_dependency(%q, [">= 4.2.5"]) 19 | s.add_runtime_dependency(%q, [">= 2.3.2"]) 20 | s.add_development_dependency(%q, [">= 3.10"]) 21 | s.add_development_dependency(%q, [">= 4"]) 22 | s.add_development_dependency(%q, [">= 1.1"]) 23 | end 24 | -------------------------------------------------------------------------------- /spec/association_indices_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "check indices for associations" do 4 | before(:each) do 5 | @article = Article.new :title => "DHH drops OpenID on 37signals" 6 | @article.save 7 | 8 | expect(@article).to be 9 | expect(@article.title).to eq("DHH drops OpenID on 37signals") 10 | 11 | @comment1 = Comment.new :body => "test" 12 | @comment1.save 13 | expect(@comment1).to be 14 | expect(@comment1.body).to eq("test") 15 | expect(@comment1.moderated).to eq(false) 16 | 17 | @comment2 = Comment.new :body => "test #2", :moderated => true 18 | @comment2.save 19 | expect(@comment2).to be 20 | expect(@comment2.body).to eq("test #2") 21 | expect(@comment2.moderated).to eq(true) 22 | end 23 | 24 | it "should properly find associated records (e.g. with :conditions, :order, etc options) '<<' used for association" do 25 | @article.comments << [@comment1, @comment2] 26 | expect(@article.comments.count).to eq(2) 27 | 28 | expect(@article.comments.all(:limit => 1).size).to eq(1) 29 | expect(@article.comments.find(:first)).to be 30 | expect(@article.comments.find(:first).id).to eq(@comment1.id) 31 | expect(@article.comments.find(:last)).to be 32 | expect(@article.comments.find(:last).id).to eq(@comment2.id) 33 | 34 | expect(@article.comments.find(:all, :conditions => {:moderated => true}).size).to eq(1) 35 | expect(@article.comments.find(:all, :conditions => {:moderated => false}).size).to eq(1) 36 | expect(@article.comments.find(:all, :conditions => {:moderated => true})[0].id).to eq(@comment2.id) 37 | expect(@article.comments.find(:all, :conditions => {:moderated => false})[0].id).to eq(@comment1.id) 38 | 39 | expect(@article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1).size).to eq(1) 40 | expect(@article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1).size).to eq(1) 41 | expect(@article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1)[0].id).to eq(@comment2.id) 42 | expect(@article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1)[0].id).to eq(@comment1.id) 43 | 44 | expect(@article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1, :order => :desc).size).to eq(1) 45 | expect(@article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1, :order => :asc).size).to eq(1) 46 | expect(@article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1, :order => :desc)[0].id).to eq(@comment2.id) 47 | expect(@article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1, :order => :asc)[0].id).to eq(@comment1.id) 48 | 49 | @comment1.update_attribute :moderated, true 50 | 51 | # expect(@article.comments.find(:all, :conditions => {:moderated => true}).size).to eq(2) 52 | # expect(@article.comments.find(:all, :conditions => {:moderated => false}).size).to eq(0) 53 | 54 | @comment1.destroy 55 | 56 | expect($redis.zrange("article:#{@article.id}:comments:moderated:true", 0, -1).size).to eq(1) 57 | expect($redis.zrange("article:#{@article.id}:comments:moderated:true", 0, -1)[0]).to eq(@comment2.id.to_s) 58 | # expect($redis.zrange("article:#{@article.id}:comments:moderated:false", 0, -1).size).to eq(0) 59 | expect(@article.comments.find(:all, :conditions => {:moderated => true}).size).to eq(1) 60 | expect(@article.comments.find(:all, :conditions => {:moderated => false}).size).to eq(0) 61 | end 62 | 63 | it "should properly find associated records (e.g. with :conditions, :order, etc options) '=' used for association" do 64 | @article.comments = [@comment1, @comment2] 65 | expect(@article.comments.count).to eq(2) 66 | 67 | expect(@article.comments.all(:limit => 1).size).to eq(1) 68 | expect(@article.comments.find(:first)).to be 69 | expect(@article.comments.find(:first).id).to eq(@comment1.id) 70 | expect(@article.comments.find(:last)).to be 71 | expect(@article.comments.find(:last).id).to eq(@comment2.id) 72 | 73 | expect(@article.comments.find(:all, :conditions => {:moderated => true}).size).to eq(1) 74 | expect(@article.comments.find(:all, :conditions => {:moderated => false}).size).to eq(1) 75 | expect(@article.comments.find(:all, :conditions => {:moderated => true})[0].id).to eq(@comment2.id) 76 | expect(@article.comments.find(:all, :conditions => {:moderated => false})[0].id).to eq(@comment1.id) 77 | 78 | expect(@article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1).size).to eq(1) 79 | expect(@article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1).size).to eq(1) 80 | expect(@article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1)[0].id).to eq(@comment2.id) 81 | expect(@article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1)[0].id).to eq(@comment1.id) 82 | 83 | expect(@article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1, :order => :desc).size).to eq(1) 84 | expect(@article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1, :order => :asc).size).to eq(1) 85 | expect(@article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1, :order => :desc)[0].id).to eq(@comment2.id) 86 | expect(@article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1, :order => :asc)[0].id).to eq(@comment1.id) 87 | 88 | @comment1.update_attribute :moderated, true 89 | expect(@article.comments.find(:all, :conditions => {:moderated => true}).size).to eq(2) 90 | expect(@article.comments.find(:all, :conditions => {:moderated => false}).size).to eq(0) 91 | 92 | @comment1.destroy 93 | expect(@article.comments.find(:all, :conditions => {:moderated => true}).size).to eq(1) 94 | expect(@article.comments.find(:all, :conditions => {:moderated => false}).size).to eq(0) 95 | expect($redis.zrange("article:#{@article.id}:comments:moderated:true", 0, -1).size).to eq(1) 96 | expect($redis.zrange("article:#{@article.id}:comments:moderated:true", 0, -1)[0]).to eq(@comment2.id.to_s) 97 | expect($redis.zrange("article:#{@article.id}:comments:moderated:false", 0, -1).size).to eq(0) 98 | end 99 | 100 | it "should check compound indices for associations" do 101 | friend1 = User.create :name => "Director", :moderator => true, :moderated_area => "films" 102 | friend2 = User.create :name => "Admin", :moderator => true, :moderated_area => "all" 103 | friend3 = User.create :name => "Gena", :moderator => false 104 | 105 | me = User.create :name => "german" 106 | 107 | me.friends << [friend1, friend2, friend3] 108 | 109 | expect(me.friends.count).to eq(3) 110 | expect(me.friends.find(:all, :conditions => {:moderator => true}).size).to eq(2) 111 | expect(me.friends.find(:all, :conditions => {:moderator => false}).size).to eq(1) 112 | 113 | expect(me.friends.find(:all, :conditions => {:moderator => true, :moderated_area => "films"}).size).to eq(1) 114 | expect(me.friends.find(:all, :conditions => {:moderator => true, :moderated_area => "films"})[0].id).to eq(friend1.id) 115 | 116 | # reverse key's order in :conditions hash 117 | expect(me.friends.find(:all, :conditions => {:moderated_area => "all", :moderator => true}).size).to eq(1) 118 | expect(me.friends.find(:all, :conditions => {:moderated_area => "all", :moderator => true})[0].id).to eq(friend2.id) 119 | end 120 | 121 | # TODO check that index assoc shouldn't be created while no assoc_record is provided 122 | 123 | it "should return first model if it exists, when conditions contain associated object" do 124 | user = User.create :name => "Dmitrii Samoilov", :age => 99, :wage => 35_000, 125 | :first_name => "Dmitrii", :last_name => "Samoilov" 126 | note = Note.create :body => "a test to test" 127 | note2 = Note.create :body => "aero" 128 | 129 | note.owner = user 130 | 131 | expect(User.count).to eq(1) 132 | expect(Note.count).to eq(2) 133 | expect($redis.zcard("note:owner:1")).to eq(1) 134 | expect(note.owner).to eq(user) 135 | expect(Note.find(:all, :conditions => {:owner => user})).to eq([note]) 136 | expect(Note.find(:first, :conditions => {:owner => user})).to eq(note) 137 | 138 | note.owner = nil 139 | expect(Note.find(:all, :conditions => {:owner => user})).to eq([]) 140 | expect(Note.find(:first, :conditions => {:owner => user})).to eq(nil) 141 | expect($redis.zcard("note:owner:1")).to eq(0) 142 | end 143 | 144 | it "should return first model if it exists when conditions contain associated object (belongs_to assoc established when creating object)" do 145 | user = User.create :name => "Dmitrii Samoilov", :age => 99, :wage => 35_000, 146 | :first_name => "Dmitrii", :last_name => "Samoilov" 147 | note = Note.create :body => "a test to test", :owner => user 148 | Note.create :body => "aero" # just test what would *find* return if 2 exemplars of Note are created 149 | 150 | expect(User.count).to eq(1) 151 | expect(Note.count).to eq(2) 152 | 153 | expect(note.owner).to eq(user) 154 | 155 | expect(Note.find(:all, :conditions => {:owner => user})).to eq([note]) 156 | expect(Note.find(:first, :conditions => {:owner => user})).to eq(note) 157 | end 158 | 159 | it "should return first model if it exists when conditions contain associated object (has_one assoc established when creating object)" do 160 | profile = Profile.create :title => "a test to test", :name => "german" 161 | user = User.create :name => "Dmitrii Samoilov", :age => 99, :wage => 35_000, 162 | :first_name => "Dmitrii", :last_name => "Samoilov", :profile => profile 163 | User.create :name => "Warren Buffet", :age => 399, :wage => 12_235_000, 164 | :first_name => "Warren", :last_name => "Buffet" 165 | 166 | expect(User.count).to eq(2) 167 | expect(Profile.count).to eq(1) 168 | 169 | expect(profile.user).to eq(user) 170 | 171 | expect(User.find(:all, :conditions => {:profile => profile})).to eq([user]) 172 | expect(User.find(:first, :conditions => {:profile => profile})).to eq(user) 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /spec/associations_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "check associations" do 4 | before(:each) do 5 | @article = Article.new :title => "DHH drops OpenID on 37signals" 6 | @article.save 7 | 8 | @article.should be 9 | @article.title.should == "DHH drops OpenID on 37signals" 10 | 11 | @comment1 = Comment.new :body => "test" 12 | @comment1.save 13 | @comment1.should be 14 | @comment1.body.should == "test" 15 | 16 | @comment2 = Comment.new :body => "test #2" 17 | @comment2.save 18 | @comment2.should be 19 | @comment2.body.should == "test #2" 20 | end 21 | 22 | it "should assign properly from belongs_to side" do 23 | @comment1.article.should == nil 24 | @comment1.article = @article 25 | @comment1.article.id.should == @article.id 26 | @article.comments.count.should == 1 27 | @article.comments[0].id.should == @comment1.id 28 | 29 | @comment2.article.should == nil 30 | @comment2.article = @article 31 | @comment2.article.id.should == @article.id 32 | @article.comments.count.should == 2 33 | @article.comments[0].id.should == @comment2.id 34 | end 35 | 36 | it "should correctly resets associations when nil/[] provided" do 37 | # from has_many proxy side 38 | @article.comments << [@comment1, @comment2] 39 | @article.comments.count.should == 2 40 | expect(@comment1.article.id).to eq(@article.id) 41 | expect(@comment2.article.id).to eq(@article.id) 42 | 43 | # clear 44 | @article.comments = [] 45 | @article.comments.count.should == 0 46 | expect(@comment1.article).to be_nil 47 | expect(@comment2.article).to be_nil 48 | 49 | # from belongs_to side 50 | @article.comments << [@comment1, @comment2] 51 | @article.comments.count.should == 2 52 | @comment1.article.id.should == @article.id 53 | 54 | # clear 55 | @comment1.article = nil 56 | @article.comments.count.should == 1 57 | @comment1.article.should == nil 58 | 59 | # from has_one side 60 | profile = Profile.create :title => "test" 61 | chicago = City.create :name => "Chicago" 62 | 63 | profile.city = chicago 64 | profile.city.name.should == "Chicago" 65 | chicago.profiles.count.should == 1 66 | chicago.profiles[0].id.should == profile.id 67 | 68 | # clear 69 | profile.city = nil 70 | profile.city.should == nil 71 | chicago.profiles.count.should == 0 72 | end 73 | 74 | it "should return array of records for has_many association" do 75 | @article.comments << [] 76 | @article.comments.count.should == 0 77 | 78 | @article.comments = [] 79 | @article.comments.count.should == 0 80 | 81 | @article.comments << [@comment1, @comment2] 82 | #@article.comments.should be_kind_of(Array) 83 | 84 | @article.comments.count.should == 2 85 | @article.comments.size.should == 2 86 | 87 | @comment1.article.should be 88 | @comment2.article.should be 89 | 90 | @comment1.article.id.should == @comment2.article.id 91 | end 92 | 93 | it "should behave as active_record (proxy couldn't return records w/o #all call) += and << behave differently" do 94 | @article.comments << @comment1 << @comment2 95 | @article.comments.count.should == 2 96 | 97 | comments = @article.comments 98 | comments.count.should == 2 99 | 100 | comments = [] 101 | comments += @article.comments 102 | comments.count.should == 2 103 | comments.collect{|c| c.id}.should include(@comment1.id) 104 | comments.collect{|c| c.id}.should include(@comment2.id) 105 | 106 | comments = [] 107 | comments << @article.comments.all 108 | comments.flatten.count.should == 2 109 | 110 | comments = [] 111 | comments << @article.comments 112 | comments.count.should == 1 113 | end 114 | 115 | it "should return 1 comment when second was deleted" do 116 | Comment.count.should == 2 117 | @article.comments << [@comment1, @comment2] 118 | #@article.comments.should be_kind_of(Array) 119 | @article.comments.size.should == 2 120 | 121 | @comment1.destroy 122 | 123 | @article.comments.size.should == 1 124 | @article.comments.count.should == 1 125 | Comment.count.should == 1 126 | end 127 | 128 | it "should leave associations when parent has been deleted (nullify assocs)" do 129 | Comment.count.should == 2 130 | @article.comments << [@comment1, @comment2] 131 | @comment1.article.id.should == @article.id 132 | @comment2.article.id.should == @article.id 133 | #@article.comments.should be_kind_of(Array) 134 | @article.comments.size.should == 2 135 | @article.comments.count.should == 2 136 | 137 | @article.destroy 138 | 139 | Article.count.should == 0 140 | Comment.count.should == 2 141 | end 142 | 143 | it "should replace associations when '=' is used instead of '<<' " do 144 | Comment.count.should == 2 145 | @article.comments << [@comment1, @comment2] 146 | @comment1.article.id.should == @article.id 147 | @comment2.article.id.should == @article.id 148 | @article.comments.size.should == 2 149 | @article.comments.count.should == 2 150 | 151 | @article.comments = [@comment1] 152 | @article.comments.count.should == 1 153 | @article.comments.first.id.should == @comment1.id 154 | 155 | @comment1.article.id.should == @article.id 156 | end 157 | 158 | it "should correctly use many-to-many associations both with '=' and '<<' " do 159 | @cat1 = Category.create :name => "Nature" 160 | @cat2 = Category.create :name => "Art" 161 | @cat3 = Category.create :name => "Web" 162 | 163 | @cat1.name.should == "Nature" 164 | @cat2.name.should == "Art" 165 | @cat3.name.should == "Web" 166 | 167 | @article.categories << [@cat1, @cat2] 168 | 169 | @cat1.articles.count.should == 1 170 | @cat1.articles[0].should == @article 171 | @cat2.articles.count.should == 1 172 | @cat2.articles[0].should == @article 173 | 174 | @article.categories.size.should == 2 175 | @article.categories.count.should == 2 176 | 177 | @article.categories = [@cat1, @cat3] 178 | @article.categories.count.should == 2 179 | @article.categories.map{|c| c.id}.include?(@cat1.id).should be 180 | @article.categories.map{|c| c.id}.include?(@cat3.id).should be 181 | 182 | @cat1.articles.count.should == 1 183 | @cat1.articles[0].should == @article 184 | 185 | @cat3.articles.count.should == 1 186 | @cat3.articles[0].should == @article 187 | 188 | @cat2.articles.count.should == 0 189 | 190 | @cat1.destroy 191 | Category.count.should == 2 192 | @article.categories.count.should == 1 193 | end 194 | 195 | it "should remove old associations and create new ones" do 196 | profile = Profile.new 197 | profile.title = "test" 198 | profile.save 199 | 200 | chicago = City.new 201 | chicago.name = "Chicago" 202 | chicago.save 203 | 204 | washington = City.new 205 | washington.name = "Washington" 206 | washington.save 207 | 208 | profile.city = chicago 209 | profile.city.name.should == "Chicago" 210 | chicago.profiles.count.should == 1 211 | washington.profiles.count.should == 0 212 | chicago.profiles[0].id.should == profile.id 213 | 214 | profile.city = washington 215 | profile.city.name.should == "Washington" 216 | chicago.profiles.count.should == 0 217 | washington.profiles.count.should == 1 218 | washington.profiles[0].id.should == profile.id 219 | end 220 | 221 | it "should maintain correct self referencing link" do 222 | me = User.create :name => "german" 223 | friend1 = User.create :name => "friend1" 224 | friend2 = User.create :name => "friend2" 225 | 226 | me.friends << [friend1, friend2] 227 | 228 | me.friends.count.should == 2 229 | friend1.friends.count.should == 0 230 | friend2.friends.count.should == 0 231 | end 232 | 233 | it "should delete one specific record from an array with associated records" do 234 | me = User.create :name => "german" 235 | friend1 = User.create :name => "friend1" 236 | friend2 = User.create :name => "friend2" 237 | 238 | me.friends << [friend1, friend2] 239 | 240 | me = User.find_by_name 'german' 241 | me.friends.count.should == 2 242 | friend1 = User.find_by_name 'friend1' 243 | friend1.friends.count.should == 0 244 | friend2 = User.find_by_name 'friend2' 245 | friend2.friends.count.should == 0 246 | 247 | me.friends.delete(friend1.id) 248 | me.friends.count.should == 1 249 | me.friends[0].id == friend2.id 250 | User.count.should == 3 251 | end 252 | 253 | it "should create self-referencing link for has_one association" do 254 | m = Message.create :text => "it should create self-referencing link for has_one association" 255 | 256 | r = Message.create :text => "replay" 257 | 258 | r.replay_to = m 259 | 260 | Message.count.should == 2 261 | r.replay_to.should be 262 | r.replay_to.id.should == m.id 263 | 264 | rf = Message.last 265 | rf.replay_to.should be 266 | rf.replay_to.id.should == Message.first.id 267 | end 268 | 269 | it "should find associations within modules" do 270 | BelongsToModelWithinModule::Reply.count.should == 0 271 | essay = Article.create :title => "Red is cluster" 272 | BelongsToModelWithinModule::Reply.create :essay => essay 273 | BelongsToModelWithinModule::Reply.count.should == 1 274 | reply = BelongsToModelWithinModule::Reply.last 275 | reply.essay.should == essay 276 | 277 | HasManyModelWithinModule::SpecialComment.count.should == 0 278 | book = HasManyModelWithinModule::Brochure.create :title => "Red is unstable" 279 | HasManyModelWithinModule::SpecialComment.create :book => book 280 | HasManyModelWithinModule::Brochure.count.should == 1 281 | HasManyModelWithinModule::SpecialComment.count.should == 1 282 | end 283 | 284 | it "should properly handle self-referencing model both belongs_to and has_many/has_one associations" do 285 | comment1 = Comment.create :body => "comment1" 286 | comment11 = Comment.create :body => "comment1.1" 287 | comment12 = Comment.create :body => "comment1.2" 288 | 289 | comment1.replies = [comment11, comment12] 290 | comment1.replies.count.should == 2 291 | comment11.reply_to.should == comment1 292 | comment12.reply_to.should == comment1 293 | end 294 | end 295 | -------------------------------------------------------------------------------- /spec/classes/album.rb: -------------------------------------------------------------------------------- 1 | class Album < RedisOrm::Base 2 | property :title, String 3 | 4 | has_one :photo, as: :front_photo 5 | has_many :photos, dependent: :destroy 6 | end 7 | -------------------------------------------------------------------------------- /spec/classes/article.rb: -------------------------------------------------------------------------------- 1 | class Article < RedisOrm::Base 2 | property :title, String 3 | property :karma, Integer 4 | 5 | has_many :comments 6 | has_many :categories 7 | end 8 | -------------------------------------------------------------------------------- /spec/classes/article_with_comments.rb: -------------------------------------------------------------------------------- 1 | class ArticleWithComments < RedisOrm::Base 2 | property :title, String 3 | property :comments, Array 4 | 5 | property :rates, Hash, default: {'1': 0, '2': 0, '3': 0, '4': 0, '5': 0} 6 | 7 | has_many :categories 8 | end 9 | -------------------------------------------------------------------------------- /spec/classes/book.rb: -------------------------------------------------------------------------------- 1 | class Book < RedisOrm::Base 2 | property :price, Integer, :default => 0 # in cents 3 | property :title, String 4 | 5 | has_one :catalog_item 6 | end 7 | -------------------------------------------------------------------------------- /spec/classes/catalog_item.rb: -------------------------------------------------------------------------------- 1 | class CatalogItem < RedisOrm::Base 2 | property :title, String 3 | 4 | belongs_to :resource, :polymorphic => true 5 | end 6 | -------------------------------------------------------------------------------- /spec/classes/category.rb: -------------------------------------------------------------------------------- 1 | class Category < RedisOrm::Base 2 | property :name, String 3 | property :title, String 4 | 5 | has_many :articles 6 | has_many :photos, :dependent => :nullify 7 | end 8 | -------------------------------------------------------------------------------- /spec/classes/city.rb: -------------------------------------------------------------------------------- 1 | class City < RedisOrm::Base 2 | property :name, String 3 | property :name, String 4 | 5 | has_many :people 6 | has_many :profiles 7 | end 8 | -------------------------------------------------------------------------------- /spec/classes/comment.rb: -------------------------------------------------------------------------------- 1 | class Comment < RedisOrm::Base 2 | property :body, String 3 | property :text, String 4 | property :moderated, RedisOrm::Boolean, :default => false 5 | 6 | index :moderated 7 | 8 | belongs_to :user 9 | belongs_to :article 10 | 11 | has_many :comments, :as => :replies 12 | belongs_to :comment, :as => :reply_to 13 | 14 | before_save :trim_whitespaces 15 | after_save :regenerate_karma 16 | 17 | def trim_whitespaces 18 | self.text = self.text.to_s.strip 19 | end 20 | 21 | def regenerate_karma 22 | if self.user 23 | self.user.update_attribute :karma, (self.user.karma - self.text.length) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/classes/country.rb: -------------------------------------------------------------------------------- 1 | class Country < RedisOrm::Base 2 | property :name, String 3 | 4 | has_many :people 5 | end 6 | -------------------------------------------------------------------------------- /spec/classes/custom_user.rb: -------------------------------------------------------------------------------- 1 | class CustomUser < RedisOrm::Base 2 | property :first_name, String 3 | property :last_name, String 4 | 5 | index :first_name, :unique => false 6 | index :last_name, :unique => false 7 | index [:first_name, :last_name], :unique => true 8 | end 9 | -------------------------------------------------------------------------------- /spec/classes/cutout.rb: -------------------------------------------------------------------------------- 1 | class Cutout < RedisOrm::Base 2 | property :filename, String 3 | 4 | before_create :increase_revisions 5 | before_destroy :decrease_revisions 6 | 7 | def increase_revisions 8 | ca = CutoutAggregator.last 9 | ca.update_attribute(:revision, ca.revision + 1) if ca 10 | end 11 | 12 | def decrease_revisions 13 | ca = CutoutAggregator.first 14 | if ca.revision > 0 15 | ca.update_attribute :revision, ca.revision - 1 16 | end 17 | 18 | ca.destroy if ca.revision == 0 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/classes/cutout_aggregator.rb: -------------------------------------------------------------------------------- 1 | class CutoutAggregator < RedisOrm::Base 2 | property :modified_at, Time 3 | 4 | property :revision, Integer, :default => 0 5 | end 6 | -------------------------------------------------------------------------------- /spec/classes/default_user.rb: -------------------------------------------------------------------------------- 1 | class DefaultUser < RedisOrm::Base 2 | property :name, String, default: "german" 3 | property :age, Integer, default: 26 4 | property :wage, Float, default: 256.25 5 | property :male, RedisOrm::Boolean, default: true 6 | property :admin, RedisOrm::Boolean, default: false 7 | 8 | property :created_at, DateTime 9 | property :modified_at, DateTime 10 | end 11 | -------------------------------------------------------------------------------- /spec/classes/empty_person.rb: -------------------------------------------------------------------------------- 1 | class EmptyPerson 2 | end 3 | -------------------------------------------------------------------------------- /spec/classes/expire_user.rb: -------------------------------------------------------------------------------- 1 | class ExpireUser < RedisOrm::Base 2 | property :name, String 3 | 4 | expire 10.minutes.from_now 5 | 6 | has_many :articles 7 | has_one :profile 8 | end 9 | -------------------------------------------------------------------------------- /spec/classes/expire_user_with_predicate.rb: -------------------------------------------------------------------------------- 1 | class ExpireUserWithPredicate < RedisOrm::Base 2 | property :name, String 3 | property :persist, RedisOrm::Boolean, :default => false 4 | 5 | expire 10.minutes.from_now, :if => Proc.new {|r| !r.persist?} 6 | 7 | has_many :articles 8 | has_one :profile 9 | 10 | def persist? 11 | !!self.persist 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/classes/giftcard.rb: -------------------------------------------------------------------------------- 1 | class Giftcard < RedisOrm::Base 2 | property :price, Integer, :default => 0 # in cents 3 | property :title, String 4 | 5 | has_one :catalog_item 6 | end 7 | -------------------------------------------------------------------------------- /spec/classes/jigsaw.rb: -------------------------------------------------------------------------------- 1 | class Jigsaw < RedisOrm::Base 2 | property :title, String 3 | belongs_to :user 4 | end 5 | -------------------------------------------------------------------------------- /spec/classes/location.rb: -------------------------------------------------------------------------------- 1 | class Location < RedisOrm::Base 2 | property :coordinates, String 3 | 4 | has_many :profiles 5 | end 6 | -------------------------------------------------------------------------------- /spec/classes/message.rb: -------------------------------------------------------------------------------- 1 | class Message < RedisOrm::Base 2 | property :text, String 3 | has_one :message, :as => :replay_to 4 | end 5 | -------------------------------------------------------------------------------- /spec/classes/note.rb: -------------------------------------------------------------------------------- 1 | class Note < RedisOrm::Base 2 | property :body, :string, default: "made by redis_orm" 3 | 4 | belongs_to :user, as: :owner, index: true 5 | end -------------------------------------------------------------------------------- /spec/classes/omni_user.rb: -------------------------------------------------------------------------------- 1 | class OmniUser < RedisOrm::Base 2 | property :email, String 3 | property :uid, Integer 4 | 5 | index :email, :case_insensitive => true 6 | index :uid 7 | index [:email, :uid], :case_insensitive => true 8 | end 9 | -------------------------------------------------------------------------------- /spec/classes/person.rb: -------------------------------------------------------------------------------- 1 | # for second test 2 | class Person < RedisOrm::Base 3 | property :name, String 4 | 5 | belongs_to :location, :polymorphic => true 6 | end 7 | -------------------------------------------------------------------------------- /spec/classes/photo.rb: -------------------------------------------------------------------------------- 1 | class Photo < RedisOrm::Base 2 | property :image, String 3 | property :image_type, String 4 | 5 | property :checked, RedisOrm::Boolean, :default => false 6 | index :checked 7 | 8 | property :inverted, RedisOrm::Boolean, :default => true 9 | index :inverted 10 | 11 | index :image 12 | index [:image, :image_type] 13 | 14 | belongs_to :album 15 | belongs_to :user 16 | belongs_to :category 17 | 18 | # validates :image, presence: true # length 19 | # validates :image, :in => 7..32 20 | # validates_format_of :image, :with => /\w*\.(gif|jpe?g|png)/ 21 | end 22 | -------------------------------------------------------------------------------- /spec/classes/profile.rb: -------------------------------------------------------------------------------- 1 | class Profile < RedisOrm::Base 2 | property :title, String 3 | property :name, String 4 | 5 | belongs_to :user 6 | belongs_to :expire_user 7 | has_one :location 8 | has_one :city 9 | end 10 | -------------------------------------------------------------------------------- /spec/classes/sortable_user.rb: -------------------------------------------------------------------------------- 1 | class SortableUser < RedisOrm::Base 2 | property :name, String, sortable: true 3 | property :age, Integer, sortable: true, default: 26.0 4 | property :wage, Float, sortable: true, default: 20_000 5 | property :address, String, default: "Singa_poor" 6 | 7 | property :test_type_cast, RedisOrm::Boolean, default: false 8 | 9 | index :age 10 | index :name 11 | end 12 | -------------------------------------------------------------------------------- /spec/classes/timestamp.rb: -------------------------------------------------------------------------------- 1 | class TimeStamp < RedisOrm::Base 2 | timestamps 3 | end 4 | -------------------------------------------------------------------------------- /spec/classes/user.rb: -------------------------------------------------------------------------------- 1 | class User < RedisOrm::Base 2 | property :name, String 3 | property :first_name, String 4 | property :last_name, String 5 | property :karma, Integer, default: 1000 6 | property :age, Integer 7 | property :wage, Float 8 | property :male, RedisOrm::Boolean 9 | property :created_at, DateTime 10 | property :modified_at, DateTime 11 | property :gender, RedisOrm::Boolean, default: true 12 | property :moderator, RedisOrm::Boolean, default: false 13 | property :moderated_area, String, default: "messages" 14 | 15 | index :moderator 16 | index [:moderator, :moderated_area] 17 | index :age 18 | index :name 19 | index :first_name 20 | index :last_name 21 | index [:first_name, :last_name] 22 | 23 | has_one :profile, index: true 24 | has_many :comments 25 | has_many :users, as: :friends 26 | has_one :photo, dependent: :destroy 27 | has_many :notes 28 | 29 | after_create :store_in_rating 30 | after_destroy :after_destroy_callback 31 | 32 | def store_in_rating 33 | $redis.zadd "users:sorted_by_rating", 0.0, self.id 34 | end 35 | 36 | def after_destroy_callback 37 | self.comments.map{|c| c.destroy} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/classes/uuid_default_user.rb: -------------------------------------------------------------------------------- 1 | class UuidDefaultUser < RedisOrm::Base 2 | use_uuid_as_id 3 | 4 | property :name, String, :default => "german" 5 | property :age, Integer, :default => 26 6 | property :wage, Float, :default => 256.25 7 | property :male, RedisOrm::Boolean, :default => true 8 | property :admin, RedisOrm::Boolean, :default => false 9 | 10 | property :created_at, DateTime 11 | property :modified_at, DateTime 12 | end 13 | -------------------------------------------------------------------------------- /spec/classes/uuid_timestamp.rb: -------------------------------------------------------------------------------- 1 | class UuidTimeStamp < RedisOrm::Base 2 | use_uuid_as_id 3 | 4 | timestamps 5 | end 6 | -------------------------------------------------------------------------------- /spec/classes/uuid_user.rb: -------------------------------------------------------------------------------- 1 | class UuidUser < RedisOrm::Base 2 | use_uuid_as_id 3 | 4 | property :name, String 5 | property :age, Integer 6 | property :wage, Float 7 | property :male, RedisOrm::Boolean 8 | 9 | property :created_at, DateTime 10 | property :modified_at, DateTime 11 | 12 | has_many :users, :as => :friends 13 | end 14 | -------------------------------------------------------------------------------- /spec/expire_records_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "expire record after specified time" do 4 | it "should create a record and then delete if *expire* method is specified in appropriate class" do 5 | euser = ExpireUser.create :name => "Ghost rider" 6 | $redis.ttl(euser.__redis_record_key).should be > 9.minutes.from_now.to_i 7 | $redis.ttl(euser.__redis_record_key).should be < (10.minutes.from_now.to_i + 1) 8 | end 9 | 10 | it "should create a record and then delete if *expire* method is specified in appropriate class" do 11 | euser = ExpireUserWithPredicate.create :name => "Ghost rider" 12 | $redis.ttl(euser.__redis_record_key).should be > 9.minutes.from_now.to_i 13 | $redis.ttl(euser.__redis_record_key).should be < (10.minutes.from_now.to_i + 1) 14 | 15 | euser2 = ExpireUserWithPredicate.create :name => "Ghost rider", :persist => true 16 | $redis.ttl(euser2.__redis_record_key).should == -1 17 | end 18 | 19 | it "should create a record with an inline *expire* option (which overrides default *expire* value)" do 20 | euser = ExpireUser.create :name => "Ghost rider", :expire_in => 50.minutes.from_now 21 | $redis.ttl(euser.__redis_record_key).should be < (50.minutes.from_now.to_i + 1) 22 | $redis.ttl(euser.__redis_record_key).should be > 49.minutes.from_now.to_i 23 | end 24 | 25 | it "should also create expirable key when record has associated records" do 26 | euser = ExpireUser.create :name => "Ghost rider" 27 | $redis.ttl(euser.__redis_record_key).should be > 9.minutes.from_now.to_i 28 | $redis.ttl(euser.__redis_record_key).should be < (10.minutes.from_now.to_i + 1) 29 | 30 | profile = Profile.create :title => "Profile for ghost rider", :name => "Ghost Rider" 31 | articles = [Article.create(:title => "article1", :karma => 1), Article.create(:title => "article2", :karma => 2)] 32 | 33 | euser.profile = profile 34 | euser.profile.should == profile 35 | $redis.get("expire_user:1:profile").to_i.should == profile.id 36 | $redis.ttl("expire_user:1:profile").should be > 9.minutes.from_now.to_i 37 | $redis.ttl("expire_user:1:profile").should be < (10.minutes.from_now.to_i + 1) 38 | 39 | euser.articles = articles 40 | $redis.zrange("expire_user:1:articles", 0, -1).should =~ articles.map{|a| a.id.to_s} 41 | $redis.ttl("expire_user:1:articles").should be > 9.minutes.from_now.to_i 42 | $redis.ttl("expire_user:1:articles").should be < (10.minutes.from_now.to_i + 1) 43 | end 44 | 45 | it "should also create expirable key when record has associated records (class with predicate expiry)" do 46 | euser2 = ExpireUserWithPredicate.create :name => "Ghost rider", :persist => false 47 | $redis.ttl(euser2.__redis_record_key).should be > 9.minutes.from_now.to_i 48 | $redis.ttl(euser2.__redis_record_key).should be < (10.minutes.from_now.to_i + 1) 49 | 50 | profile = Profile.create :title => "Profile for ghost rider", :name => "Ghost Rider" 51 | articles = [Article.create(:title => "article1", :karma => 1), Article.create(:title => "article2", :karma => 2)] 52 | 53 | euser2.profile = profile 54 | euser2.profile.should == profile 55 | $redis.get("expire_user_with_predicate:1:profile").to_i.should == profile.id 56 | $redis.ttl("expire_user_with_predicate:1:profile").should be > 9.minutes.from_now.to_i 57 | $redis.ttl("expire_user_with_predicate:1:profile").should be < (10.minutes.from_now.to_i + 1) 58 | 59 | euser2.articles << articles 60 | $redis.zrange("expire_user_with_predicate:1:articles", 0, -1).should =~ articles.map{|a| a.id.to_s} 61 | $redis.ttl("expire_user_with_predicate:1:articles").should be > 9.minutes.from_now.to_i 62 | $redis.ttl("expire_user_with_predicate:1:articles").should be < (10.minutes.from_now.to_i + 1) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/generators/model_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'rails/generators/redis_orm/model/model_generator' 3 | 4 | describe RedisOrm::Generators::ModelGenerator do 5 | destination File.expand_path(File.join(File.dirname(__FILE__), 6 | '..', '..', 'tmp')) 7 | 8 | before do 9 | prepare_destination 10 | run_generator args 11 | end 12 | subject { file('app/models/post.rb') } 13 | 14 | context "Given only model's name" do 15 | let(:args) { %w[post] } 16 | 17 | it { should exist } 18 | end 19 | context "Given model's name and attributes" do 20 | let(:args) { %w[post title:string created_at:time] } 21 | 22 | it { should exist } 23 | it "should define properties" do 24 | should contain /property\s+\:title,\sString/ 25 | end 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /spec/models/association_indices_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "check indices for associations" do 4 | before(:each) do 5 | @article = Article.new :title => "DHH drops OpenID on 37signals" 6 | @article.save 7 | 8 | @article.should be 9 | @article.title.should == "DHH drops OpenID on 37signals" 10 | 11 | @comment1 = Comment.new :body => "test" 12 | @comment1.save 13 | @comment1.should be 14 | @comment1.body.should == "test" 15 | @comment1.moderated.should == false 16 | 17 | @comment2 = Comment.new :body => "test #2", :moderated => true 18 | @comment2.save 19 | @comment2.should be 20 | @comment2.body.should == "test #2" 21 | @comment2.moderated.should == true 22 | end 23 | 24 | it "should properly find associated records (e.g. with :conditions, :order, etc options) '<<' used for association" do 25 | @article.comments << [@comment1, @comment2] 26 | @article.comments.count.should == 2 27 | 28 | @article.comments.all(:limit => 1).size.should == 1 29 | @article.comments.find(:first).should be 30 | @article.comments.find(:first).id.should == @comment1.id 31 | @article.comments.find(:last).should be 32 | @article.comments.find(:last).id.should == @comment2.id 33 | 34 | @article.comments.find(:all, :conditions => {:moderated => true}).size.should == 1 35 | @article.comments.find(:all, :conditions => {:moderated => false}).size.should == 1 36 | @article.comments.find(:all, :conditions => {:moderated => true})[0].id.should == @comment2.id 37 | @article.comments.find(:all, :conditions => {:moderated => false})[0].id.should == @comment1.id 38 | 39 | @article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1).size.should == 1 40 | @article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1).size.should == 1 41 | @article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1)[0].id.should == @comment2.id 42 | @article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1)[0].id.should == @comment1.id 43 | 44 | @article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1, :order => :desc).size.should == 1 45 | @article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1, :order => :asc).size.should == 1 46 | @article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1, :order => :desc)[0].id.should == @comment2.id 47 | @article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1, :order => :asc)[0].id.should == @comment1.id 48 | 49 | @comment1.update_attribute :moderated, true 50 | @article.comments.find(:all, :conditions => {:moderated => true}).size.should == 2 51 | @article.comments.find(:all, :conditions => {:moderated => false}).size.should == 0 52 | 53 | @comment1.destroy 54 | $redis.zrange("article:#{@article.id}:comments:moderated:true", 0, -1).size.should == 1 55 | $redis.zrange("article:#{@article.id}:comments:moderated:true", 0, -1)[0].should == @comment2.id.to_s 56 | $redis.zrange("article:#{@article.id}:comments:moderated:false", 0, -1).size.should == 0 57 | @article.comments.find(:all, :conditions => {:moderated => true}).size.should == 1 58 | @article.comments.find(:all, :conditions => {:moderated => false}).size.should == 0 59 | end 60 | 61 | it "should properly find associated records (e.g. with :conditions, :order, etc options) '=' used for association" do 62 | @article.comments = [@comment1, @comment2] 63 | @article.comments.count.should == 2 64 | 65 | @article.comments.all(:limit => 1).size.should == 1 66 | @article.comments.find(:first).should be 67 | @article.comments.find(:first).id.should == @comment1.id 68 | @article.comments.find(:last).should be 69 | @article.comments.find(:last).id.should == @comment2.id 70 | 71 | @article.comments.find(:all, :conditions => {:moderated => true}).size.should == 1 72 | @article.comments.find(:all, :conditions => {:moderated => false}).size.should == 1 73 | @article.comments.find(:all, :conditions => {:moderated => true})[0].id.should == @comment2.id 74 | @article.comments.find(:all, :conditions => {:moderated => false})[0].id.should == @comment1.id 75 | 76 | @article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1).size.should == 1 77 | @article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1).size.should == 1 78 | @article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1)[0].id.should == @comment2.id 79 | @article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1)[0].id.should == @comment1.id 80 | 81 | @article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1, :order => :desc).size.should == 1 82 | @article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1, :order => :asc).size.should == 1 83 | @article.comments.find(:all, :conditions => {:moderated => true}, :limit => 1, :order => :desc)[0].id.should == @comment2.id 84 | @article.comments.find(:all, :conditions => {:moderated => false}, :limit => 1, :order => :asc)[0].id.should == @comment1.id 85 | 86 | @comment1.update_attribute :moderated, true 87 | @article.comments.find(:all, :conditions => {:moderated => true}).size.should == 2 88 | @article.comments.find(:all, :conditions => {:moderated => false}).size.should == 0 89 | 90 | @comment1.destroy 91 | @article.comments.find(:all, :conditions => {:moderated => true}).size.should == 1 92 | @article.comments.find(:all, :conditions => {:moderated => false}).size.should == 0 93 | $redis.zrange("article:#{@article.id}:comments:moderated:true", 0, -1).size.should == 1 94 | $redis.zrange("article:#{@article.id}:comments:moderated:true", 0, -1)[0].should == @comment2.id.to_s 95 | $redis.zrange("article:#{@article.id}:comments:moderated:false", 0, -1).size.should == 0 96 | end 97 | 98 | it "should check compound indices for associations" do 99 | friend1 = User.create :name => "Director", :moderator => true, :moderated_area => "films" 100 | friend2 = User.create :name => "Admin", :moderator => true, :moderated_area => "all" 101 | friend3 = User.create :name => "Gena", :moderator => false 102 | 103 | me = User.create :name => "german" 104 | 105 | me.friends << [friend1, friend2, friend3] 106 | 107 | me.friends.count.should == 3 108 | me.friends.find(:all, :conditions => {:moderator => true}).size.should == 2 109 | me.friends.find(:all, :conditions => {:moderator => false}).size.should == 1 110 | 111 | me.friends.find(:all, :conditions => {:moderator => true, :moderated_area => "films"}).size.should == 1 112 | me.friends.find(:all, :conditions => {:moderator => true, :moderated_area => "films"})[0].id.should == friend1.id 113 | 114 | # reverse key's order in :conditions hash 115 | me.friends.find(:all, :conditions => {:moderated_area => "all", :moderator => true}).size.should == 1 116 | me.friends.find(:all, :conditions => {:moderated_area => "all", :moderator => true})[0].id.should == friend2.id 117 | end 118 | 119 | # TODO check that index assoc shouldn't be created while no assoc_record is provided 120 | 121 | it "should return first model if it exists, when conditions contain associated object" do 122 | user = User.create :name => "Dmitrii Samoilov", :age => 99, :wage => 35_000, :first_name => "Dmitrii", :last_name => "Samoilov" 123 | note = Note.create :body => "a test to test" 124 | note2 = Note.create :body => "aero" 125 | 126 | note.owner = user 127 | 128 | User.count.should == 1 129 | Note.count.should == 2 130 | $redis.zcard("note:owner:1").should == 1 131 | note.owner.should == user 132 | Note.find(:all, :conditions => {:owner => user}).should == [note] 133 | Note.find(:first, :conditions => {:owner => user}).should == note 134 | 135 | note.owner = nil 136 | Note.find(:all, :conditions => {:owner => user}).should == [] 137 | Note.find(:first, :conditions => {:owner => user}).should == nil 138 | $redis.zcard("note:owner:1").should == 0 139 | end 140 | 141 | it "should return first model if it exists when conditions contain associated object (belongs_to assoc established when creating object)" do 142 | user = User.create name: "Dmytro Samoilov", age: 99, wage: 35_000, first_name: "Dmytro", last_name: "Samoilov" 143 | 144 | note = Note.create body: "a test to test", owner: user 145 | Note.create body: "aero" # just test what would *find* return if 2 exemplars of Note are created 146 | 147 | expect(User.count).to eq(1) 148 | expect(Note.count).to eq(1) 149 | 150 | note.owner.should == user 151 | 152 | Note.find(:all, :conditions => {:owner => user}).should == [note] 153 | Note.find(:first, :conditions => {:owner => user}).should == note 154 | end 155 | 156 | it "should return first model if it exists when conditions contain associated object (has_one assoc established when creating object)" do 157 | profile = Profile.create title: "a test to test", name: "german" 158 | user = User.create name: "Dmitrii Samoilov", age: 99, wage: 35_000, first_name: "Dmitrii", last_name: "Samoilov", profile: profile 159 | User.create name: "Warren Buffet", age: 399, wage: 12_235_000, first_name: "Warren", last_name: "Buffet" 160 | 161 | User.count.should == 2 162 | Profile.count.should == 1 163 | 164 | profile.user.should == user 165 | 166 | User.find(:all, :conditions => {:profile => profile}).should == [user] 167 | User.find(:first, :conditions => {:profile => profile}).should == user 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /spec/models/associations_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "check associations" do 4 | before(:each) do 5 | @article = Article.new :title => "DHH drops OpenID on 37signals" 6 | @article.save 7 | 8 | @article.should be 9 | @article.title.should == "DHH drops OpenID on 37signals" 10 | 11 | @comment1 = Comment.new :body => "test" 12 | @comment1.save 13 | @comment1.should be 14 | @comment1.body.should == "test" 15 | 16 | @comment2 = Comment.new :body => "test #2" 17 | @comment2.save 18 | @comment2.should be 19 | @comment2.body.should == "test #2" 20 | end 21 | 22 | it "should assign properly from belongs_to side" do 23 | @comment1.article.should == nil 24 | @comment1.article = @article 25 | @comment1.article.id.should == @article.id 26 | @article.comments.count.should == 1 27 | @article.comments[0].id.should == @comment1.id 28 | 29 | @comment2.article.should == nil 30 | @comment2.article = @article 31 | @comment2.article.id.should == @article.id 32 | @article.comments.count.should == 2 33 | @article.comments[0].id.should == @comment2.id 34 | end 35 | 36 | it "should correctly resets associations when nil/[] provided" do 37 | # from has_many proxy side 38 | @article.comments << [@comment1, @comment2] 39 | @article.comments.count.should == 2 40 | @comment1.article.id.should == @article.id 41 | @comment2.article.id.should == @article.id 42 | 43 | # clear 44 | @article.comments = [] 45 | @article.comments.count.should == 0 46 | @comment1.article.should == nil 47 | @comment2.article.should == nil 48 | 49 | # from belongs_to side 50 | @article.comments << [@comment1, @comment2] 51 | @article.comments.count.should == 2 52 | @comment1.article.id.should == @article.id 53 | 54 | # clear 55 | @comment1.article = nil 56 | @article.comments.count.should == 1 57 | @comment1.article.should == nil 58 | 59 | # from has_one side 60 | profile = Profile.create :title => "test" 61 | chicago = City.create :name => "Chicago" 62 | 63 | profile.city = chicago 64 | profile.city.name.should == "Chicago" 65 | chicago.profiles.count.should == 1 66 | chicago.profiles[0].id.should == profile.id 67 | 68 | # clear 69 | profile.city = nil 70 | profile.city.should == nil 71 | chicago.profiles.count.should == 0 72 | end 73 | 74 | it "should return array of records for has_many association" do 75 | @article.comments << [] 76 | @article.comments.count.should == 0 77 | 78 | @article.comments = [] 79 | @article.comments.count.should == 0 80 | 81 | @article.comments << [@comment1, @comment2] 82 | #@article.comments.should be_kind_of(Array) 83 | 84 | @article.comments.count.should == 2 85 | @article.comments.size.should == 2 86 | 87 | @comment1.article.should be 88 | @comment2.article.should be 89 | 90 | @comment1.article.id.should == @comment2.article.id 91 | end 92 | 93 | it "should behave as active_record (proxy couldn't return records w/o #all call) += and << behave differently" do 94 | @article.comments << @comment1 << @comment2 95 | @article.comments.count.should == 2 96 | 97 | comments = @article.comments 98 | comments.count.should == 2 99 | 100 | comments = [] 101 | comments += @article.comments 102 | comments.count.should == 2 103 | comments.collect{|c| c.id}.should include(@comment1.id) 104 | comments.collect{|c| c.id}.should include(@comment2.id) 105 | 106 | comments = [] 107 | comments << @article.comments.all 108 | comments.flatten.count.should == 2 109 | 110 | comments = [] 111 | comments << @article.comments 112 | comments.count.should == 1 113 | end 114 | 115 | it "should return 1 comment when second was deleted" do 116 | Comment.count.should == 2 117 | @article.comments << [@comment1, @comment2] 118 | #@article.comments.should be_kind_of(Array) 119 | @article.comments.size.should == 2 120 | 121 | @comment1.destroy 122 | 123 | @article.comments.size.should == 1 124 | @article.comments.count.should == 1 125 | Comment.count.should == 1 126 | end 127 | 128 | it "should leave associations when parent has been deleted (nullify assocs)" do 129 | Comment.count.should == 2 130 | @article.comments << [@comment1, @comment2] 131 | @comment1.article.id.should == @article.id 132 | @comment2.article.id.should == @article.id 133 | #@article.comments.should be_kind_of(Array) 134 | @article.comments.size.should == 2 135 | @article.comments.count.should == 2 136 | 137 | @article.destroy 138 | 139 | Article.count.should == 0 140 | Comment.count.should == 2 141 | end 142 | 143 | it "should replace associations when '=' is used instead of '<<' " do 144 | Comment.count.should == 2 145 | @article.comments << [@comment1, @comment2] 146 | @comment1.article.id.should == @article.id 147 | @comment2.article.id.should == @article.id 148 | @article.comments.size.should == 2 149 | @article.comments.count.should == 2 150 | 151 | @article.comments = [@comment1] 152 | @article.comments.count.should == 1 153 | expect(@article.comments.first.id).to eq(@comment1.id) 154 | 155 | @comment1.article.id.should == @article.id 156 | end 157 | 158 | it "should correctly use many-to-many associations both with '=' and '<<' " do 159 | @cat1 = Category.create :name => "Nature" 160 | @cat2 = Category.create :name => "Art" 161 | @cat3 = Category.create :name => "Web" 162 | 163 | @cat1.name.should == "Nature" 164 | @cat2.name.should == "Art" 165 | @cat3.name.should == "Web" 166 | 167 | @article.categories << [@cat1, @cat2] 168 | 169 | @cat1.articles.count.should == 1 170 | @cat1.articles[0].should == @article 171 | @cat2.articles.count.should == 1 172 | @cat2.articles[0].should == @article 173 | 174 | @article.categories.size.should == 2 175 | @article.categories.count.should == 2 176 | 177 | @article.categories = [@cat1, @cat3] 178 | @article.categories.count.should == 2 179 | @article.categories.map{|c| c.id}.include?(@cat1.id).should be 180 | @article.categories.map{|c| c.id}.include?(@cat3.id).should be 181 | 182 | @cat1.articles.count.should == 1 183 | @cat1.articles[0].should == @article 184 | 185 | @cat3.articles.count.should == 1 186 | @cat3.articles[0].should == @article 187 | 188 | @cat2.articles.count.should == 0 189 | 190 | @cat1.destroy 191 | Category.count.should == 2 192 | @article.categories.count.should == 1 193 | end 194 | 195 | it "should remove old associations and create new ones" do 196 | profile = Profile.new 197 | profile.title = "test" 198 | profile.save 199 | 200 | chicago = City.new 201 | chicago.name = "Chicago" 202 | chicago.save 203 | 204 | washington = City.new 205 | washington.name = "Washington" 206 | washington.save 207 | 208 | profile.city = chicago 209 | profile.city.name.should == "Chicago" 210 | chicago.profiles.count.should == 1 211 | washington.profiles.count.should == 0 212 | chicago.profiles[0].id.should == profile.id 213 | 214 | profile.city = washington 215 | profile.city.name.should == "Washington" 216 | chicago.profiles.count.should == 0 217 | washington.profiles.count.should == 1 218 | washington.profiles[0].id.should == profile.id 219 | end 220 | 221 | it "should maintain correct self referencing link" do 222 | me = User.create :name => "german" 223 | friend1 = User.create :name => "friend1" 224 | friend2 = User.create :name => "friend2" 225 | 226 | me.friends << [friend1, friend2] 227 | 228 | me.friends.count.should == 2 229 | friend1.friends.count.should == 0 230 | friend2.friends.count.should == 0 231 | end 232 | 233 | it "should delete one specific record from an array with associated records" do 234 | me = User.create :name => "german" 235 | friend1 = User.create :name => "friend1" 236 | friend2 = User.create :name => "friend2" 237 | 238 | me.friends << [friend1, friend2] 239 | 240 | me = User.find_by_name 'german' 241 | me.friends.count.should == 2 242 | friend1 = User.find_by_name 'friend1' 243 | friend1.friends.count.should == 0 244 | friend2 = User.find_by_name 'friend2' 245 | friend2.friends.count.should == 0 246 | 247 | me.friends.delete(friend1.id) 248 | me.friends.count.should == 1 249 | me.friends[0].id == friend2.id 250 | User.count.should == 3 251 | end 252 | 253 | it "should create self-referencing link for has_one association" do 254 | m = Message.create :text => "it should create self-referencing link for has_one association" 255 | 256 | r = Message.create :text => "replay" 257 | 258 | r.replay_to = m 259 | 260 | Message.count.should == 2 261 | r.replay_to.should be 262 | r.replay_to.id.should == m.id 263 | 264 | rf = Message.last 265 | rf.replay_to.should be 266 | rf.replay_to.id.should == Message.first.id 267 | end 268 | 269 | it "should find associations within modules" do 270 | BelongsToModelWithinModule::Reply.count.should == 0 271 | essay = Article.create :title => "Red is cluster" 272 | BelongsToModelWithinModule::Reply.create :essay => essay 273 | BelongsToModelWithinModule::Reply.count.should == 1 274 | reply = BelongsToModelWithinModule::Reply.last 275 | reply.essay.should == essay 276 | 277 | HasManyModelWithinModule::SpecialComment.count.should == 0 278 | book = HasManyModelWithinModule::Brochure.create :title => "Red is unstable" 279 | HasManyModelWithinModule::SpecialComment.create :book => book 280 | HasManyModelWithinModule::Brochure.count.should == 1 281 | HasManyModelWithinModule::SpecialComment.count.should == 1 282 | end 283 | 284 | it "should properly handle self-referencing model both belongs_to and has_many/has_one associations" do 285 | comment1 = Comment.create :body => "comment1" 286 | comment11 = Comment.create :body => "comment1.1" 287 | comment12 = Comment.create :body => "comment1.2" 288 | 289 | comment1.replies = [comment11, comment12] 290 | comment1.replies.count.should == 2 291 | comment11.reply_to.should == comment1 292 | comment12.reply_to.should == comment1 293 | end 294 | end 295 | -------------------------------------------------------------------------------- /spec/models/atomicity_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "check atomicity" do 4 | let(:init_value) { 1 } 5 | let(:number_of_threads) { 100 } 6 | 7 | it "should properly increment property's value" do 8 | article = Article.create({title: "Simple test atomicity with multiple threads", karma: init_value}) 9 | threads = [] 10 | 11 | number_of_threads.times do |i| 12 | threads << Thread.new(i) do 13 | article.update_attributes({karma: (article.karma + 1)}) 14 | end 15 | end 16 | 17 | threads.each{|thread| thread.join} 18 | expect(Article.first.karma).to eq(number_of_threads + init_value) 19 | end 20 | 21 | it "should properly increment/decrement property's value" do 22 | article = Article.create :title => "article #1", :karma => 10 23 | threads = [] 24 | 25 | 12.times do 26 | threads << Thread.new { article.update_attributes(karma: (article.karma + 2)) } 27 | end 28 | 29 | 24.times do 30 | threads << Thread.new { article.update_attributes(karma: (article.karma - 1)) } 31 | end 32 | 33 | threads.each{|thread| thread.join} 34 | expect(article.karma).to eq(10) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/models/basic_functionality_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "check basic functionality" do 4 | it "should have 3 models in descendants" do 5 | RedisOrm::Base.descendants.should include(User, DefaultUser, TimeStamp) 6 | RedisOrm::Base.descendants.should_not include(EmptyPerson) 7 | end 8 | 9 | it "should return the same user" do 10 | user = User.new name: "german" 11 | user.save 12 | expect(User.first).to eq(user) 13 | 14 | user.name = "Anderson" 15 | expect(User.first).not_to eq(user) 16 | end 17 | 18 | it "test_simple_creation" do 19 | expect(User.count).to be(0) 20 | 21 | user = User.new :name => "german" 22 | user.save 23 | 24 | expect(user).to be 25 | 26 | expect(user.name).to eq("german") 27 | user.__redis_record_key.should == "user:1" 28 | 29 | User.count.should == 1 30 | User.first.name.should == "german" 31 | end 32 | 33 | it "should test different ways to update a record" do 34 | User.count.should == 0 35 | 36 | user = User.new name: "german" 37 | user.should be 38 | user.save 39 | 40 | user.name.should == "german" 41 | 42 | user.name = "nobody" 43 | user.save 44 | 45 | User.count.should == 1 46 | User.first.name.should == "nobody" 47 | 48 | u = User.first 49 | expect(u).to be 50 | u.update_attribute :name, "root" 51 | User.first.name.should == "root" 52 | 53 | u = User.first 54 | u.should be 55 | u.update_attributes name: "german" 56 | User.first.name.should == "german" 57 | end 58 | 59 | it "test_deletion" do 60 | User.count.should == 0 61 | 62 | user = User.new :name => "german" 63 | user.save 64 | user.should be 65 | 66 | user.name.should == "german" 67 | 68 | User.count.should == 1 69 | id = user.id 70 | 71 | user.destroy 72 | User.count.should == 0 73 | $redis.zrank("user:ids", id).should == nil 74 | $redis.hgetall("user:#{id}").should == {} 75 | end 76 | 77 | it "should return first and last objects" do 78 | User.count.should == 0 79 | User.first.should == nil 80 | User.last.should == nil 81 | 82 | user1 = User.new :name => "german" 83 | user1.save 84 | user1.should be 85 | user1.name.should == "german" 86 | 87 | user2 = User.new :name => "nobody" 88 | user2.save 89 | user2.should be 90 | user2.name.should == "nobody" 91 | 92 | User.count.should == 2 93 | 94 | User.first.should be 95 | User.last.should be 96 | 97 | User.first.id.should == user1.id 98 | User.last.id.should == user2.id 99 | end 100 | 101 | it "should return values with correct classes" do 102 | user = User.new 103 | user.name = "german" 104 | user.age = 26 105 | user.wage = 124.34 106 | user.male = true 107 | user.save 108 | 109 | user.should be 110 | 111 | u = User.first 112 | 113 | u.created_at.class.should == DateTime 114 | u.modified_at.class.should == DateTime 115 | u.wage.class.should == Float 116 | u.male.class.to_s.should match(/TrueClass|FalseClass/) 117 | u.age.class.to_s.should match(/Integer|Fixnum/) 118 | 119 | u.name.should == "german" 120 | u.wage.should == 124.34 121 | u.age.should == 26 122 | u.male.should == true 123 | end 124 | 125 | it "should return correct saved defaults" do 126 | expect{ 127 | DefaultUser.create 128 | }.to change(DefaultUser, :count) 129 | 130 | u = DefaultUser.first 131 | expect(u.wage.class).to eq(Float) 132 | 133 | u.male.class.to_s.should match(/TrueClass|FalseClass/) 134 | u.admin.class.to_s.should match(/TrueClass|FalseClass/) 135 | u.age.class.to_s.should match(/Integer|Fixnum/) 136 | 137 | expect(u.name).to eq("german") 138 | expect(u.male).to eq(true) 139 | expect(u.age).to eq(26) 140 | expect(u.wage).to eq(256.25) 141 | expect(u.admin).to eq(false) 142 | 143 | du = DefaultUser.new 144 | du.name = "germaninthetown" 145 | du.save 146 | du_saved = DefaultUser.last 147 | 148 | expect(du_saved.name).to eq("germaninthetown") 149 | expect(du_saved.admin).to eq(false) 150 | end 151 | 152 | it "should expand timestamps declaration properly" do 153 | t = TimeStamp.new 154 | t.save 155 | expect(t.created_at).to be 156 | expect(t.modified_at).to be 157 | expect(t.created_at.day).to eq(Time.now.day) 158 | expect(t.modified_at.day).to eq(Time.now.day) 159 | end 160 | 161 | it "should store arrays in the property correctly" do 162 | a = ArticleWithComments.new :title => "Article #1", :comments => ["Hello", "there are comments"] 163 | expect { 164 | a.save 165 | }.to change(ArticleWithComments, :count).by(1) 166 | 167 | saved_article = ArticleWithComments.last 168 | saved_article.comments.should == ["Hello", "there are comments"] 169 | end 170 | 171 | it "should store default hash in the property if it's not provided" do 172 | a = ArticleWithComments.new title: "Article #1" 173 | expect { 174 | a.save 175 | }.to change(ArticleWithComments, :count).by(1) 176 | 177 | saved_article = ArticleWithComments.last 178 | h = {'1': 0, '2': 0, '3': 0, '4': 0, '5': 0} 179 | expect(saved_article.rates).to eq({'1': 0, '2': 0, '3': 0, '4': 0, '5': 0}) 180 | end 181 | 182 | it "should store hash in the property correctly" do 183 | a = ArticleWithComments.new(title: "Article #1", rates: {'4': 134}) 184 | expect { 185 | a.save 186 | }.to change(ArticleWithComments, :count).by(1) 187 | 188 | saved_article = ArticleWithComments.last 189 | expect(saved_article.rates).to eql({'4' => 134}) 190 | end 191 | 192 | it "should properly transform :default values to right classes (if :default values are wrong) so when comparing them to other/stored instances they'll be the same" do 193 | # SortableUser class has 3 properties with wrong classes of :default value 194 | u = SortableUser.new :name => "Alan" 195 | u.save 196 | 197 | su = SortableUser.first 198 | su.test_type_cast.should == false 199 | su.wage.should == 20_000.0 200 | su.age.should == 26 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /spec/models/callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "check callbacks" do 4 | it "should fire after_create/after_destroy callbacks" do 5 | user = User.create(first_name: "Robert", last_name: "Pirsig") 6 | expect($redis.zrank("users:sorted_by_rating", user.id)).to eq(0) 7 | 8 | comment = Comment.create :text => "First!" 9 | user.comments << comment 10 | 11 | u = User.first 12 | u.id.should == user.id 13 | u.comments.count.should == 1 14 | u.destroy 15 | u.comments.count.should == 0 16 | end 17 | 18 | it "should fire before_create/before_destroy callbacks" do 19 | CutoutAggregator.create 20 | 21 | CutoutAggregator.count.should == 1 22 | Cutout.create :filename => "1.jpg" 23 | Cutout.create :filename => "2.jpg" 24 | CutoutAggregator.last.revision.should == 2 25 | Cutout.last.destroy 26 | Cutout.last.destroy 27 | CutoutAggregator.count.should == 0 28 | end 29 | 30 | it "should fire after_save/before_save callbacks" do 31 | comment = Comment.new :text => " Trim meeee ! " 32 | comment.save 33 | Comment.first.text.should == "Trim meeee !" 34 | 35 | user = User.new :first_name => "Robert", :last_name => "Pirsig" 36 | user.save 37 | user.karma.should == 1000 38 | 39 | user.comments << comment 40 | user.comments.count == 1 41 | 42 | c = Comment.first 43 | c.update_attributes :text => "Another brick in the wall" 44 | 45 | User.first.karma.should == 975 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/models/changes_array_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "check associations" do 4 | it "should return correct _changes array" do 5 | user = User.new name: "german" 6 | # expect(user.name_changed?).to be_falsey 7 | 8 | expect(user.name_change).to eq([nil, "german"]) 9 | user.save 10 | 11 | expect(user.name_change).to eq(nil) 12 | user.name = "germaninthetown" 13 | expect(user.name_change).to eq(["german", "germaninthetown"]) 14 | expect(user.name_changed?).to be_truthy 15 | user.save 16 | 17 | user = User.first 18 | expect(user.name).to eq("germaninthetown") 19 | # expect(user.name_changed?).to be_falsey 20 | expect(user.name_change).to eq([nil, "germaninthetown"]) 21 | user.name = "german" 22 | expect(user.name_changed?).to be_truthy 23 | expect(user.name_change).to eq([nil, "german"]) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/models/dynamic_finders_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "check associations" do 4 | class DynamicFinderUser < RedisOrm::Base 5 | property :first_name, String 6 | property :last_name, String 7 | 8 | index :first_name, :unique => true 9 | index :last_name, :unique => true 10 | index [:first_name, :last_name], :unique => false 11 | end 12 | 13 | it "should create and use indexes to implement dynamic finders" do 14 | user1 = DynamicFinderUser.new 15 | user1.first_name = "Dmitrii" 16 | user1.last_name = "Samoilov" 17 | user1.save 18 | 19 | expect(DynamicFinderUser.find_by_first_name("John")).to be_nil 20 | 21 | user = DynamicFinderUser.find_by_first_name "Dmitrii" 22 | expect(user.id).to eq(user1.id) 23 | 24 | expect(DynamicFinderUser.find_all_by_first_name("Dmitrii").size).to eq(1) 25 | 26 | user = DynamicFinderUser.find_by_first_name_and_last_name('Dmitrii', 'Samoilov') 27 | expect(user).to be 28 | expect(user.id).to eq(user1.id) 29 | 30 | expect(DynamicFinderUser.find_all_by_first_name_and_last_name('Dmitrii', 'Samoilov').size).to eq(1) 31 | expect(DynamicFinderUser.find_all_by_last_name_and_first_name('Samoilov', 'Dmitrii')[0].id).to eq(user1.id) 32 | 33 | expect( 34 | lambda{DynamicFinderUser.find_by_first_name_and_err_name('Dmitrii', 'Samoilov')} 35 | ).to raise_error(RedisOrm::NotIndexFound) 36 | end 37 | 38 | it "should create and use indexes to implement dynamic finders" do 39 | user1 = CustomUser.new 40 | user1.first_name = "Dmitrii" 41 | user1.last_name = "Samoilov" 42 | user1.save 43 | 44 | user2 = CustomUser.new 45 | user2.first_name = "Dmitrii" 46 | user2.last_name = "Nabaldyan" 47 | user2.save 48 | 49 | user = CustomUser.find_by_first_name "Dmitrii" 50 | expect(user.id).to eq(user1.id) 51 | 52 | expect(CustomUser.find_by_last_name("Krassovkin")).to be_nil 53 | expect(CustomUser.find_all_by_first_name("Dmitrii").size).to eq(2) 54 | end 55 | 56 | # TODO 57 | it "should properly delete indices when record was deleted" do 58 | 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/models/exceptions_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "exceptions test" do 4 | it "should raise an exception if association is provided with improper class" do 5 | expect(User.count).to eq(0) 6 | 7 | user = User.new name: "german", age: 26 8 | user.save 9 | 10 | expect(user).to be 11 | expect(user.name).to eq('german') 12 | expect(User.count).to eq(1) 13 | 14 | expect(lambda{ User.find :all, conditions: { gender: true } }).to raise_error(RedisOrm::NotIndexFound) 15 | expect(User.find(:all, conditions: { age: 26 }).size).to eq(1) 16 | expect(lambda{ User.find :all, conditions: {name: 'german', age: 26} }).to raise_error(RedisOrm::NotIndexFound) 17 | 18 | jigsaw = Jigsaw.new 19 | jigsaw.title = "123" 20 | jigsaw.save 21 | 22 | expect(lambda { user.profile = jigsaw }).to raise_error(RedisOrm::TypeMismatchError) 23 | end 24 | 25 | it "should raise an exception if there is no such record in the storage" do 26 | expect(User.find(12)).to be_nil 27 | expect(lambda{ User.find! 12 }).to raise_error(RedisOrm::RecordNotFound) 28 | end 29 | 30 | it "should throw an exception if there was an error while creating object with #create! method" do 31 | jigsaw = Jigsaw.create title: "jigsaw" 32 | expect{ User.create(name: "John", age: 44, profile: jigsaw) }.to raise_error(RedisOrm::TypeMismatchError) 33 | end 34 | 35 | it "should throw an exception if wrong format of the default value is specified for Array/Hash property" do 36 | a = ArticleWithComments.new :title => "Article #1", :rates => [1,2,3,4,5] 37 | expect(lambda { 38 | a.save 39 | }).to raise_error(RedisOrm::TypeMismatchError) 40 | 41 | a = ArticleWithComments.new :title => "Article #1", :comments => 12 42 | expect(lambda { 43 | a.save 44 | }).to raise_error(RedisOrm::TypeMismatchError) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/models/expire_records_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "expire record after specified time" do 4 | it "should create a record and then delete if *expire* method is specified in appropriate class" do 5 | euser = ExpireUser.create :name => "Ghost rider" 6 | $redis.ttl(euser.__redis_record_key).should be > 9.minutes.from_now.to_i 7 | $redis.ttl(euser.__redis_record_key).should be < (10.minutes.from_now.to_i + 1) 8 | end 9 | 10 | it "should create a record and then delete if *expire* method is specified in appropriate class" do 11 | euser = ExpireUserWithPredicate.create :name => "Ghost rider" 12 | $redis.ttl(euser.__redis_record_key).should be > 9.minutes.from_now.to_i 13 | $redis.ttl(euser.__redis_record_key).should be < (10.minutes.from_now.to_i + 1) 14 | 15 | euser2 = ExpireUserWithPredicate.create :name => "Ghost rider", :persist => true 16 | $redis.ttl(euser2.__redis_record_key).should == -1 17 | end 18 | 19 | it "should create a record with an inline *expire* option (which overrides default *expire* value)" do 20 | euser = ExpireUser.create :name => "Ghost rider", :expire_in => 50.minutes.from_now 21 | $redis.ttl(euser.__redis_record_key).should be < (50.minutes.from_now.to_i + 1) 22 | $redis.ttl(euser.__redis_record_key).should be > 49.minutes.from_now.to_i 23 | end 24 | 25 | it "should also create expirable key when record has associated records" do 26 | euser = ExpireUser.create :name => "Ghost rider" 27 | $redis.ttl(euser.__redis_record_key).should be > 9.minutes.from_now.to_i 28 | $redis.ttl(euser.__redis_record_key).should be < (10.minutes.from_now.to_i + 1) 29 | 30 | profile = Profile.create :title => "Profile for ghost rider", :name => "Ghost Rider" 31 | articles = [Article.create(:title => "article1", :karma => 1), Article.create(:title => "article2", :karma => 2)] 32 | 33 | euser.profile = profile 34 | euser.profile.should == profile 35 | $redis.get("expire_user:1:profile").to_i.should == profile.id 36 | $redis.ttl("expire_user:1:profile").should be > 9.minutes.from_now.to_i 37 | $redis.ttl("expire_user:1:profile").should be < (10.minutes.from_now.to_i + 1) 38 | 39 | euser.articles = articles 40 | $redis.zrange("expire_user:1:articles", 0, -1).should =~ articles.map{|a| a.id.to_s} 41 | $redis.ttl("expire_user:1:articles").should be > 9.minutes.from_now.to_i 42 | $redis.ttl("expire_user:1:articles").should be < (10.minutes.from_now.to_i + 1) 43 | end 44 | 45 | it "should also create expirable key when record has associated records (class with predicate expiry)" do 46 | euser2 = ExpireUserWithPredicate.create :name => "Ghost rider", :persist => false 47 | $redis.ttl(euser2.__redis_record_key).should be > 9.minutes.from_now.to_i 48 | $redis.ttl(euser2.__redis_record_key).should be < (10.minutes.from_now.to_i + 1) 49 | 50 | profile = Profile.create :title => "Profile for ghost rider", :name => "Ghost Rider" 51 | articles = [Article.create(:title => "article1", :karma => 1), Article.create(:title => "article2", :karma => 2)] 52 | 53 | euser2.profile = profile 54 | euser2.profile.should == profile 55 | $redis.get("expire_user_with_predicate:1:profile").to_i.should == profile.id 56 | $redis.ttl("expire_user_with_predicate:1:profile").should be > 9.minutes.from_now.to_i 57 | $redis.ttl("expire_user_with_predicate:1:profile").should be < (10.minutes.from_now.to_i + 1) 58 | 59 | euser2.articles << articles 60 | $redis.zrange("expire_user_with_predicate:1:articles", 0, -1).should =~ articles.map{|a| a.id.to_s} 61 | $redis.ttl("expire_user_with_predicate:1:articles").should be > 9.minutes.from_now.to_i 62 | $redis.ttl("expire_user_with_predicate:1:articles").should be < (10.minutes.from_now.to_i + 1) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/models/has_one_has_many_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "check associations" do 4 | it "should save associations properly" do 5 | @profile = Profile.new 6 | @profile.name = "my profile" 7 | @profile.save 8 | 9 | @profile.should be 10 | @profile.name.should == "my profile" 11 | 12 | @location1 = Location.new 13 | @location1.coordinates = "44.343456345 56.23341432" 14 | @location1.save 15 | @location1.should be 16 | @location1.coordinates.should == "44.343456345 56.23341432" 17 | 18 | @profile.location = @location1 19 | 20 | @profile.location.should be 21 | @profile.location.id.should == @location1.id 22 | 23 | @location1.profiles.size.should == 1 24 | @location1.profiles.first.id.should == @profile.id 25 | 26 | # check second profile 27 | @profile2 = Profile.new 28 | @profile2.name = "someone else's profile" 29 | @profile2.save 30 | 31 | @profile2.should be 32 | @profile2.name.should == "someone else's profile" 33 | 34 | @profile2.location = @location1 35 | 36 | @profile2.location.should be 37 | @profile2.location.id.should == @location1.id 38 | 39 | @location1.profiles.size.should == 2 40 | @location1.profiles.collect{|p| p.id}.sort.should == [@profile.id, @profile2.id].sort 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/models/indices_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "check indices" do 4 | it "should change index accordingly to the changes in the model" do 5 | user = User.new :first_name => "Robert", :last_name => "Pirsig" 6 | user.save 7 | 8 | u = User.find_by_first_name("Robert") 9 | u.id.should == user.id 10 | 11 | u = User.find_by_first_name_and_last_name("Robert", "Pirsig") 12 | u.id.should == user.id 13 | 14 | u.first_name = "Chris" 15 | u.save 16 | 17 | expect(User.find_by_first_name("Robert")).to be_nil 18 | 19 | expect(User.find_by_first_name_and_last_name("Robert", "Pirsig")).to be_nil 20 | 21 | User.find_by_first_name("Chris").id.should == user.id 22 | User.find_by_last_name("Pirsig").id.should == user.id 23 | User.find_by_first_name_and_last_name("Chris", "Pirsig").id.should == user.id 24 | end 25 | 26 | it "should change index accordingly to the changes in the model (test #update_attributes method)" do 27 | user = User.new :first_name => "Robert", :last_name => "Pirsig" 28 | user.save 29 | 30 | u = User.find_by_first_name("Robert") 31 | u.id.should == user.id 32 | 33 | u = User.find_by_first_name_and_last_name("Robert", "Pirsig") 34 | u.id.should == user.id 35 | 36 | u.update_attributes :first_name => "Christofer", :last_name => "Robin" 37 | 38 | User.find_by_first_name("Robert").should == nil 39 | User.find_by_last_name("Pirsig").should == nil 40 | User.find_by_first_name_and_last_name("Robert", "Pirsig").should == nil 41 | 42 | User.find_by_first_name("Christofer").id.should == user.id 43 | User.find_by_last_name("Robin").id.should == user.id 44 | User.find_by_first_name_and_last_name("Christofer", "Robin").id.should == user.id 45 | end 46 | 47 | it "should create case insensitive indices too" do 48 | ou = OmniUser.new :email => "GERMAN@Ya.ru", :uid => 2718281828 49 | ou.save 50 | 51 | OmniUser.count.should == 1 52 | OmniUser.find_by_email("german@ya.ru").should be 53 | OmniUser.find_all_by_email("german@ya.ru").count.should == 1 54 | 55 | OmniUser.find_by_email_and_uid("german@ya.ru", 2718281828).should be 56 | OmniUser.find_all_by_email_and_uid("german@ya.ru", 2718281828).count.should == 1 57 | 58 | OmniUser.find_by_email("geRman@yA.rU").should be 59 | OmniUser.find_all_by_email_and_uid("GerMan@Ya.ru", 2718281828).count.should == 1 60 | 61 | OmniUser.find_all_by_email_and_uid("german@ya.ru", 2718281829).count.should == 0 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/models/options_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "test options" do 4 | before(:each) do 5 | @album = Album.new 6 | @album.title = "my 1st album" 7 | @album.save 8 | 9 | @album.should be 10 | @album.title.should == "my 1st album" 11 | 12 | @photo1 = Photo.new :image => "facepalm.jpg", :image_type => "jpg", :checked => true 13 | @photo1.save 14 | @photo1.should be 15 | @photo1.image.should == "facepalm.jpg" 16 | @photo1.image_type.should == "jpg" 17 | 18 | @photo2 = Photo.new :image => "boobs.png", :image_type => "png", :inverted => false 19 | @photo2.save 20 | @photo2.should be 21 | @photo2.image.should == "boobs.png" 22 | @photo2.image_type.should == "png" 23 | end 24 | 25 | it "should behave like expected for #find and #find! methods (nb exceptions with #find! are tested in exceptions_test.rb file)" do 26 | Album.find(@album.id).should == @album 27 | Album.find!(@album.id).should == @album 28 | 29 | Album.find(:first).should == @album 30 | Album.find!(:first).should == @album 31 | 32 | Album.find(:all, limit: 1).size.should == 1 33 | Album.find!(:all, limit: 1).size.should == 1 34 | end 35 | 36 | it "should return correct array when :limit and :offset options are provided" do 37 | @album.photos.count.should == 0 38 | @album.photos.all(limit: 2, offset: 0).should == [] 39 | 40 | @album.photos << [@photo1, @photo2] 41 | @album.photos.all(limit: 0, offset: 0).should == [] 42 | @album.photos.all(limit: 1, offset: 0).size.should == 1 43 | @album.photos.all(limit: 2, offset: 0).size.should == 2 #[@photo1, @photo2] 44 | 45 | @album.photos.all(limit: 0, offset: 0).should == [] 46 | @album.photos.all(limit: 1, offset: 1).size.should == 1 # [@photo2] 47 | @album.photos.all(limit: 2, offset: 2).should == [] 48 | 49 | @album.photos.find(:all, :limit => 1, :offset => 1).size.should == 1 50 | 51 | Photo.find(:all).size.should == 2 52 | 53 | Photo.find(:first).should == @photo1 54 | Photo.find(:last).should == @photo2 55 | 56 | Photo.find(:all, :conditions => {:image => "facepalm.jpg"}).size.should == 1 57 | Photo.find(:all, :conditions => {:image => "boobs.png"}).size.should == 1 58 | 59 | Photo.find(:all, :conditions => {:image => "facepalm.jpg", :image_type => "jpg"}).size.should == 1 60 | Photo.find(:all, :conditions => {:image => "boobs.png", :image_type => "png"}).size.should == 1 61 | 62 | Photo.find(:first, :conditions => {:image => "facepalm.jpg"}).should == @photo1 63 | Photo.find(:first, :conditions => {:image => "boobs.png"}).should == @photo2 64 | 65 | Photo.find(:first, :conditions => {:image => "facepalm.jpg", :image_type => "jpg"}).should == @photo1 66 | Photo.find(:first, :conditions => {:image => "boobs.png", :image_type => "png"}).should == @photo2 67 | 68 | Photo.find(:last, :conditions => {:image => "facepalm.jpg"}).should == @photo1 69 | Photo.find(:last, :conditions => {:image => "boobs.png"}).should == @photo2 70 | 71 | Photo.find(:last, :conditions => {:image => "facepalm.jpg", :image_type => "jpg"}).should == @photo1 72 | Photo.find(:last, :conditions => {:image => "boobs.png", :image_type => "png"}).should == @photo2 73 | end 74 | 75 | it "should accept options for #first and #last methods" do 76 | Photo.first(:conditions => {:image => "facepalm.jpg"}).should == @photo1 77 | Photo.first(:conditions => {:image => "boobs.png"}).should == @photo2 78 | 79 | Photo.last(:conditions => {:image => "facepalm.jpg", :image_type => "jpg"}).should == @photo1 80 | Photo.last(:conditions => {:image => "boobs.png", :image_type => "png"}).should == @photo2 81 | end 82 | 83 | it "should correctly save boolean values" do 84 | $redis.hgetall("photo:#{@photo1.id}")["inverted"].should == "true" 85 | $redis.hgetall("photo:#{@photo2.id}")["inverted"].should == "false" 86 | 87 | @photo1.inverted.should == true 88 | @photo2.inverted.should == false 89 | 90 | $redis.zrange("photo:inverted:true", 0, -1).should include(@photo1.id.to_s) 91 | $redis.zrange("photo:inverted:false", 0, -1).should include(@photo2.id.to_s) 92 | 93 | $redis.hgetall("photo:#{@photo1.id}")["checked"].should == "true" 94 | $redis.hgetall("photo:#{@photo2.id}")["checked"].should == "false" 95 | 96 | @photo1.checked.should == true 97 | @photo2.checked.should == false 98 | 99 | $redis.zrange("photo:checked:true", 0, -1).should include(@photo1.id.to_s) 100 | $redis.zrange("photo:checked:false", 0, -1).should include(@photo2.id.to_s) 101 | end 102 | 103 | it "should search on bool values properly" do 104 | Photo.find(:all, :conditions => {:checked => true}).size.should == 1 105 | Photo.find(:all, :conditions => {:checked => true}).first.id.should == @photo1.id 106 | Photo.find(:all, :conditions => {:checked => false}).size.should == 1 107 | Photo.find(:all, :conditions => {:checked => false}).first.id.should == @photo2.id 108 | 109 | Photo.find(:all, :conditions => {:inverted => true}).size.should == 1 110 | Photo.find(:all, :conditions => {:inverted => true}).first.id.should == @photo1.id 111 | Photo.find(:all, :conditions => {:inverted => false}).size.should == 1 112 | Photo.find(:all, :conditions => {:inverted => false}).first.id.should == @photo2.id 113 | end 114 | 115 | it "should return correct array when :order option is provided" do 116 | Photo.all(:order => "asc").map{|p| p.id}.should == [@photo1.id, @photo2.id] 117 | Photo.all(:order => "desc").map{|p| p.id}.should == [@photo2.id, @photo1.id] 118 | 119 | Photo.all(:order => "asc", :limit => 1).map{|p| p.id}.should == [@photo1.id] 120 | Photo.all(:order => "desc", :limit => 1).map{|p| p.id}.should == [@photo2.id] 121 | 122 | Photo.all(:order => "asc", :limit => 1, :offset => 1).map{|p| p.id}.should == [@photo2.id] 123 | Photo.all(:order => "desc", :limit => 1, :offset => 1).map{|p| p.id}.should == [@photo1.id] 124 | 125 | # testing #find method 126 | Photo.find(:all, :order => "asc").map{|p| p.id}.should == [@photo1.id, @photo2.id] 127 | Photo.find(:all, :order => "desc").map{|p| p.id}.should == [@photo2.id, @photo1.id] 128 | 129 | Photo.find(:all, :order => "asc", :limit => 1).map{|p| p.id}.should == [@photo1.id] 130 | Photo.find(:all, :order => "desc", :limit => 1).map{|p| p.id}.should == [@photo2.id] 131 | 132 | Photo.find(:first, :order => "asc", :limit => 1, :offset => 1).id.should == @photo2.id 133 | Photo.find(:first, :order => "desc", :limit => 1, :offset => 1).id.should == @photo1.id 134 | 135 | Photo.find(:last, :order => "asc").id.should == @photo2.id 136 | Photo.find(:last, :order => "desc").id.should == @photo1.id 137 | 138 | @album.photos.count.should == 0 139 | @album.photos.all(:limit => 2, :offset => 0).should == [] 140 | @album.photos << @photo2 141 | @album.photos << @photo1 142 | 143 | @album.photos.all(:order => "asc").map{|p| p.id}.should == [@photo2.id, @photo1.id] 144 | @album.photos.all(:order => "desc").map{|p| p.id}.should == [@photo1.id, @photo2.id] 145 | @album.photos.all(:order => "asc", :limit => 1).map{|p| p.id}.should == [@photo2.id] 146 | @album.photos.all(:order => "desc", :limit => 1).map{|p| p.id}.should == [@photo1.id] 147 | @album.photos.all(:order => "asc", :limit => 1, :offset => 1).map{|p| p.id}.should == [@photo1.id] 148 | @album.photos.all(:order => "desc", :limit => 1, :offset => 1).map{|p| p.id}.should == [@photo2.id] 149 | 150 | @album.photos.find(:all, :order => "asc").map{|p| p.id}.should == [@photo2.id, @photo1.id] 151 | @album.photos.find(:all, :order => "desc").map{|p| p.id}.should == [@photo1.id, @photo2.id] 152 | 153 | @album.photos.find(:first, :order => "asc").id.should == @photo2.id 154 | @album.photos.find(:first, :order => "desc").id.should == @photo1.id 155 | 156 | @album.photos.find(:last, :order => "asc").id.should == @photo1.id 157 | @album.photos.find(:last, :order => "desc").id.should == @photo2.id 158 | 159 | @album.photos.find(:last, :order => "desc", :offset => 2).should == nil 160 | @album.photos.find(:first, :order => "desc", :offset => 2).should == nil 161 | 162 | @album.photos.find(:all, :order => "asc", :limit => 1, :offset => 1).map{|p| p.id}.should == [@photo1.id] 163 | @album.photos.find(:all, :order => "desc", :limit => 1, :offset => 1).map{|p| p.id}.should == [@photo2.id] 164 | end 165 | 166 | it "should delete associated records when :dependant => :destroy in *has_many* assoc" do 167 | @album.photos << [@photo1, @photo2] 168 | 169 | @album.photos.count.should == 2 170 | 171 | Photo.count.should == 2 172 | @album.destroy 173 | Photo.count.should == 0 174 | Album.count.should == 0 175 | end 176 | 177 | it "should *NOT* delete associated records when :dependant => :nullify or empty in *has_many* assoc" do 178 | Photo.count.should == 2 179 | 180 | category = Category.new 181 | category.title = "cats" 182 | category.save 183 | 184 | Category.count.should == 1 185 | 186 | category.photos << [@photo1, @photo2] 187 | category.photos.count.should == 2 188 | 189 | category.destroy 190 | 191 | Photo.count.should == 2 192 | Category.count.should == 0 193 | end 194 | 195 | it "should delete associated records when :dependant => :destroy and leave them otherwise in *has_one* assoc" do 196 | user = User.new 197 | user.name = "Dmitrii Samoilov" 198 | user.save 199 | user.should be 200 | 201 | user.photo = @photo1 202 | 203 | user.photo.id.should == @photo1.id 204 | 205 | User.count.should == 1 206 | Photo.count.should == 2 207 | user.destroy 208 | Photo.count.should == 1 209 | User.count.should == 0 210 | end 211 | 212 | it "should delete link to associated record when record was deleted" do 213 | @album.photos << [@photo1, @photo2] 214 | 215 | @album.photos.count.should == 2 216 | 217 | Photo.count.should == 2 218 | @photo1.destroy 219 | Photo.count.should == 1 220 | 221 | @album.photos.count.should == 1 222 | @album.photos.size.should == 1 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /spec/models/sortable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "test options" do 4 | before(:each) do 5 | @dan = SortableUser.create name: "Daniel", age: 26, wage: 40000.0, address: "Bellevue" 6 | @abe = SortableUser.create name: "Abe", age: 30, wage: 100000.0, address: "Bellevue" 7 | @michael = SortableUser.create name: "Michael", age: 25, wage: 60000.0, address: "Bellevue" 8 | @todd = SortableUser.create name: "Todd", age: 22, wage: 30000.0, address: "Bellevue" 9 | end 10 | 11 | it "should return records in specified order" do 12 | expect($redis.llen("sortable_user:name_ids").to_i).to eq(SortableUser.count) 13 | expect($redis.zcard("sortable_user:age_ids").to_i).to eq(SortableUser.count) 14 | expect($redis.zcard("sortable_user:wage_ids").to_i).to eq(SortableUser.count) 15 | 16 | expect(SortableUser.find(:all, order: [:name, :asc])).to eq([@abe, @dan, @michael, @todd]) 17 | expect(SortableUser.find(:all, order: [:name, :desc])).to eq([@todd, @michael, @dan, @abe]) 18 | 19 | expect(SortableUser.find(:all, order: [:age, :asc])).to eq([@todd, @michael, @dan, @abe]) 20 | expect(SortableUser.find(:all, order: [:age, :desc])).to eq([@abe, @dan, @michael, @todd]) 21 | 22 | expect(SortableUser.find(:all, order: [:wage, :asc])).to eq([@todd, @dan, @michael, @abe]) 23 | expect(SortableUser.find(:all, order: [:wage, :desc])).to eq([@abe, @michael, @dan, @todd]) 24 | end 25 | 26 | it "should return records which met specified conditions in specified order" do 27 | @abe2 = SortableUser.create name: "Abe", age: 12, wage: 10.0, address: "Santa Fe" 28 | 29 | # :asc should be default value for property in :order clause 30 | expect(SortableUser.find(:all, conditions: {name: "Abe"}, order: [:wage])).to eq([@abe2, @abe]) 31 | 32 | expect(SortableUser.find(:all, conditions: {name: "Abe"}, order: [:wage, :desc])).to eq([@abe, @abe2]) 33 | expect(SortableUser.find(:all, conditions: {name: "Abe"}, order: [:wage, :asc])).to eq([@abe2, @abe]) 34 | 35 | expect(SortableUser.find(:all, conditions: {name: "Abe"}, order: [:age, :desc])).to eq([@abe, @abe2]) 36 | expect(SortableUser.find(:all, conditions: {name: "Abe"}, order: [:age, :asc])).to eq([@abe2, @abe]) 37 | 38 | expect(SortableUser.find(:all, conditions: {name: "Abe"}, order: [:wage, :desc])).to eq([@abe, @abe2]) 39 | expect(SortableUser.find(:all, conditions: {name: "Abe"}, order: [:wage, :asc])).to eq([@abe2, @abe]) 40 | end 41 | 42 | it "should update keys after the persisted object was edited and sort properly" do 43 | @abe.update_attributes :name => "Zed", :age => 12, :wage => 10.0, :address => "Santa Fe" 44 | 45 | expect($redis.llen("sortable_user:name_ids").to_i).to eq(SortableUser.count) 46 | expect($redis.zcard("sortable_user:age_ids").to_i).to eq(SortableUser.count) 47 | expect($redis.zcard("sortable_user:wage_ids").to_i).to eq(SortableUser.count) 48 | 49 | expect(SortableUser.find(:all, order: [:name, :asc])).to eq([@dan, @michael, @todd, @abe]) 50 | expect(SortableUser.find(:all, order: [:name, :desc])).to eq([@abe, @todd, @michael, @dan]) 51 | 52 | expect(SortableUser.find(:all, order: [:age, :asc])).to eq([@abe, @todd, @michael, @dan]) 53 | expect(SortableUser.find(:all, order: [:age, :desc])).to eq([@dan, @michael, @todd, @abe]) 54 | 55 | expect(SortableUser.find(:all, order: [:wage, :asc])).to eq([@abe, @todd, @dan, @michael]) 56 | expect(SortableUser.find(:all, order: [:wage, :desc])).to eq([@michael, @dan, @todd, @abe]) 57 | end 58 | 59 | it "should update keys after the persisted object was deleted and sort properly" do 60 | user_count = SortableUser.count 61 | @abe.destroy 62 | 63 | expect($redis.llen("sortable_user:name_ids").to_i).to eq(user_count - 1) 64 | expect($redis.zcard("sortable_user:age_ids").to_i).to eq(user_count - 1) 65 | expect($redis.zcard("sortable_user:wage_ids").to_i).to eq(user_count - 1) 66 | 67 | expect(SortableUser.find(:all, order: [:name, :asc])).to eq([@dan, @michael, @todd]) 68 | expect(SortableUser.find(:all, order: [:name, :desc])).to eq([@todd, @michael, @dan]) 69 | 70 | expect(SortableUser.find(:all, order: [:age, :asc])).to eq([@todd, @michael, @dan]) 71 | expect(SortableUser.find(:all, order: [:age, :desc])).to eq([@dan, @michael, @todd]) 72 | 73 | expect(SortableUser.find(:all, order: [:wage, :asc])).to eq([@todd, @dan, @michael]) 74 | expect(SortableUser.find(:all, order: [:wage, :desc])).to eq([@michael, @dan, @todd]) 75 | end 76 | 77 | it "should sort objects with more than 3-4 symbols" do 78 | vladislav = SortableUser.create name: "Vladislav", age: 19, wage: 120.0 79 | vladimir = SortableUser.create name: "Vladimir", age: 22, wage: 220.5 80 | vlad = SortableUser.create name: "Vlad", age: 29, wage: 1200.0 81 | 82 | expect(SortableUser.find(:all, order: [:name, :desc], limit: 3)).to eq([vladislav, vladimir, vlad]) 83 | expect(SortableUser.find(:all, order: [:name, :desc], limit: 2, offset: 4)).to eq([@michael, @dan]) 84 | expect(SortableUser.find(:all, order: [:name, :desc], offset: 3)).to eq([@todd, @michael, @dan, @abe]) 85 | expect(SortableUser.find(:all, order: [:name, :desc])).to eq([vladislav, vladimir, vlad, @todd, @michael, @dan, @abe]) 86 | 87 | expect(SortableUser.find(:all, order: [:name, :asc], limit: 3, offset: 4)).to eq([vlad, vladimir, vladislav]) 88 | expect(SortableUser.find(:all, order: [:name, :asc], offset: 3)).to eq([@todd, vlad, vladimir, vladislav]) 89 | expect(SortableUser.find(:all, order: [:name, :asc], limit: 3)).to eq([@abe, @dan, @michael]) 90 | expect(SortableUser.find(:all, order: [:name, :asc])).to eq([@abe, @dan, @michael, @todd, vlad, vladimir, vladislav]) 91 | end 92 | 93 | it "should properly handle multiple users with almost the same names" do 94 | users = [@abe, @todd, @michael, @dan] 95 | 20.times{|i| users << SortableUser.create(name: "user#{i}") } 96 | expect(users.sort_by(&:name)).to eq(SortableUser.all(order: [:name, :asc])) 97 | end 98 | 99 | it "should properly handle multiple users with almost the same names (descending order)" do 100 | rev_users = [@abe, @todd, @michael, @dan] 101 | 20.times{|i| rev_users << SortableUser.create(name: "user#{i}") } 102 | expect(SortableUser.all(order: [:name, :desc])).to eq(rev_users.sort_by(&:name).reverse) 103 | end 104 | 105 | it "should properly store records with the same names" do 106 | users = [@abe, @todd, @michael, @dan] 107 | 108 | users << SortableUser.create(name: "user#1") 109 | users << SortableUser.create(name: "user#2") 110 | users << SortableUser.create(name: "user#1") 111 | users << SortableUser.create(name: "user#2") 112 | 113 | # we pluck only *name* here since it didn't sort by id (and it could be messed up) 114 | expect(SortableUser.all(order: [:name, :desc]).map{|u| u.name}).to eq(users.sort_by(&:name).map{|u| u.name}.reverse) 115 | expect(SortableUser.all(order: [:name, :asc]).map{|u| u.name}).to eq(users.sort_by(&:name).map{|u| u.name}) 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/models/uuid_as_id_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "check basic functionality" do 4 | it "test_simple_creation" do 5 | UuidUser.count.should == 0 6 | 7 | user = UuidUser.new :name => "german" 8 | user.save 9 | 10 | user.should be 11 | user.id.should be 12 | 13 | user.id.should_not == 1 14 | user.id.length.should == 32 # b57525b09a69012e8fbe001d61192f09 for example 15 | 16 | user.name.should == "german" 17 | 18 | UuidUser.count.should == 1 19 | UuidUser.first.name.should == "german" 20 | end 21 | 22 | it "should test different ways to update a record" do 23 | UuidUser.count.should == 0 24 | 25 | user = UuidUser.new :name => "german" 26 | user.should be 27 | user.save 28 | 29 | user.name.should == "german" 30 | 31 | user.name = "nobody" 32 | user.save 33 | 34 | UuidUser.count.should == 1 35 | UuidUser.first.name.should == "nobody" 36 | 37 | u = UuidUser.first 38 | u.should be 39 | u.id.should_not == 1 40 | u.id.length.should == 32 41 | u.update_attribute :name, "root" 42 | UuidUser.first.name.should == "root" 43 | 44 | u = UuidUser.first 45 | u.should be 46 | u.update_attributes :name => "german" 47 | UuidUser.first.name.should == "german" 48 | end 49 | 50 | it "test_deletion" do 51 | UuidUser.count.should == 0 52 | 53 | user = UuidUser.new :name => "german" 54 | user.save 55 | user.should be 56 | 57 | user.name.should == "german" 58 | 59 | UuidUser.count.should == 1 60 | id = user.id 61 | 62 | user.destroy 63 | UuidUser.count.should == 0 64 | $redis.zrank("user:ids", id).should == nil 65 | $redis.hgetall("user:#{id}").should == {} 66 | end 67 | 68 | it "should return first and last objects" do 69 | UuidUser.count.should == 0 70 | UuidUser.first.should == nil 71 | UuidUser.last.should == nil 72 | 73 | user1 = UuidUser.new :name => "german" 74 | user1.save 75 | user1.should be 76 | user1.name.should == "german" 77 | user1.id.should_not == 1 78 | user1.id.length.should == 32 # b57525b09a69012e8fbe001d61192f09 for example 79 | 80 | user2 = UuidUser.new :name => "nobody" 81 | user2.save 82 | user2.should be 83 | user2.name.should == "nobody" 84 | user2.id.should_not == 2 85 | user2.id.length.should == 32 86 | 87 | UuidUser.count.should == 2 88 | 89 | UuidUser.first.should be 90 | UuidUser.last.should be 91 | 92 | UuidUser.first.id.should == user1.id 93 | UuidUser.last.id.should == user2.id 94 | end 95 | 96 | it "should return values with correct classes" do 97 | user = UuidUser.new 98 | user.name = "german" 99 | user.age = 26 100 | user.wage = 124.34 101 | user.male = true 102 | user.save 103 | 104 | user.should be 105 | 106 | u = UuidUser.first 107 | 108 | u.created_at.class.should == DateTime 109 | u.modified_at.class.should == DateTime 110 | u.wage.class.should == Float 111 | u.male.class.to_s.should match(/TrueClass|FalseClass/) 112 | u.age.class.to_s.should match(/Integer|Fixnum/) 113 | u.id.should_not == 1 114 | u.id.length.should == 32 115 | 116 | u.name.should == "german" 117 | u.wage.should == 124.34 118 | u.age.should == 26 119 | u.male.should == true 120 | end 121 | 122 | it "should return correct saved defaults" do 123 | UuidDefaultUser.count.should == 0 124 | UuidDefaultUser.create 125 | UuidDefaultUser.count.should == 1 126 | 127 | u = UuidDefaultUser.first 128 | 129 | u.created_at.class.should == DateTime 130 | u.modified_at.class.should == DateTime 131 | u.wage.class.should == Float 132 | u.male.class.to_s.should match(/TrueClass|FalseClass/) 133 | u.admin.class.to_s.should match(/TrueClass|FalseClass/) 134 | u.age.class.to_s.should match(/Integer|Fixnum/) 135 | 136 | u.name.should == "german" 137 | u.male.should == true 138 | u.age.should == 26 139 | u.wage.should == 256.25 140 | u.admin.should == false 141 | u.id.should_not == 1 142 | u.id.length.should == 32 143 | 144 | du = UuidDefaultUser.new 145 | du.name = "germaninthetown" 146 | du.save 147 | 148 | du_saved = UuidDefaultUser.last 149 | du_saved.name.should == "germaninthetown" 150 | du_saved.admin.should == false 151 | du.id.should_not == 2 152 | du.id.should_not == u.id 153 | du.id.length.should == 32 154 | end 155 | 156 | it "should expand timestamps declaration properly" do 157 | t = UuidTimeStamp.new 158 | t.save 159 | 160 | t.created_at.should be 161 | t.modified_at.should be 162 | t.created_at.day.should == Time.now.day 163 | t.modified_at.day.should == Time.now.day 164 | end 165 | 166 | # from associations_test.rb 167 | it "should maintain correct self referencing link" do 168 | me = UuidUser.create :name => "german", :age => 26, :wage => 10.0, :male => true 169 | friend1 = UuidUser.create :name => "friend1", :age => 26, :wage => 7.0, :male => true 170 | friend2 = UuidUser.create :name => "friend2", :age => 25, :wage => 5.0, :male => true 171 | 172 | me.friends << [friend1, friend2] 173 | 174 | me.friends.count.should == 2 175 | friend1.friends.count.should == 0 176 | friend2.friends.count.should == 0 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /spec/models/validations_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper.rb' 2 | 3 | describe "check associations" do 4 | it "should validate presence if image in photo" do 5 | p = Photo.new 6 | expect(p.save).to be_falsey 7 | expect(p.errors).to be 8 | expect(p.errors[:image]).to include("can't be blank") 9 | 10 | p.image = "test" 11 | expect(p.save).to be_falsey 12 | expect(p.errors).to be 13 | expect(p.errors[:image]).to include("is too short (minimum is 7 characters)") 14 | expect(p.errors[:image]).to include("is invalid") 15 | 16 | p.image = "facepalm.jpg" 17 | p.save 18 | expect(p.errors.blank?).to be true 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/modules/belongs_to_model_within_module.rb: -------------------------------------------------------------------------------- 1 | module BelongsToModelWithinModule 2 | class Reply < RedisOrm::Base 3 | property :body, String, :default => "test" 4 | belongs_to :article, :as => :essay 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/modules/has_many_model_within_module.rb: -------------------------------------------------------------------------------- 1 | module HasManyModelWithinModule 2 | class SpecialComment < RedisOrm::Base 3 | property :body, String, :default => "test" 4 | belongs_to :brochure, :as => :book 5 | end 6 | 7 | class Brochure < RedisOrm::Base 8 | property :title, String 9 | has_many :special_comments 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/polymorphic_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "check polymorphic property" do 4 | it "should provide proper associations and save records correctly for has_one/belongs_to polymorphic" do 5 | book = Book.new title: "Permutation City", price: 1529 6 | book.save 7 | 8 | giftcard = Giftcard.create title: "Happy New Year!" 9 | 10 | ci1 = CatalogItem.create title: giftcard.title 11 | ci1.resource = giftcard 12 | 13 | ci2 = CatalogItem.create title: book.title 14 | ci2.resource = book 15 | 16 | expect(CatalogItem.count).to be(2) 17 | [ci1, ci2].collect{|ci| ci.title}.should == [giftcard.title, book.title] 18 | 19 | ci1.resource.title.should == giftcard.title 20 | ci2.resource.title.should == book.title 21 | 22 | Book.first.catalog_item.should be 23 | Book.first.catalog_item.id.should == ci2.id 24 | 25 | Giftcard.first.catalog_item.should be 26 | Giftcard.first.catalog_item.id.should == ci1.id 27 | end 28 | 29 | it "should provide proper associations and save records correctly for has_many/belongs_to polymorphic" do 30 | country = Country.create name: "Ukraine" 31 | city = City.create name: "Lviv" 32 | 33 | person = Person.create name: "german" 34 | person.location = country 35 | 36 | Person.first.location.id.should == country.id 37 | City.first.people.count.should == 0 38 | Country.first.people.count.should == 1 39 | Country.first.people[0].id.should == person.id 40 | 41 | person = Person.first 42 | person.location = city 43 | 44 | Person.first.location.id.should == city.id 45 | City.first.people.count.should == 1 46 | City.first.people[0].id.should == person.id 47 | Country.first.people.count.should == 0 48 | end 49 | 50 | it "should delete records properly" do 51 | country = Country.create name: "Ukraine" 52 | person = Person.create name: "german" 53 | person.location = country 54 | 55 | Person.first.location.id.should == country.id 56 | Country.first.people.count.should == 1 57 | Country.first.people[0].id.should == person.id 58 | 59 | person.destroy 60 | Person.count.should == 0 61 | $redis.hgetall("user:#{person.id}").should == {} 62 | $redis.zrank("user:ids", person.id).should == nil 63 | Country.first.people.count.should == 0 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/redis.conf: -------------------------------------------------------------------------------- 1 | # Redis configuration file example 2 | 3 | # Note on units: when memory size is needed, it is possible to specifiy 4 | # it in the usual form of 1k 5GB 4M and so forth: 5 | # 6 | # 1k => 1000 bytes 7 | # 1kb => 1024 bytes 8 | # 1m => 1000000 bytes 9 | # 1mb => 1024*1024 bytes 10 | # 1g => 1000000000 bytes 11 | # 1gb => 1024*1024*1024 bytes 12 | # 13 | # units are case insensitive so 1GB 1Gb 1gB are all the same. 14 | 15 | # By default Redis does not run as a daemon. Use 'yes' if you need it. 16 | # Note that Redis will write a pid file in /var/run/redis.pid when daemonized. 17 | daemonize no 18 | 19 | # When running daemonized, Redis writes a pid file in /var/run/redis.pid by 20 | # default. You can specify a custom pid file location here. 21 | pidfile redis.pid 22 | 23 | # Accept connections on the specified port, default is 6379. 24 | # If port 0 is specified Redis will not listen on a TCP socket. 25 | port 0 26 | 27 | # If you want you can bind a single interface, if the bind option is not 28 | # specified all the interfaces will listen for incoming connections. 29 | # 30 | # bind 127.0.0.1 31 | 32 | # Specify the path for the unix socket that will be used to listen for 33 | # incoming connections. There is no default, so Redis will not listen 34 | # on a unix socket when not specified. 35 | # 36 | unixsocket redis.sock 37 | 38 | # Close the connection after a client is idle for N seconds (0 to disable) 39 | timeout 300 40 | 41 | # Set server verbosity to 'debug' 42 | # it can be one of: 43 | # debug (a lot of information, useful for development/testing) 44 | # verbose (many rarely useful info, but not a mess like the debug level) 45 | # notice (moderately verbose, what you want in production probably) 46 | # warning (only very important / critical messages are logged) 47 | loglevel verbose 48 | 49 | # Specify the log file name. Also 'stdout' can be used to force 50 | # Redis to log on the standard output. Note that if you use standard 51 | # output for logging but daemonize, logs will be sent to /dev/null 52 | logfile stdout 53 | 54 | # To enable logging to the system logger, just set 'syslog-enabled' to yes, 55 | # and optionally update the other syslog parameters to suit your needs. 56 | # syslog-enabled no 57 | 58 | # Specify the syslog identity. 59 | # syslog-ident redis 60 | 61 | # Specify the syslog facility. Must be USER or between LOCAL0-LOCAL7. 62 | # syslog-facility local0 63 | 64 | # Set the number of databases. The default database is DB 0, you can select 65 | # a different one on a per-connection basis using SELECT where 66 | # dbid is a number between 0 and 'databases'-1 67 | databases 2 68 | 69 | ################################ SNAPSHOTTING ################################# 70 | # 71 | # Save the DB on disk: 72 | # 73 | # save 74 | # 75 | # Will save the DB if both the given number of seconds and the given 76 | # number of write operations against the DB occurred. 77 | # 78 | # In the example below the behaviour will be to save: 79 | # after 900 sec (15 min) if at least 1 key changed 80 | # after 300 sec (5 min) if at least 10 keys changed 81 | # after 60 sec if at least 10000 keys changed 82 | # 83 | # Note: you can disable saving at all commenting all the "save" lines. 84 | 85 | save 10 1 86 | save 300 10 87 | save 60 10000 88 | 89 | # Compress string objects using LZF when dump .rdb databases? 90 | # For default that's set to 'yes' as it's almost always a win. 91 | # If you want to save some CPU in the saving child set it to 'no' but 92 | # the dataset will likely be bigger if you have compressible values or keys. 93 | rdbcompression yes 94 | 95 | # The filename where to dump the DB 96 | dbfilename dump.rdb 97 | 98 | # The working directory. 99 | # 100 | # The DB will be written inside this directory, with the filename specified 101 | # above using the 'dbfilename' configuration directive. 102 | # 103 | # Also the Append Only File will be created inside this directory. 104 | # 105 | # Note that you must specify a directory here, not a file name. 106 | dir ./ 107 | 108 | ################################# REPLICATION ################################# 109 | 110 | # Master-Slave replication. Use slaveof to make a Redis instance a copy of 111 | # another Redis server. Note that the configuration is local to the slave 112 | # so for example it is possible to configure the slave to save the DB with a 113 | # different interval, or to listen to another port, and so on. 114 | # 115 | # slaveof 116 | 117 | # If the master is password protected (using the "requirepass" configuration 118 | # directive below) it is possible to tell the slave to authenticate before 119 | # starting the replication synchronization process, otherwise the master will 120 | # refuse the slave request. 121 | # 122 | # masterauth 123 | 124 | # When a slave lost the connection with the master, or when the replication 125 | # is still in progress, the slave can act in two different ways: 126 | # 127 | # 1) if slave-serve-stale-data is set to 'yes' (the default) the slave will 128 | # still reply to client requests, possibly with out of data data, or the 129 | # data set may just be empty if this is the first synchronization. 130 | # 131 | # 2) if slave-serve-stale data is set to 'no' the slave will reply with 132 | # an error "SYNC with master in progress" to all the kind of commands 133 | # but to INFO and SLAVEOF. 134 | # 135 | slave-serve-stale-data yes 136 | 137 | ################################## SECURITY ################################### 138 | 139 | # Require clients to issue AUTH before processing any other 140 | # commands. This might be useful in environments in which you do not trust 141 | # others with access to the host running redis-server. 142 | # 143 | # This should stay commented out for backward compatibility and because most 144 | # people do not need auth (e.g. they run their own servers). 145 | # 146 | # Warning: since Redis is pretty fast an outside user can try up to 147 | # 150k passwords per second against a good box. This means that you should 148 | # use a very strong password otherwise it will be very easy to break. 149 | # 150 | # requirepass foobared 151 | 152 | # Command renaming. 153 | # 154 | # It is possilbe to change the name of dangerous commands in a shared 155 | # environment. For instance the CONFIG command may be renamed into something 156 | # of hard to guess so that it will be still available for internal-use 157 | # tools but not available for general clients. 158 | # 159 | # Example: 160 | # 161 | # rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52 162 | # 163 | # It is also possilbe to completely kill a command renaming it into 164 | # an empty string: 165 | # 166 | # rename-command CONFIG "" 167 | 168 | ################################### LIMITS #################################### 169 | 170 | # Set the max number of connected clients at the same time. By default there 171 | # is no limit, and it's up to the number of file descriptors the Redis process 172 | # is able to open. The special value '0' means no limits. 173 | # Once the limit is reached Redis will close all the new connections sending 174 | # an error 'max number of clients reached'. 175 | # 176 | # maxclients 128 177 | 178 | # Don't use more memory than the specified amount of bytes. 179 | # When the memory limit is reached Redis will try to remove keys with an 180 | # EXPIRE set. It will try to start freeing keys that are going to expire 181 | # in little time and preserve keys with a longer time to live. 182 | # Redis will also try to remove objects from free lists if possible. 183 | # 184 | # If all this fails, Redis will start to reply with errors to commands 185 | # that will use more memory, like SET, LPUSH, and so on, and will continue 186 | # to reply to most read-only commands like GET. 187 | # 188 | # WARNING: maxmemory can be a good idea mainly if you want to use Redis as a 189 | # 'state' server or cache, not as a real DB. When Redis is used as a real 190 | # database the memory usage will grow over the weeks, it will be obvious if 191 | # it is going to use too much memory in the long run, and you'll have the time 192 | # to upgrade. With maxmemory after the limit is reached you'll start to get 193 | # errors for write operations, and this may even lead to DB inconsistency. 194 | # 195 | # maxmemory 196 | 197 | # MAXMEMORY POLICY: how Redis will select what to remove when maxmemory 198 | # is reached? You can select among five behavior: 199 | # 200 | # volatile-lru -> remove the key with an expire set using an LRU algorithm 201 | # allkeys-lru -> remove any key accordingly to the LRU algorithm 202 | # volatile-random -> remove a random key with an expire set 203 | # allkeys->random -> remove a random key, any key 204 | # volatile-ttl -> remove the key with the nearest expire time (minor TTL) 205 | # noeviction -> don't expire at all, just return an error on write operations 206 | # 207 | # Note: with all the kind of policies, Redis will return an error on write 208 | # operations, when there are not suitable keys for eviction. 209 | # 210 | # At the date of writing this commands are: set setnx setex append 211 | # incr decr rpush lpush rpushx lpushx linsert lset rpoplpush sadd 212 | # sinter sinterstore sunion sunionstore sdiff sdiffstore zadd zincrby 213 | # zunionstore zinterstore hset hsetnx hmset hincrby incrby decrby 214 | # getset mset msetnx exec sort 215 | # 216 | # The default is: 217 | # 218 | # maxmemory-policy volatile-lru 219 | 220 | # LRU and minimal TTL algorithms are not precise algorithms but approximated 221 | # algorithms (in order to save memory), so you can select as well the sample 222 | # size to check. For instance for default Redis will check three keys and 223 | # pick the one that was used less recently, you can change the sample size 224 | # using the following configuration directive. 225 | # 226 | # maxmemory-samples 3 227 | 228 | ############################## APPEND ONLY MODE ############################### 229 | 230 | # By default Redis asynchronously dumps the dataset on disk. If you can live 231 | # with the idea that the latest records will be lost if something like a crash 232 | # happens this is the preferred way to run Redis. If instead you care a lot 233 | # about your data and don't want to that a single record can get lost you should 234 | # enable the append only mode: when this mode is enabled Redis will append 235 | # every write operation received in the file appendonly.aof. This file will 236 | # be read on startup in order to rebuild the full dataset in memory. 237 | # 238 | # Note that you can have both the async dumps and the append only file if you 239 | # like (you have to comment the "save" statements above to disable the dumps). 240 | # Still if append only mode is enabled Redis will load the data from the 241 | # log file at startup ignoring the dump.rdb file. 242 | # 243 | # IMPORTANT: Check the BGREWRITEAOF to check how to rewrite the append 244 | # log file in background when it gets too big. 245 | 246 | appendonly no 247 | 248 | # The name of the append only file (default: "appendonly.aof") 249 | # appendfilename appendonly.aof 250 | 251 | # The fsync() call tells the Operating System to actually write data on disk 252 | # instead to wait for more data in the output buffer. Some OS will really flush 253 | # data on disk, some other OS will just try to do it ASAP. 254 | # 255 | # Redis supports three different modes: 256 | # 257 | # no: don't fsync, just let the OS flush the data when it wants. Faster. 258 | # always: fsync after every write to the append only log . Slow, Safest. 259 | # everysec: fsync only if one second passed since the last fsync. Compromise. 260 | # 261 | # The default is "everysec" that's usually the right compromise between 262 | # speed and data safety. It's up to you to understand if you can relax this to 263 | # "no" that will will let the operating system flush the output buffer when 264 | # it wants, for better performances (but if you can live with the idea of 265 | # some data loss consider the default persistence mode that's snapshotting), 266 | # or on the contrary, use "always" that's very slow but a bit safer than 267 | # everysec. 268 | # 269 | # If unsure, use "everysec". 270 | 271 | # appendfsync always 272 | appendfsync everysec 273 | # appendfsync no 274 | 275 | # When the AOF fsync policy is set to always or everysec, and a background 276 | # saving process (a background save or AOF log background rewriting) is 277 | # performing a lot of I/O against the disk, in some Linux configurations 278 | # Redis may block too long on the fsync() call. Note that there is no fix for 279 | # this currently, as even performing fsync in a different thread will block 280 | # our synchronous write(2) call. 281 | # 282 | # In order to mitigate this problem it's possible to use the following option 283 | # that will prevent fsync() from being called in the main process while a 284 | # BGSAVE or BGREWRITEAOF is in progress. 285 | # 286 | # This means that while another child is saving the durability of Redis is 287 | # the same as "appendfsync none", that in pratical terms means that it is 288 | # possible to lost up to 30 seconds of log in the worst scenario (with the 289 | # default Linux settings). 290 | # 291 | # If you have latency problems turn this to "yes". Otherwise leave it as 292 | # "no" that is the safest pick from the point of view of durability. 293 | no-appendfsync-on-rewrite no 294 | 295 | ################################ VIRTUAL MEMORY ############################### 296 | 297 | # Virtual Memory allows Redis to work with datasets bigger than the actual 298 | # amount of RAM needed to hold the whole dataset in memory. 299 | # In order to do so very used keys are taken in memory while the other keys 300 | # are swapped into a swap file, similarly to what operating systems do 301 | # with memory pages. 302 | # 303 | # To enable VM just set 'vm-enabled' to yes, and set the following three 304 | # VM parameters accordingly to your needs. 305 | 306 | #vm-enabled no 307 | # vm-enabled yes 308 | 309 | # This is the path of the Redis swap file. As you can guess, swap files 310 | # can't be shared by different Redis instances, so make sure to use a swap 311 | # file for every redis process you are running. Redis will complain if the 312 | # swap file is already in use. 313 | # 314 | # The best kind of storage for the Redis swap file (that's accessed at random) 315 | # is a Solid State Disk (SSD). 316 | # 317 | # *** WARNING *** if you are using a shared hosting the default of putting 318 | # the swap file under /tmp is not secure. Create a dir with access granted 319 | # only to Redis user and configure Redis to create the swap file there. 320 | #vm-swap-file /tmp/redis.swap 321 | 322 | # vm-max-memory configures the VM to use at max the specified amount of 323 | # RAM. Everything that deos not fit will be swapped on disk *if* possible, that 324 | # is, if there is still enough contiguous space in the swap file. 325 | # 326 | # With vm-max-memory 0 the system will swap everything it can. Not a good 327 | # default, just specify the max amount of RAM you can in bytes, but it's 328 | # better to leave some margin. For instance specify an amount of RAM 329 | # that's more or less between 60 and 80% of your free RAM. 330 | #vm-max-memory 0 331 | 332 | # Redis swap files is split into pages. An object can be saved using multiple 333 | # contiguous pages, but pages can't be shared between different objects. 334 | # So if your page is too big, small objects swapped out on disk will waste 335 | # a lot of space. If you page is too small, there is less space in the swap 336 | # file (assuming you configured the same number of total swap file pages). 337 | # 338 | # If you use a lot of small objects, use a page size of 64 or 32 bytes. 339 | # If you use a lot of big objects, use a bigger page size. 340 | # If unsure, use the default :) 341 | #vm-page-size 32 342 | 343 | # Number of total memory pages in the swap file. 344 | # Given that the page table (a bitmap of free/used pages) is taken in memory, 345 | # every 8 pages on disk will consume 1 byte of RAM. 346 | # 347 | # The total swap size is vm-page-size * vm-pages 348 | # 349 | # With the default of 32-bytes memory pages and 134217728 pages Redis will 350 | # use a 4 GB swap file, that will use 16 MB of RAM for the page table. 351 | # 352 | # It's better to use the smallest acceptable value for your application, 353 | # but the default is large in order to work in most conditions. 354 | #vm-pages 134217728 355 | 356 | # Max number of VM I/O threads running at the same time. 357 | # This threads are used to read/write data from/to swap file, since they 358 | # also encode and decode objects from disk to memory or the reverse, a bigger 359 | # number of threads can help with big objects even if they can't help with 360 | # I/O itself as the physical device may not be able to couple with many 361 | # reads/writes operations at the same time. 362 | # 363 | # The special value of 0 turn off threaded I/O and enables the blocking 364 | # Virtual Memory implementation. 365 | #vm-max-threads 4 366 | 367 | ############################### ADVANCED CONFIG ############################### 368 | 369 | # Hashes are encoded in a special way (much more memory efficient) when they 370 | # have at max a given numer of elements, and the biggest element does not 371 | # exceed a given threshold. You can configure this limits with the following 372 | # configuration directives. 373 | #hash-max-zipmap-entries 512 374 | #hash-max-zipmap-value 64 375 | 376 | # Similarly to hashes, small lists are also encoded in a special way in order 377 | # to save a lot of space. The special representation is only used when 378 | # you are under the following limits: 379 | list-max-ziplist-entries 512 380 | list-max-ziplist-value 64 381 | 382 | # Sets have a special encoding in just one case: when a set is composed 383 | # of just strings that happens to be integers in radix 10 in the range 384 | # of 64 bit signed integers. 385 | # The following configuration setting sets the limit in the size of the 386 | # set in order to use this special memory saving encoding. 387 | set-max-intset-entries 512 388 | 389 | # Active rehashing uses 1 millisecond every 100 milliseconds of CPU time in 390 | # order to help rehashing the main Redis hash table (the one mapping top-level 391 | # keys to values). The hash table implementation redis uses (see dict.c) 392 | # performs a lazy rehashing: the more operation you run into an hash table 393 | # that is rhashing, the more rehashing "steps" are performed, so if the 394 | # server is idle the rehashing is never complete and some more memory is used 395 | # by the hash table. 396 | # 397 | # The default is to use this millisecond 10 times every second in order to 398 | # active rehashing the main dictionaries, freeing memory when possible. 399 | # 400 | # If unsure: 401 | # use "activerehashing no" if you have hard latency requirements and it is 402 | # not a good thing in your environment that Redis can reply form time to time 403 | # to queries with 2 milliseconds delay. 404 | # 405 | # use "activerehashing yes" if you don't have such hard requirements but 406 | # want to free memory asap when possible. 407 | activerehashing yes 408 | 409 | ################################## INCLUDES ################################### 410 | 411 | # Include one or more other config files here. This is useful if you 412 | # have a standard template that goes to all redis server but also need 413 | # to customize a few per-server settings. Include files can include 414 | # other files, so use this wisely. 415 | # 416 | # include /path/to/local.conf 417 | # include /path/to/other.conf 418 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rails/all' 2 | require 'rspec' 3 | require_relative '../lib/redis_orm' 4 | 5 | $: << File.dirname(File.expand_path(__FILE__)) + '/../lib/' 6 | 7 | module RedisOrmRails 8 | class Application < ::Rails::Application 9 | end 10 | end 11 | 12 | require 'rspec/rails' 13 | require 'ammeter/init' 14 | 15 | Dir.glob(['spec/classes/*.rb', 'spec/modules/*.rb']).each do |klassfile| 16 | require File.dirname(File.expand_path(__FILE__)) + '/../' + klassfile 17 | end 18 | 19 | RSpec.configure do |config| 20 | config.mock_with :rspec 21 | 22 | config.before(:all) do 23 | begin 24 | $redis = Redis.new(:host => 'localhost') 25 | rescue => e 26 | puts 'Unable to create connection to the redis server: ' + e.message.inspect 27 | end 28 | end 29 | 30 | config.before(:each) do 31 | $redis.flushall if $redis 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/test_helper.rb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /spec/uuid_as_id_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "check basic functionality" do 4 | it "test_simple_creation" do 5 | UuidUser.count.should == 0 6 | 7 | user = UuidUser.new :name => "german" 8 | user.save 9 | 10 | user.should be 11 | user.id.should be 12 | 13 | user.id.should_not == 1 14 | user.id.length.should == 32 # b57525b09a69012e8fbe001d61192f09 for example 15 | 16 | user.name.should == "german" 17 | 18 | UuidUser.count.should == 1 19 | UuidUser.first.name.should == "german" 20 | end 21 | 22 | it "should test different ways to update a record" do 23 | UuidUser.count.should == 0 24 | 25 | user = UuidUser.new :name => "german" 26 | user.should be 27 | user.save 28 | 29 | user.name.should == "german" 30 | 31 | user.name = "nobody" 32 | user.save 33 | 34 | UuidUser.count.should == 1 35 | UuidUser.first.name.should == "nobody" 36 | 37 | u = UuidUser.first 38 | u.should be 39 | u.id.should_not == 1 40 | u.id.length.should == 32 41 | u.update_attribute :name, "root" 42 | UuidUser.first.name.should == "root" 43 | 44 | u = UuidUser.first 45 | u.should be 46 | u.update_attributes :name => "german" 47 | UuidUser.first.name.should == "german" 48 | end 49 | 50 | it "test_deletion" do 51 | UuidUser.count.should == 0 52 | 53 | user = UuidUser.new :name => "german" 54 | user.save 55 | user.should be 56 | 57 | user.name.should == "german" 58 | 59 | UuidUser.count.should == 1 60 | id = user.id 61 | 62 | user.destroy 63 | UuidUser.count.should == 0 64 | $redis.zrank("user:ids", id).should == nil 65 | $redis.hgetall("user:#{id}").should == {} 66 | end 67 | 68 | it "should return first and last objects" do 69 | UuidUser.count.should == 0 70 | UuidUser.first.should == nil 71 | UuidUser.last.should == nil 72 | 73 | user1 = UuidUser.new :name => "german" 74 | user1.save 75 | user1.should be 76 | user1.name.should == "german" 77 | user1.id.should_not == 1 78 | user1.id.length.should == 32 # b57525b09a69012e8fbe001d61192f09 for example 79 | 80 | user2 = UuidUser.new :name => "nobody" 81 | user2.save 82 | user2.should be 83 | user2.name.should == "nobody" 84 | user2.id.should_not == 2 85 | user2.id.length.should == 32 86 | 87 | UuidUser.count.should == 2 88 | 89 | UuidUser.first.should be 90 | UuidUser.last.should be 91 | 92 | UuidUser.first.id.should == user1.id 93 | UuidUser.last.id.should == user2.id 94 | end 95 | 96 | it "should return values with correct classes" do 97 | user = UuidUser.new 98 | user.name = "german" 99 | user.age = 26 100 | user.wage = 124.34 101 | user.male = true 102 | user.save 103 | 104 | user.should be 105 | 106 | u = UuidUser.first 107 | 108 | u.created_at.class.should == DateTime 109 | u.modified_at.class.should == DateTime 110 | u.wage.class.should == Float 111 | u.male.class.to_s.should match(/TrueClass|FalseClass/) 112 | u.age.class.to_s.should match(/Integer|Fixnum/) 113 | u.id.should_not == 1 114 | u.id.length.should == 32 115 | 116 | u.name.should == "german" 117 | u.wage.should == 124.34 118 | u.age.should == 26 119 | u.male.should == true 120 | end 121 | 122 | it "should return correct saved defaults" do 123 | expect(UuidDefaultUser.count).to be(0) 124 | UuidDefaultUser.create 125 | expect(UuidDefaultUser.count).to be(1) 126 | 127 | u = UuidDefaultUser.first 128 | expect(u.created_at.class).to be(DateTime) 129 | expect(u.modified_at.class).to be(DateTime) 130 | expect(u.wage.class).to be(Float) 131 | expect(u.male.class.to_s).to match(/TrueClass|FalseClass/) 132 | expect(u.admin.class.to_s).to match(/TrueClass|FalseClass/) 133 | expect(u.age.class.to_s).to match(/Integer|Fixnum/) 134 | 135 | expect(u.name).to eq("german") 136 | expect(u.male).to be(true) 137 | expect(u.age).to be(26) 138 | expect(u.wage).to be(256.25) 139 | expect(u.admin).to be(false) 140 | expect(u.id).not_to be(1) 141 | expect(u.id.length).to be(32) 142 | 143 | du = UuidDefaultUser.new 144 | du.name = "germaninthetown" 145 | expect(du.save).to be_truthy 146 | expect(du.name).to eq("germaninthetown") 147 | 148 | expect(UuidDefaultUser.count).to be(2) 149 | 150 | du_last = UuidDefaultUser.last 151 | expect(du_last.name).to eq("germaninthetown") 152 | 153 | expect(du_last.admin).to be_falsey 154 | expect(du.id).not_to be(2) 155 | expect(du.id).not_to be(u.id) 156 | expect(du.id.length).to be(32) 157 | end 158 | 159 | it "should expand timestamps declaration properly" do 160 | t = UuidTimeStamp.new 161 | t.save 162 | 163 | t.created_at.should be 164 | t.modified_at.should be 165 | t.created_at.day.should == Time.now.day 166 | t.modified_at.day.should == Time.now.day 167 | end 168 | 169 | # from associations_test.rb 170 | it "should maintain correct self referencing link" do 171 | me = UuidUser.create :name => "german", :age => 26, :wage => 10.0, :male => true 172 | friend1 = UuidUser.create :name => "friend1", :age => 26, :wage => 7.0, :male => true 173 | friend2 = UuidUser.create :name => "friend2", :age => 25, :wage => 5.0, :male => true 174 | 175 | me.friends << [friend1, friend2] 176 | 177 | me.friends.count.should == 2 178 | friend1.friends.count.should == 0 179 | friend2.friends.count.should == 0 180 | end 181 | end 182 | --------------------------------------------------------------------------------