├── .gemtest ├── .gitignore ├── .travis.yml ├── .yardopts ├── Changelog.md ├── Gemfile ├── Guide.md ├── MIT-LICENSE ├── README.md ├── Rakefile ├── ambry.gemspec ├── extras ├── bench.rb └── countries.rb ├── lib ├── ambry.rb ├── ambry │ ├── abstract_key_set.rb │ ├── active_model.rb │ ├── adapter.rb │ ├── adapters │ │ ├── file.rb │ │ └── yaml.rb │ ├── hash_proxy.rb │ ├── mapper.rb │ ├── model.rb │ └── version.rb └── generators │ └── ambry_generator.rb └── spec ├── active_model_spec.rb ├── adapter_spec.rb ├── file_adapter_spec.rb ├── fixtures.yml ├── key_set_spec.rb ├── mapper_spec.rb ├── model_spec.rb └── spec_helper.rb /.gemtest: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/norman/ambry/f6563b0d3c881ace9839114f8a8b6e2f26ab9d07/.gemtest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Gemfile.lock 3 | doc 4 | pkg 5 | coverage 6 | old 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 2.3.0 3 | - rbx 4 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --files=*.md 2 | --protected 3 | --list-undoc 4 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Ambry Changelog 2 | 3 | ## [1.0.0](https://github.com/norman/ambry/tree/1.0.0) - 2016-07-22 ([diff](https://github.com/norman/ambry/compare/0.3.1...1.0.0)) 4 | 5 | * Make ambry compatible with Rails 5 6 | * Drop `ActiveModel::Serializers::Xml` support 7 | 8 | ## [0.3.1](https://github.com/norman/ambry/tree/0.3.1) - 2012-06-20 ([diff](https://github.com/norman/ambry/compare/0.3.0...0.3.1)) 9 | 10 | ### [Norman Clarke](https://github.com/norman) 11 | 12 | * Remove default :main adapter in Rail initializer. 13 | 14 | 15 | ## [0.3.0](https://github.com/norman/ambry/tree/0.3.0) - 2012-03-15 ([diff](https://github.com/norman/ambry/compare/0.2.4...0.3.0)) 16 | 17 | ### [Norman Clarke](https://github.com/norman) 18 | 19 | * Don't raise from finds using hash proxy when a key has a falsy value 20 | * Remove cookie adapter; keep Ambry focused on its core mission. 21 | * Fixed bug which allowed invalid records to be saved with Active Model. Thanks Tute Costa for reporting. 22 | 23 | ## [0.2.4](https://github.com/norman/ambry/tree/0.2.4) - 2011-10-07 ([diff](https://github.com/norman/ambry/compare/0.2.3...0.2.4)) 24 | 25 | ### [Norman Clarke](https://github.com/norman) 26 | 27 | * Add #key? to mappers and models 28 | 29 | ## [0.2.3](https://github.com/norman/ambry/tree/0.2.3) - 2011-10-07 ([diff](https://github.com/norman/ambry/compare/0.2.2...0.2.3)) 30 | 31 | ### [Norman Clarke](https://github.com/norman) 32 | 33 | * Make cookie adapter's cookie name configurable 34 | 35 | ### [Norman Clarke](https://github.com/norman) 36 | 37 | * Allow middleware to accept a Proc 38 | * Add ability to remove an adapter 39 | 40 | ## [0.2.2](https://github.com/norman/ambry/tree/0.2.2) - 2011-09-21 ([diff](https://github.com/norman/ambry/compare/0.2.1...0.2.2)) 41 | 42 | ### [Norman Clarke](https://github.com/norman) 43 | 44 | * Allow middleware to accept a Proc 45 | * Add ability to remove an adapter 46 | 47 | 48 | ## [0.2.1](https://github.com/norman/ambry/tree/0.2.1) - 2011-09-20 ([diff](https://github.com/norman/ambry/compare/0.2.0...0.2.1)) 49 | 50 | ### [Norman Clarke](https://github.com/norman) 51 | 52 | * Fix handling of attributes with falsy values 53 | 54 | 55 | ## [0.2.0](https://github.com/norman/ambry/tree/0.2.0) - 2011-09-05 ([diff](https://github.com/norman/ambry/compare/0.1.2...0.2.0)) 56 | 57 | ### [Norman Clarke](https://github.com/norman) 58 | 59 | * Always create a default in-memory adapter 60 | 61 | ## [0.1.2](https://github.com/norman/ambry/tree/0.1.1) - 2011-08-29 ([diff](https://github.com/norman/ambry/compare/0.1.1...0.1.2)) 62 | 63 | ### [Norman Clarke](https://github.com/norman) 64 | 65 | * Add read-only option to adapters 66 | 67 | ### [Luis Lavena](https://github.com/luislavena) 68 | 69 | * Load Marshal data as binary 70 | 71 | 72 | ## [0.1.1](https://github.com/norman/ambry/tree/0.1.1) - 2011-08-24 ([diff](https://github.com/norman/ambry/compare/0.1.0...0.1.1)) 73 | 74 | ### [Esad Hajdarevic](https://github.com/esad) 75 | 76 | * Allow attribute keys to be strings 77 | * Pass key as attribute when loading database. This allows you to avoid specifying the key twice when manually editing a YAML file. 78 | * Remove use of String#blank? to avoid depending on Active Support 79 | 80 | ### [Ignacio Carrera](https://github.com/nachokb) 81 | 82 | * Added #last and #inspect 83 | 84 | 85 | ## [0.1.0](https://github.com/norman/ambry/tree/0.1.0) - 2011-08-18 86 | 87 | Initial release. 88 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Guide.md: -------------------------------------------------------------------------------- 1 | # The Ambry Guide 2 | 3 | By [Norman Clarke](http://njclarke.com) 4 | 5 | ## What is Ambry? 6 | 7 | Ambry is a database and ORM alternative for small, mostly static models. Use 8 | it to replace database-persisted seed data and ad-hoc structures in your app or 9 | library with plain old Ruby objects that are searchable via a fast, simple 10 | database-like API. 11 | 12 | Many applications and libraries need models for datasets like the 50 US states, 13 | the world's countries indexed by top level domain, or a list of phone number 14 | prefixes and their associated state, province or city. 15 | 16 | Creating a model with Active Record, DataMapper or another ORM and storing this 17 | data in an RDBMS introduces dependencies and is usually overkill for small 18 | and/or static datasets. On the other hand, keeping it in ad-hoc strutures can 19 | offer little flexibility when it comes to filtering, or establishing searchable 20 | relations with other models. 21 | 22 | Ambry offers a middle ground: it loads your dataset from a script or file, 23 | keeps it in memory as a hash, and makes use of Ruby's Enumerable module to 24 | expose a powerful, ORM-like query interface to your data. 25 | 26 | But just one word of warning: Ambry is not like Redis or Membase. It's not a 27 | real database of any kind - SQL or NoSQL. Think of it as a "NoDB." Don't use it 28 | for more than a few megabytes of data: for that you'll want something like 29 | SQLite, Redis, Postgres, or whatever kind of database makes sense for your 30 | needs. 31 | 32 | ## Creating Models 33 | 34 | Almost any Ruby class can be stored as a Ambry Model, simply by extending 35 | the {#Ambry::Model} module, and specifying which fields you want to store: 36 | 37 | class Person 38 | extend Ambry::Model 39 | field :email, :name 40 | end 41 | 42 | You can also extend the {Ambry::ActiveModel} module to add an Active 43 | Record/Rails compatible API. This will be discussed in more detail later. 44 | 45 | ### Setting up a simple model class 46 | 47 | As shown above, simply extend (**not** include) `Ambry::Model` to create a 48 | model class. In your class, you can add persistable/searchable fields using the 49 | {Ambry::Model::ClassMethods#field field} method. This adds accessor methods, 50 | similar to those created by `attr_accessor`, but marks them for internal use by 51 | Ambry. 52 | 53 | class Person 54 | extend Ambry::Model 55 | field :email, :name, :birthday, :favorite_color 56 | end 57 | 58 | All AmbryModels require at least one unique field to use as a hash key. By 59 | convention, the first field you add will be used as the key; `:email` in the 60 | example above. You can also use the {Ambry::Model::ClassMethods#id_field 61 | id\_field} method to specify which field to use as the key. 62 | 63 | ### Basic operations on models 64 | 65 | New instances of Ambry Models can be 66 | {Ambry::Model::InstanceMethods#initialize initialized} with an optional hash 67 | of attributes, or a block. 68 | 69 | person = Person.new :name => "Moe" 70 | 71 | person = Person.new 72 | person.name = "Moe" 73 | 74 | person = Person.new do |p| 75 | p.name = "moe" 76 | end 77 | 78 | When initializing with both a hash and a block, the block is called last, so 79 | accessor calls in the block take precedence: 80 | 81 | person = Person.new(:name => "Larry") do |p| 82 | p.name = "Moe" 83 | end 84 | p.name #=> "Moe" 85 | 86 | Ambry exposes methods for model creation and storage which should look quite 87 | familiar to anyone acquantied with ORM's, but the searching, indexing and 88 | filtering methods are a little different. 89 | 90 | #### CRUD 91 | 92 | {Ambry::Model::ClassMethods#create Create}, 93 | {Ambry::AbstractKeySet#find Read}, 94 | {Ambry::Model::InstanceMethods#update Update}, 95 | {Ambry::Model::InstanceMethods#delete Delete} 96 | methods are fairly standard: 97 | 98 | # create 99 | Person.create :name => "Moe Howard", :email => "moe@3stooges.com" 100 | 101 | # read 102 | moe = Person.get "moe@3stooges.com" # or... 103 | moe = Person.find "moe@3stooges" 104 | 105 | # update 106 | moe.name = "Mo' Howard" 107 | moe.save # or... 108 | moe.update :name => "Mo' Howard" # or... 109 | 110 | # delete 111 | moe.delete # or... 112 | Person.delete "moe@3stooges.com" 113 | 114 | #### Searching 115 | 116 | Finds in Ambry are performed using the `find` class method. If a single 117 | argument is passed, that is treated as a key and Ambry looks for the matching 118 | record: 119 | 120 | Person.find "moe@3stooges" # returns instance of Person 121 | Person.find "cdsafdfds" # raises Ambry::NotFoundError 122 | 123 | If a block is passed, then Ambry looks for records that return true for the 124 | conditions in the block, and returns an iterator that you can use to step 125 | through the results: 126 | 127 | people = Person.find {|p| p.city =~ /Seattle|Portland|London/} 128 | people.each do |person| 129 | puts "#{person.name} probably wishes it was sunny right now." 130 | end 131 | 132 | There are two important things to note here. First, in the `find` block, it 133 | appears that an instance of person is yielded. However, this is actually an 134 | instance of {Ambry::HashProxy}, which allows you to invoke model attributes 135 | either as symbols, strings, or methods. You could also have written the example 136 | these two ways: 137 | 138 | people = Person.find {|p| p[:city] =~ /Seattle|Portland|London/} 139 | people = Person.find {|p| p["city"] =~ /Seattle|Portland|London/} 140 | 141 | Second, the result of the find is not an array, but rather an enumerator that 142 | allows you to iterate over results while instantiating only the model objects 143 | that you use, in order to improve performance. This enumerator will be an 144 | instance of an anonymous subclass of {Ambry::AbstractKeySet}. 145 | 146 | Models' `find` methods are actually implemented directly on key sets: when you 147 | do `Person.find` you're performing a find on a key set that includes all keys 148 | for the Person class. This is important because it allows finds to be refined: 149 | 150 | londoners = Person.find {|p| p.city == "London"} 151 | 152 | londoners.find {|p| p.country == "CA"}.each do |person| 153 | puts "#{person.name} lives in Ontario" 154 | end 155 | 156 | londoners.find {|p| p.country == "GB"}.each do |person| 157 | puts "#{person.name} lives in England" 158 | end 159 | 160 | Key sets can also be manipulated with set arithmetic functions: 161 | 162 | european = Country.find {|c| c.continent == "Europe"} 163 | spanish_speaking = Country.find {|c| c.language == :es} 164 | portuguese_speaking = Country.find {|c| c.language == :pt} 165 | speak_an_iberian_language = spanish_speaking + portuguese_speaking 166 | non_european_iberian_speaking = speak_an_iberian_language - european 167 | 168 | An important implementation detail is that the return value of `Person.find` is 169 | actually an instance of a subclass of {Ambry::AbstractKeySet}. When you 170 | {Ambry::Model.extended extend Ambry::Model}, Ambry creates 171 | {Ambry::Model::ClassMethods#key_class an anonymous subclass} of 172 | Ambry::AbstractKeySet, which facilitates customized finders on a per-model 173 | basis, such as the filters described below. 174 | 175 | #### Filters 176 | 177 | Filters in Ambry are saved finds that can be chained together, conceptually 178 | similar to [Active Record 179 | scopes](http://api.rubyonrails.org/classes/ActiveRecord/NamedScope/ClassMethods.html#method-i-scope). 180 | 181 | You define them with the {Ambry::Model::ClassMethods#filters filters} class 182 | method: 183 | 184 | class Person 185 | extend Ambry::Model 186 | field :email, :gender, :city, :age 187 | 188 | filters do 189 | def men 190 | find {|p| p.gender == "male"} 191 | end 192 | 193 | def who_live_in(city) 194 | find {|p| p.city == city} 195 | end 196 | 197 | def between_ages(min, max) 198 | find {|p| p.age >= min && p.age <= max} 199 | end 200 | end 201 | end 202 | 203 | The filters are then available both as class methods on Person, and instance 204 | methods on key sets resulting from `Person.find`. This allows them to be 205 | chained: 206 | 207 | Person.men.who_live_in("Seattle").between_ages(35, 40) 208 | 209 | #### Relations 210 | 211 | Ambry doesn't include any special methods for creating relations as in Active 212 | Record, because this can easily be accomplished by defining an instance method 213 | in your model: 214 | 215 | class Book 216 | extend Ambry::Model 217 | field :isbn, :title, :author_id, :genre, :year 218 | 219 | def author 220 | Author.get(author_id) 221 | end 222 | 223 | filters 224 | def by_genre(genre) 225 | find {|b| b.genre == genre} 226 | end 227 | 228 | def from_year(year) 229 | find {|b| b.year == year} 230 | end 231 | end 232 | end 233 | 234 | class Author 235 | extend Ambry::Model 236 | field :email, :name 237 | 238 | def books 239 | Book.find {|b| b.author_id == email} 240 | end 241 | end 242 | 243 | Assuming for a moment that books can only have one author, the above example 244 | demonstrates how simple it is to set up `has_many` / `belongs_to` relationships 245 | in Ambry. Since the results of these finds are key sets, you can also chain 246 | any filters you want with them too: 247 | 248 | Author.get("stevenking@writers.com").books.by_genre("horror").from_year(1975) 249 | 250 | 251 | #### Indexes 252 | 253 | If your dataset is on the larger side of what's suitable for Ambry (a few 254 | thousand records or so) then you can use wrap your search with the 255 | {Ambry::Model::ClassMethods#with_index} method to memoize the results and 256 | improve the performance of frequently accessed queries: 257 | 258 | class Book 259 | extend Ambry::Model 260 | field :isbn, :title, :author_id, :genre, :year 261 | 262 | def self.horror 263 | with_index do 264 | find {|b| b.genre == "horror"} 265 | end 266 | end 267 | end 268 | 269 | The argument to `with_index` is simply a name for the index, which needs to be 270 | unique to the model. You can optionally pass a name to `with_index`, which is 271 | a good idea when indexing methods that take arguments: 272 | 273 | def self.by_genre(genre) 274 | with_index("genre_#{genre}") do 275 | find {|b| b.genre == genre} 276 | end 277 | end 278 | 279 | ### Active Model 280 | 281 | Ambry implements Active Model: read more about it 282 | [here](http://yehudakatz.com/2010/01/10/activemodel-make-any-ruby-object-feel-like-activerecord/). 283 | 284 | TODO: write me 285 | 286 | ## Mappers and Adapters 287 | 288 | TODO: write me 289 | 290 | ### Bundled adapters 291 | 292 | TODO: write me 293 | 294 | #### Ambry::Adapter 295 | 296 | TODO: write me 297 | 298 | #### Ambry::Adapters::File 299 | 300 | TODO: write me 301 | 302 | #### Ambry::Adapters::YAML 303 | 304 | TODO: write me 305 | 306 | #### Ambry::Adapters::SignedString 307 | 308 | TODO: write me 309 | 310 | ## Extending Ambry 311 | 312 | TODO: write me 313 | 314 | ### Adding functionality to Ambry::Model 315 | 316 | TODO: write me 317 | 318 | ### Creating your own adapter 319 | 320 | TODO: write me -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Norman Clarke 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | 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, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ambry 2 | 3 | Ambry is a database and ORM replacement for small, mostly static models. Use 4 | it to replace database-persisted seed data and ad-hoc structures in your app or 5 | library with plain old Ruby objects that are searchable via a fast, simple 6 | database-like API. 7 | 8 | It implements Active Model and has generators to integrate nicely with Rails. 9 | You can store your data in either YAML or dump file. 10 | 11 | For more info, take a peek at the 12 | [docs](http://rubydoc.info/github/norman/ambry/frames), or read on for some 13 | quick samples. 14 | 15 | ## A quick tour 16 | 17 | ```ruby 18 | # Create a model. 19 | class Country 20 | # Turn any Ruby object into a Ambry model by extending this module. 21 | extend Ambry::Model 22 | 23 | # The first field listed here will be the "primary key." 24 | field :tld, :name 25 | 26 | # Chainable filters, sort of like Active Record scopes. 27 | filters do 28 | def big 29 | find {|c| c.population > 100_000_000 } 30 | end 31 | 32 | def in_region(region) 33 | find {|c| c.region == region } 34 | end 35 | 36 | def alphabetical 37 | sort_by {|c| c.name } 38 | end 39 | end 40 | 41 | # Root filter, can be used to setup relations. 42 | def regions 43 | Region.find {|r| r.id == region } 44 | end 45 | 46 | end 47 | 48 | # create some contries 49 | Country.create :tld => "AR", :name => "Argentina", :region => :america, :population => 40_000_000 50 | Country.create :tld => "CA", :name => "Canada", :region => :america, :population => 34_000_000 51 | Country.create :tld => "JP", :name => "Japan", :region => :asia, :population => 127_000_000 52 | Country.create :tld => "CN", :name => "China", :region => :asia, :population => 1_300_000_000 53 | # etc. 54 | 55 | # Do some searches 56 | big_asian_countries = Country.big.in_region(:asia) 57 | countries_that_start_with_c = Country.find {|c| c.name =~ /^C/ } 58 | # #first and #last only make sense if you run Ruby 1.9 (creation order) or explicitly specified an order 59 | first_alphabetical = Country.alphabetical.first 60 | last_alphabetical = Country.alphabetical.last 61 | ``` 62 | 63 | ## When should I use Ambry? 64 | 65 | Ambry can be useful for refactoring code with large hash constants and long case 66 | statements. Sometimes it's hard to figure out where code ends and data begins, 67 | but if your code looks like it could be simplified significantly by putting some 68 | things in a data store, yet it's not enough data to justify something like 69 | SQLite, then Ambry could be a good fit. 70 | 71 | 72 | ## Installation 73 | 74 | gem install ambry 75 | 76 | ## Compatibility 77 | 78 | Ambry has been tested against these current Rubies, and is likely compatible 79 | with others. 80 | 81 | * Ruby 1.8.7 - 1.9.3 82 | * Rubinius 1.2.x+ 83 | * JRuby 1.5+ 84 | 85 | ## Author 86 | 87 | [Norman Clarke](mailto:norman@njclarke.com) 88 | 89 | Many thanks to Adrián Mugnolo for initial code review and feedback. 90 | 91 | ## License 92 | 93 | Copyright (c) 2011 Norman Clarke 94 | 95 | Permission is hereby granted, free of charge, to any person obtaining a copy of 96 | this software and associated documentation files (the "Software"), to deal in 97 | the Software without restriction, including without limitation the rights to 98 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 99 | of the Software, and to permit persons to whom the Software is furnished to do 100 | so, subject to the following conditions: 101 | 102 | The above copyright notice and this permission notice shall be included in all 103 | copies or substantial portions of the Software. 104 | 105 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 106 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 107 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 108 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 109 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 110 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 111 | SOFTWARE. 112 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake" 2 | require "rake/testtask" 3 | require "rake/clean" 4 | require "rubygems/package_task" 5 | 6 | task :default => :spec 7 | task :test => :spec 8 | 9 | CLEAN << %w[pkg doc coverage .yardoc] 10 | 11 | begin 12 | desc "Run SimpleCov" 13 | task :coverage do 14 | ENV["coverage"] = "true" 15 | Rake::Task["spec"].execute 16 | end 17 | rescue LoadError 18 | end 19 | 20 | gemspec = File.expand_path("../ambry.gemspec", __FILE__) 21 | if File.exist? gemspec 22 | Gem::PackageTask.new(eval(File.read(gemspec))) { |pkg| } 23 | end 24 | 25 | Rake::TestTask.new(:spec) { |t| t.pattern = "spec/**/*_spec.rb" } 26 | 27 | begin 28 | require "yard" 29 | YARD::Rake::YardocTask.new do |t| 30 | t.options = ["--output-dir=doc"] 31 | t.options << "--files" << ["Guide.md", "Changelog.md"].join(",") 32 | end 33 | rescue LoadError 34 | end 35 | 36 | desc "Run benchmarks" 37 | task :bench do 38 | require File.expand_path("../extras/bench", __FILE__) 39 | end 40 | -------------------------------------------------------------------------------- /ambry.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path("../lib/ambry/version", __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.authors = "Norman Clarke" 5 | s.email = "norman@njclarke.com" 6 | s.files = `git ls-files`.split("\n").reject {|f| f =~ /^\./} 7 | s.homepage = "http://github.com/norman/ambry" 8 | s.name = "ambry" 9 | s.platform = Gem::Platform::RUBY 10 | s.rubyforge_project = "[none]" 11 | s.summary = "An ActiveModel-compatible ORM-like library for storing model instances in an in-memory Hash." 12 | s.test_files = Dir.glob "test/**/*_test.rb" 13 | s.version = Ambry::Version::STRING 14 | s.description = <<-EOD 15 | Ambry is not an ORM, man! It's a database and ORM replacement for (mostly) 16 | static models and small datasets. It provides ActiveModel compatibility, and 17 | flexible searching and storage. 18 | EOD 19 | s.add_development_dependency "ffaker" 20 | s.add_development_dependency "minitest", "~> 5.1" 21 | s.add_development_dependency "activesupport", "~> 5.0" 22 | s.add_development_dependency "activemodel", "~> 5.0" 23 | s.add_development_dependency "rake" 24 | end 25 | -------------------------------------------------------------------------------- /extras/bench.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | require "benchmark" 4 | require "ffaker" 5 | require "ambry" 6 | 7 | N = 500 8 | 9 | class Person 10 | extend Ambry::Model 11 | field :email, :name, :age 12 | 13 | def self.younger_than(age) 14 | with_index("younger_than_#{age}") do 15 | find {|person| person[:age] < age} 16 | end 17 | end 18 | 19 | filters do 20 | def older_than(age) 21 | find {|person| person[:age] > age} 22 | end 23 | 24 | def email_matches(regexp) 25 | find {|person| person[:email] =~ regexp} 26 | end 27 | 28 | end 29 | end 30 | 31 | until Person.count == 1000 do 32 | Person.create \ 33 | :name => Faker::Name.name, 34 | :email => Faker::Internet.email, 35 | :age => rand(100) 36 | end 37 | 38 | keys = Person.all.keys.sort do |a, b| 39 | rand(100) <=> rand(100) 40 | end[0,10] 41 | 42 | Benchmark.bmbm do |x| 43 | 44 | puts "Benchmarking #{N} times:\n\n" 45 | 46 | x.report("Count records") do 47 | N.times do 48 | Person.count {|p| p[:email] =~ /\.com/} 49 | end 50 | end 51 | 52 | x.report("Count scoped records") do 53 | N.times do 54 | Person.older_than(50).count 55 | end 56 | end 57 | 58 | x.report("Get 10 random keys") do 59 | N.times do 60 | keys.each {|k| Person.get(k)} 61 | end 62 | end 63 | 64 | x.report("Find records iterating on values") do 65 | N.times do 66 | Person.find {|p| p[:email] =~ /\.com/} 67 | end 68 | end 69 | 70 | x.report("Find records using proxy method") do 71 | N.times do 72 | Person.find {|p| p.email =~ /\.com/} 73 | end 74 | end 75 | 76 | x.report("Find records iterating on keys") do 77 | N.times do 78 | Person.find_by_key {|k| k =~ /\.com/} 79 | end 80 | end 81 | 82 | x.report("Find scoped people without index") do 83 | N.times do 84 | Person.find {|p| p[:age] < 50 && p[:email] =~ /\.com/} 85 | end 86 | end 87 | 88 | x.report("Find scoped people with index") do 89 | N.times do 90 | Person.younger_than(50).find {|p| p[:email] =~ /\.com/} 91 | end 92 | end 93 | 94 | x.report("Find with chained filters") do 95 | N.times do 96 | Person.older_than(50).email_matches(/\.com/) 97 | end 98 | end 99 | 100 | x.report("Find records iterating on keys and using scope") do 101 | N.times do 102 | Person.find_by_key {|k| k =~ /\.com/}.older_than(50) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /extras/countries.rb: -------------------------------------------------------------------------------- 1 | # A demo of Ambry's filters 2 | 3 | require "bundler/setup" 4 | require "ambry" 5 | 6 | class Country 7 | extend Ambry::Model 8 | field :tld, :name, :population, :region 9 | 10 | filters do 11 | def african 12 | find {|p| p.region == :africa} 13 | end 14 | 15 | def european 16 | find {|p| p.region == :europe} 17 | end 18 | 19 | def population(op, num) 20 | find {|p| p.population.send(op, num)}.sort {|a, b| b.population <=> a.population} 21 | end 22 | end 23 | end 24 | 25 | # Population data from: http://en.wikipedia.org/wiki/List_of_countries_by_population 26 | [ 27 | {:tld => "br", :name => "Brazil", :population => 190_732_694, :region => :america}, 28 | {:tld => "bw", :name => "Botswana", :population => 1_839_833, :region => :africa}, 29 | {:tld => "cn", :name => "China", :population => 1_342_740_000, :region => :asia}, 30 | {:tld => "dz", :name => "Algeria", :population => 33_333_216, :region => :africa}, 31 | {:tld => "eg", :name => "Egypt", :population => 80_335_036, :region => :africa}, 32 | {:tld => "et", :name => "Ethiopia", :population => 85_237_338, :region => :africa}, 33 | {:tld => "fr", :name => "France", :population => 65_821_885, :region => :europe}, 34 | {:tld => "ma", :name => "Morocco", :population => 33_757_175, :region => :africa}, 35 | {:tld => "mc", :name => "Monaco", :population => 33_000, :region => :europe}, 36 | {:tld => "mz", :name => "Mozambique", :population => 20_366_795, :region => :africa}, 37 | {:tld => "ng", :name => "Nigeria", :population => 154_729_000, :region => :africa}, 38 | {:tld => "sc", :name => "Seychelles", :population => 80_654, :region => :africa} 39 | ].each {|c| Country.create(c)} 40 | 41 | @african_countries = Country.african 42 | @bigger_countries = Country.population(:>=, 50_000_000) 43 | @smaller_countries = Country.population(:<=, 5_000_000) 44 | @european_countries = Country.european 45 | @bigger_african_countries = Country.african.population(:>=, 50_000_000) 46 | @bigger_non_african_countries = @bigger_countries - @african_countries 47 | @bigger_or_european_countries = @bigger_countries + @european_countries 48 | @smaller_or_european_countries = @smaller_countries + @european_countries 49 | @smaller_european_countries = @smaller_countries & @european_countries 50 | 51 | instance_variables.each do |name| 52 | puts "%s: %s" % [ 53 | name.to_s.gsub("_", " ").gsub("@", ""), 54 | instance_variable_get(name).all.map(&:name).join(", ") 55 | ] 56 | end 57 | 58 | # Output: 59 | # 60 | # african countries: Algeria, Botswana, Egypt, Ethiopia, Nigeria, Seychelles, Mozambique, Morocco 61 | # bigger countries: China, Brazil, Nigeria, Ethiopia, Egypt, France 62 | # smaller countries: Botswana, Seychelles, Monaco 63 | # european countries: France, Monaco 64 | # bigger african countries: Egypt, Ethiopia, Nigeria 65 | # bigger non african countries: China, Brazil, France 66 | # bigger or european countries: China, Brazil, Nigeria, Ethiopia, Egypt, France, Monaco 67 | # smaller and european countries: Botswana, Seychelles, Monaco, France 68 | # smaller european countries: Monaco 69 | -------------------------------------------------------------------------------- /lib/ambry.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require "thread" 3 | require "ambry/adapter" 4 | require "ambry/abstract_key_set" 5 | require "ambry/mapper" 6 | require "ambry/model" 7 | require "ambry/hash_proxy" 8 | require "ambry/adapters/file" 9 | 10 | # Ambry is a database and ORM replacement for small, mostly static models. 11 | # 12 | # Ambry is free software released under the terms of the MIT License. 13 | # @author Norman Clarke 14 | module Ambry 15 | extend self 16 | 17 | @lock = Mutex.new 18 | 19 | # The default adapter name. 20 | attr_reader :default_adapter_name 21 | @default_adapter_name = :main 22 | 23 | # A hash of all instantiated Ambry adapters. 24 | attr_reader :adapters 25 | @adapters = {} 26 | 27 | # Registers an adapter with Ambry. This facilitates allowing models to 28 | # specify an adapter by name rather than class or instance. 29 | # 30 | # @param [Symbol] adapter The adapter name. 31 | # @see Ambry::Model::ClassMethods#use 32 | def register_adapter(adapter) 33 | name = adapter.name.to_sym 34 | if adapters[name] 35 | raise AmbryError, "Adapter #{name.inspect} already registered" 36 | end 37 | @lock.synchronize do 38 | adapters[name] = adapter 39 | end 40 | end 41 | 42 | # Removes an adapter from Ambry. 43 | # 44 | # @param [Symbol] adapter The adapter name. 45 | def remove_adapter(name) 46 | @lock.synchronize do 47 | adapters[name] = nil 48 | adapters.delete name 49 | end 50 | end 51 | 52 | # Base error for Ambry. 53 | class AmbryError < StandardError ; end 54 | 55 | # Raised when a single instance is expected but could not be found. 56 | class NotFoundError < AmbryError 57 | 58 | # @param [String] klass The class from which the error originated. 59 | # @param [String] key The key whose lookup trigged the error. 60 | def initialize(*args) 61 | super('Could not find %s with key "%s"' % args) 62 | end 63 | end 64 | end 65 | 66 | Ambry::Adapter.new 67 | -------------------------------------------------------------------------------- /lib/ambry/abstract_key_set.rb: -------------------------------------------------------------------------------- 1 | module Ambry 2 | 3 | # @abstract 4 | class AbstractKeySet 5 | extend Forwardable 6 | include Enumerable 7 | 8 | attr_accessor :keys, :mapper 9 | def_delegators :keys, :empty?, :length, :size 10 | def_delegators :to_enum, :each 11 | 12 | # Create a new KeySet from an array of keys and a mapper. 13 | def initialize(keys = nil, mapper = nil) 14 | @keys = keys || [].freeze 15 | # Assume that if a frozen array is passed in, it's already been compacted 16 | # and uniqued in order to improve performance. 17 | unless @keys.frozen? 18 | @keys.uniq! 19 | @keys.compact! 20 | @keys.freeze 21 | end 22 | @mapper = mapper 23 | end 24 | 25 | def +(key_set) 26 | self.class.new(keys + key_set.keys, mapper) 27 | end 28 | alias | + 29 | 30 | def -(key_set) 31 | self.class.new((keys - key_set.keys).freeze, mapper) 32 | end 33 | 34 | def &(key_set) 35 | self.class.new((keys & key_set.keys).compact.freeze, mapper) 36 | end 37 | 38 | # With no block, returns an instance for the first key. If a block is given, 39 | # it returns the first instance yielding a true value. 40 | def first(&block) 41 | block_given? ? all.detect(&block) : all.first 42 | end 43 | 44 | # With no block, returns an instance for the first key. If a block is given, 45 | # it returns the first instance yielding a true value. 46 | def last(&block) 47 | block_given? ? all.reverse.detect(&block) : all.last 48 | end 49 | 50 | # With no block, returns the number of keys. If a block is given, counts the 51 | # number of elements yielding a true value. 52 | def count(&block) 53 | return keys.count unless block_given? 54 | proxy = HashProxy.new 55 | keys.inject(0) do |count, key| 56 | proxy.with(mapper[key], &block) ? count.succ : count 57 | end 58 | end 59 | 60 | def find(id = nil, &block) 61 | return mapper.get(id) if id 62 | return self unless block_given? 63 | proxy = HashProxy.new 64 | self.class.new(keys.inject([]) do |found, key| 65 | found << key if proxy.with(mapper[key], &block) 66 | found 67 | end, mapper) 68 | end 69 | 70 | def to_enum 71 | KeyIterator.new(keys) {|k| @mapper.get(k)} 72 | end 73 | alias all to_enum 74 | 75 | def find_by_key(&block) 76 | return self unless block_given? 77 | self.class.new(keys.inject([]) do |set, key| 78 | set << key if yield(key); set 79 | end, mapper) 80 | end 81 | 82 | def sort(&block) 83 | proxies = HashProxySet.new 84 | self.class.new(@keys.sort do |a, b| 85 | begin 86 | yield(*proxies.using(mapper[a], mapper[b])) 87 | ensure 88 | proxies.clear 89 | end 90 | end, mapper) 91 | end 92 | 93 | def limit(length) 94 | self.class.new(@keys.first(length).freeze, mapper) 95 | end 96 | end 97 | 98 | class KeyIterator 99 | include Enumerable 100 | 101 | attr_reader :keys, :callable 102 | 103 | def initialize(keys, &callable) 104 | @keys = keys 105 | @callable = callable 106 | end 107 | 108 | def reverse 109 | KeyIterator.new(keys.reverse, &callable) 110 | end 111 | 112 | def last 113 | callable.call keys.last 114 | end 115 | 116 | def each(&block) 117 | block_given? ? keys.each {|k| yield callable.call(k)} : to_enum 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/ambry/active_model.rb: -------------------------------------------------------------------------------- 1 | require "active_model" 2 | 3 | module Ambry 4 | # Extend this module if you want {Active Model}[http://github.com/rails/rails/tree/master/activemodel] 5 | # support. Active Model is an API provided by Rails to make any Ruby object 6 | # behave like an Active Record model instance. You can read an older writeup 7 | # about it {here}[http://yehudakatz.com/2010/01/10/activemodel-make-any-ruby-object-feel-like-activerecord/]. 8 | module ActiveModel 9 | def self.extended(base) 10 | base.instance_eval do 11 | extend ClassMethods 12 | include InstanceMethods 13 | extend ::ActiveModel::Naming 14 | extend ::ActiveModel::Translation 15 | include ::ActiveModel::Validations 16 | include ::ActiveModel::Serializers::JSON 17 | extend ::ActiveModel::Callbacks 18 | define_model_callbacks :save, :destroy 19 | end 20 | end 21 | 22 | # Custom validations. 23 | module Validations 24 | # A uniqueness validator, similar to the one provided by Active Record. 25 | class Uniqueness< ::ActiveModel::EachValidator 26 | def validate_each(record, attribute, value) 27 | return if record.persisted? 28 | if attribute.to_sym == record.class.id_method 29 | begin 30 | if record.class.mapper[value] 31 | record.errors[attribute] << "must be unique" 32 | end 33 | rescue Ambry::NotFoundError 34 | end 35 | else 36 | if record.class.all.detect {|x| x.send(attribute) == value} 37 | record.errors[attribute] << "must be unique" 38 | end 39 | end 40 | end 41 | end 42 | end 43 | 44 | module ClassMethods 45 | # Create and save a model instance, raising an exception if any errors 46 | # occur. 47 | def create!(*args) 48 | new(*args).save! 49 | end 50 | 51 | # Create and save a model instance, returning false if any errors 52 | # occur. 53 | def create(*args) 54 | new(*args).save! 55 | rescue AmbryError 56 | false 57 | end 58 | 59 | # Validate the uniqueness of a field's value in a model instance. 60 | def validates_uniqueness_of(*attr_names) 61 | validates_with Validations::Uniqueness, _merge_attributes(attr_names) 62 | end 63 | 64 | def model_name 65 | @model_name ||= ::ActiveModel::Name.new(self) 66 | end 67 | end 68 | 69 | module InstanceMethods 70 | def initialize(*args) 71 | @new_record = true 72 | super 73 | end 74 | 75 | def attributes 76 | hash = to_hash 77 | hash.keys.each {|k| hash[k.to_s] = hash.delete(k)} 78 | hash 79 | end 80 | 81 | def keys 82 | self.class.attribute_names 83 | end 84 | 85 | def to_model 86 | self 87 | end 88 | 89 | def new_record? 90 | @new_record 91 | end 92 | 93 | def persisted? 94 | !new_record? 95 | end 96 | 97 | def save 98 | return false unless valid? 99 | run_callbacks(:save) do 100 | @new_record = false 101 | super 102 | end 103 | end 104 | 105 | def save! 106 | if !valid? 107 | raise Ambry::AmbryError, errors.to_a.join(", ") 108 | else 109 | save 110 | end 111 | end 112 | 113 | def to_param 114 | to_id if persisted? 115 | end 116 | 117 | def to_key 118 | [to_param] if persisted? 119 | end 120 | 121 | def destroy 122 | run_callbacks(:destroy) { delete } 123 | end 124 | 125 | def update_attributes(*args) 126 | run_callbacks(:save) { update(*args) } 127 | end 128 | 129 | def to_partial_path 130 | "#{self.class.name.pluralize.underscore}/#{self.class.name.underscore}" 131 | end 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/ambry/adapter.rb: -------------------------------------------------------------------------------- 1 | module Ambry 2 | 3 | # Adapters are responsible for persisting the database. This base adapter 4 | # offers no persistence, all IO operations are just stubs. Adapters must also 5 | # present the full database as a Hash to the mapper, and provide a `key` 6 | # method that returns an array with all the keys for the specified model 7 | # class. 8 | class Adapter 9 | 10 | attr_reader :name 11 | attr_reader :db 12 | attr_accessor :read_only 13 | 14 | # @option options [String] :name The adapter name. Defaults to {#Ambry.default_adapter_name}. 15 | def initialize(options = {}) 16 | @name = options[:name] || Ambry.default_adapter_name 17 | @read_only = false 18 | load_database 19 | Ambry.register_adapter(self) 20 | end 21 | 22 | # Get a hash of all the data for the specified model class. 23 | # @param klass [#to_s] The model class whose data to return. 24 | def db_for(klass) 25 | @db[klass.to_s] ||= {} 26 | end 27 | 28 | # Loads the database. For this adapter, that means simply creating a new 29 | # hash. 30 | def load_database 31 | @db = {} 32 | end 33 | 34 | # These are all just noops for this adapter, which uses an in-memory hash 35 | # and offers no persistence. 36 | 37 | # Inheriting adapters can overload this method to export the data to a 38 | # String. 39 | def export_data 40 | true 41 | end 42 | 43 | # Inheriting adapters can overload this method to load the data from some 44 | # kind of storage. 45 | def import_data 46 | true 47 | end 48 | 49 | # Is the adapter read only? If so, attempts to write data will raise an 50 | # AmbryError. 51 | def read_only? 52 | @read_only 53 | end 54 | 55 | # Inheriting adapters can overload this method to persist the data to some 56 | # kind of storage. 57 | def save_database 58 | raise AmbryError if read_only? 59 | true 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/ambry/adapters/file.rb: -------------------------------------------------------------------------------- 1 | module Ambry 2 | module Adapters 3 | # Loads and saves hash database from a Marshal.dump file. 4 | class File < Adapter 5 | 6 | attr_reader :file_path 7 | attr :lock 8 | 9 | def initialize(options) 10 | @file_path = options[:file] 11 | @read_only = !! options[:read_only] 12 | @lock = Mutex.new 13 | super 14 | end 15 | 16 | def load_database 17 | @db = import_data 18 | (!@db || @db.empty?) ? @db = {} : @db.map(&:freeze) 19 | rescue Errno::ENOENT 20 | # @TODO warn via logger when file doesn't exist 21 | @db = {} 22 | end 23 | 24 | def export_data 25 | Marshal.dump(db) 26 | end 27 | 28 | def import_data 29 | data = ::File.open(file_path, "rb") { |f| f.read } 30 | Marshal.load(data) 31 | end 32 | 33 | def save_database 34 | super 35 | @lock.synchronize do 36 | ::File.open(file_path, "wb") {|f| f.write(export_data)} 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/ambry/adapters/yaml.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | module Ambry 4 | module Adapters 5 | # An Adapter that uses YAML for its storage. 6 | class YAML < File 7 | 8 | def import_data 9 | ::YAML.load(::File.read(file_path)) 10 | end 11 | 12 | def export_data 13 | db.to_yaml 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/ambry/hash_proxy.rb: -------------------------------------------------------------------------------- 1 | module Ambry 2 | # Wrapper around hash instances that allows values to be accessed as symbols, 3 | # strings or method invocations. It behaves similary to OpenStruct, with the 4 | # fundamental difference being that you instantiate *one* HashProxy instance 5 | # and reassign its Hash during a loop in order to avoid creating garbage. 6 | class HashProxy 7 | attr :hash 8 | 9 | def self.with(hash, &block) 10 | new.with(hash, &block) 11 | end 12 | 13 | # Allows accessing a hash attribute as a method. 14 | def method_missing(symbol) 15 | if hash.key?(symbol) 16 | hash[symbol] 17 | elsif hash.key?(symbol.to_s) 18 | hash[symbol.to_s] 19 | else 20 | raise NoMethodError 21 | end 22 | end 23 | 24 | # Allows accessing a hash attribute as hash key, either a string or symbol. 25 | def [](key) 26 | if hash.key?(key) then hash[key] 27 | elsif hash.key?(key.to_sym) then hash[key.to_sym] 28 | elsif hash.key?(key.to_s) then hash[key.to_s] 29 | end 30 | end 31 | 32 | def key?(key) 33 | hash.key?(key) or hash.key?(key.to_sym) or hash.key?(key.to_s) 34 | end 35 | 36 | # Remove the hash. 37 | def clear 38 | @hash = nil 39 | end 40 | 41 | # Assign the value to hash and return self. 42 | def using(hash) 43 | @hash = hash ; self 44 | end 45 | 46 | # Set the hash to use while calling the block. When the block ends, the 47 | # hash is unset. 48 | def with(hash, &block) 49 | yield using hash ensure clear 50 | end 51 | end 52 | 53 | # Like HashProxy, but proxies access to two or more Hash instances. 54 | class HashProxySet 55 | 56 | attr :proxies 57 | 58 | def initialize 59 | @proxies = [] 60 | end 61 | 62 | def using(*args) 63 | args.size.times { proxies.push HashProxy.new } if proxies.empty? 64 | proxies.each_with_index {|proxy, index| proxy.using args[index] } 65 | end 66 | 67 | def clear 68 | proxies.map(&:clear) 69 | end 70 | end 71 | 72 | end -------------------------------------------------------------------------------- /lib/ambry/mapper.rb: -------------------------------------------------------------------------------- 1 | module Ambry 2 | 3 | # Mappers provide the middle ground between models and adapters. Mappers are 4 | # responsible for performing finds and moving objects in and out of the 5 | # hash. 6 | class Mapper 7 | extend Forwardable 8 | attr :hash 9 | attr_accessor :adapter_name, :klass, :indexes, :options 10 | def_delegators :hash, :clear, :delete, :key? 11 | def_delegators :key_set, :all, :count, :find, :find_by_key, :first, :last, :keys 12 | 13 | def initialize(klass, adapter_name = nil, options = {}) 14 | @klass = klass 15 | @adapter_name = adapter_name || Ambry.default_adapter_name 16 | @indexes = {} 17 | @lock = Mutex.new 18 | @options = options 19 | @hash = adapter.db_for(klass) 20 | end 21 | 22 | # Returns a hash or model attributes corresponding to the provided key. 23 | def [](key) 24 | hash[key] or raise NotFoundError.new(klass, key) 25 | end 26 | 27 | # Sets a hash by key. 28 | def []=(key, value) 29 | @lock.synchronize do 30 | @indexes = {} 31 | if value.id_changed? 32 | hash.delete value.to_id(true) 33 | end 34 | saved = hash[key] = value.to_hash.freeze 35 | adapter.save_database if @options[:sync] 36 | saved 37 | end 38 | end 39 | 40 | # Memoize the output of a find in a threadsafe manner. 41 | def add_index(name, indexable) 42 | @lock.synchronize do 43 | @indexes[name] = indexable 44 | end 45 | end 46 | 47 | # Get the adapter. 48 | def adapter 49 | Ambry.adapters[adapter_name] 50 | end 51 | 52 | # Get an instance by key 53 | def get(key) 54 | klass.send :from_hash, self[key].merge(klass.id_method => key) 55 | end 56 | 57 | def key_set 58 | klass.key_class.new(hash.keys.freeze, self) 59 | end 60 | 61 | # Sets an instance, invoking its to_id method 62 | def put(instance) 63 | self[instance.to_id] = instance 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/ambry/model.rb: -------------------------------------------------------------------------------- 1 | module Ambry 2 | 3 | module Model 4 | def self.extended(base) 5 | base.instance_eval do 6 | @lock = Mutex.new 7 | @attribute_names = [] 8 | @key_class = Class.new(Ambry::AbstractKeySet) 9 | extend ClassMethods 10 | include InstanceMethods 11 | include Comparable 12 | end 13 | end 14 | 15 | module ClassMethods 16 | extend Forwardable 17 | attr_accessor :attribute_names, :id_method, :mapper 18 | attr_reader :key_class 19 | def_delegators(*[:find, Enumerable.public_instance_methods(false)].flatten) 20 | def_delegators(:mapper, :[], :all, :delete, :first, :last, :get, :count, :find, :find_by_key, :keys, :key?) 21 | alias id_field id_method= 22 | 23 | def field(*names) 24 | names.each do |name| 25 | # First attribute added is the default id 26 | id_field name if attribute_names.empty? 27 | attribute_names << name.to_sym 28 | class_eval(<<-EOM, __FILE__, __LINE__ + 1) 29 | def #{name} 30 | defined?(@#{name}) ? @#{name} : @attributes[:#{name}] 31 | end 32 | 33 | def #{name}=(value) 34 | @#{name} = value 35 | end 36 | EOM 37 | end 38 | end 39 | 40 | def use(adapter_name, options = {}) 41 | @mapper = nil 42 | @adapter_name = adapter_name 43 | @mapper_options = options 44 | end 45 | 46 | # Memoize the output of the method call invoked in the block. 47 | # @param [#to_s] name If not given, the name of the method calling with_index will be used. 48 | def with_index(name = nil, &block) 49 | name ||= caller(1)[0].match(/in `(.*)'\z/)[1] 50 | mapper.indexes[name.to_s] or begin 51 | indexable = yield 52 | mapper.add_index(name, indexable) 53 | end 54 | end 55 | 56 | def create(hash) 57 | new(hash).save 58 | end 59 | 60 | # The point of this method is to provide a fast way to get model instances 61 | # based on the hash attributes managed by the mapper and adapter. 62 | # 63 | # The hash arg gets frozen, which can be a nasty side-effect, but helps 64 | # avoid hard-to-track-down bugs if the hash is updated somewhere outside 65 | # the model. This should only be used internally to Ambry, which is why 66 | # it's private. 67 | def from_hash(hash) 68 | instance = allocate 69 | instance.instance_variable_set :@attributes, hash.freeze 70 | instance 71 | end 72 | private :from_hash 73 | 74 | def filters(&block) 75 | key_class.class_eval(&block) 76 | key_class.instance_methods(false).each do |name| 77 | instance_eval(<<-EOM, __FILE__, __LINE__ + 1) 78 | def #{name}(*args) 79 | mapper.key_set.#{name}(*args) 80 | end 81 | EOM 82 | end 83 | end 84 | 85 | def mapper 86 | @mapper or @lock.synchronize do 87 | name = @adapter_name || Ambry.default_adapter_name 88 | options = @mapper_options || {} 89 | @mapper ||= Mapper.new(self, name, options) 90 | end 91 | end 92 | 93 | def inspect 94 | "#{name}(#{attribute_names * ', '})" 95 | end 96 | end 97 | 98 | module InstanceMethods 99 | 100 | # Ambry models can be instantiated with a hash of attribures, a block, 101 | # or both. If both a hash and block are given, then the values set inside 102 | # the block will take precedence over those set in the hash. 103 | # 104 | # @example 105 | # Person.new :name => "Joe" 106 | # Person.new {|p| p.name = "Joe"} 107 | # Person.new(params[:person]) {|p| p.age = 38} 108 | # 109 | def initialize(attributes = nil, &block) 110 | @attributes = {}.freeze 111 | return unless attributes || block_given? 112 | if attributes 113 | self.class.attribute_names.each do |name| 114 | value = attributes[name] || attributes[name.to_s] 115 | send("#{name}=", value) if value 116 | end 117 | end 118 | yield self if block_given? 119 | end 120 | 121 | # Ambry models implement the <=> method and mix in Comparable to provide 122 | # sorting methods. This default implementation compares the result of 123 | # #to_id. If the items being compared are not of the same kind. 124 | def <=>(instance) 125 | to_id <=> instance.to_id if instance.kind_of? self.class 126 | end 127 | 128 | # Get a hash of the instance's model attributes. 129 | def to_hash 130 | self.class.attribute_names.inject({}) do |hash, key| 131 | hash[key] = self.send(key); hash 132 | end 133 | end 134 | 135 | # Returns true is the model's id field has been updated. 136 | def id_changed? 137 | to_id != @attributes[self.class.id_method] 138 | end 139 | 140 | # Invoke the model's id method to return this instance's unique key. If 141 | # true is passed, then the id will be read from the attributes hash rather 142 | # than from an instance variable. This allows you to retrieve the old id, 143 | # in the event that the id has been changed. 144 | def to_id(use_old = false) 145 | use_old ? @attributes[self.class.id_method] : send(self.class.id_method) 146 | end 147 | 148 | # Tell the mapper to save the data for this model instance. 149 | def save 150 | self.class.mapper.put(self) 151 | end 152 | 153 | # Update this instance's attributes and invoke #save. 154 | def update(attributes) 155 | HashProxy.with(attributes) do |proxy| 156 | self.class.attribute_names.each do |name| 157 | send("#{name}=", proxy[name]) if proxy.key?(name) 158 | end 159 | end 160 | save 161 | end 162 | 163 | # Tell the mapper to delete the data for this instance. 164 | def delete 165 | self.class.delete(self.to_id) 166 | end 167 | 168 | def inspect 169 | "#<#{self.class.name} #{self.class.attribute_names.map { |attr| "#{attr}: #{self.send(attr).inspect}" } * ', ' }>" 170 | end 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /lib/ambry/version.rb: -------------------------------------------------------------------------------- 1 | module Ambry 2 | module Version 3 | MAJOR = 1 4 | MINOR = 0 5 | TINY = 0 6 | BUILD = nil 7 | STRING = [MAJOR, MINOR, TINY, BUILD].compact.join('.') 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/generators/ambry_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/actions' 3 | 4 | # This generator adds an initializer and default empty database to your Rails 5 | # application. It can be invoked on the command line like: 6 | # 7 | # rails generate ambry 8 | # 9 | class AmbryGenerator < Rails::Generators::Base 10 | 11 | # Create the initializer and empty database. 12 | def create_files 13 | initializer("ambry.rb") do 14 | <<-EOI 15 | require "ambry/adapters/yaml" 16 | require "ambry/active_model" 17 | Ambry.remove_adapter :main 18 | Ambry::Adapters::YAML.new :file => Rails.root.join('db', 'ambry.yml') 19 | EOI 20 | end 21 | create_file("db/ambry.yml", '') 22 | end 23 | end -------------------------------------------------------------------------------- /spec/active_model_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../spec_helper", __FILE__) 2 | require "ambry/active_model" 3 | 4 | class Book 5 | extend Ambry::Model 6 | extend Ambry::ActiveModel 7 | field :slug, :title, :author 8 | validates_presence_of :slug 9 | validates_uniqueness_of :slug, :title 10 | before_save :save_callback_fired 11 | before_destroy :destroy_callback_fired 12 | 13 | def save_callback_fired 14 | @save_callback_fired = true 15 | end 16 | 17 | def destroy_callback_fired 18 | @destroy_callback_fired = true 19 | end 20 | end 21 | 22 | module ActiveModuleSupportSpecHelper 23 | def valid_book 24 | {:slug => "war-and-peace", :title => "War and Peace", :author => "Leo Tolstoy"} 25 | end 26 | 27 | def load_fixtures 28 | Ambry.adapters.clear 29 | Ambry::Adapter.new :name => :main 30 | Book.use :main 31 | @model = Book.create! valid_book 32 | end 33 | end 34 | 35 | describe Ambry::ActiveModel do 36 | 37 | before { load_fixtures } 38 | 39 | include ActiveModuleSupportSpecHelper 40 | include ActiveModel::Lint::Tests 41 | 42 | describe ".model_name" do 43 | it "should return an ActiveModel::Name" do 44 | assert_kind_of ::ActiveModel::Name, Book.model_name 45 | end 46 | end 47 | 48 | describe "#keys" do 49 | it "should return an array of attribute names" do 50 | assert @model.keys.include?(:slug), "@model.keys should include :slug" 51 | end 52 | end 53 | 54 | describe "#save!" do 55 | it "should raise an exception if the model is not valid" do 56 | assert_raises Ambry::AmbryError do 57 | Book.new.save! 58 | end 59 | end 60 | end 61 | 62 | describe "#create" do 63 | it "should not store invalid model instances" do 64 | old_count = Book.count 65 | Book.create({}) 66 | assert_equal old_count, Book.count 67 | end 68 | end 69 | 70 | describe "#save" do 71 | it "should not store invalid model instances" do 72 | old_count = Book.count 73 | book = Book.new 74 | assert !book.valid? 75 | book.save 76 | assert_equal old_count, Book.count 77 | end 78 | end 79 | 80 | describe "#to_json" do 81 | it "should serialize" do 82 | json = @model.to_json 83 | refute_nil @model.to_json 84 | assert_match(/"author":"Leo Tolstoy"/, json) 85 | end 86 | end 87 | 88 | describe "#valid?" do 89 | it "should do validation" do 90 | book = Book.new 91 | refute book.valid? 92 | book.slug = "hello-world" 93 | assert book.valid? 94 | end 95 | end 96 | 97 | describe "callbacks" do 98 | it "should fire save callbacks" do 99 | Book.mapper.clear 100 | book = Book.new valid_book 101 | assert book.valid? 102 | book.save 103 | assert book.instance_variable_defined? :@save_callback_fired 104 | end 105 | 106 | it "should fire destroy callbacks" do 107 | @model.destroy 108 | assert @model.instance_variable_defined? :@destroy_callback_fired 109 | end 110 | end 111 | 112 | describe ".validates_uniqueness_of" do 113 | it "should validate on id attribute" do 114 | @book = Book.new valid_book.merge(:title => "War and Peace II") 115 | refute @book.valid? 116 | @book.slug = "war-and-peace-2" 117 | assert @book.valid? 118 | end 119 | 120 | it "should validate on non-id attribute" do 121 | @book = Book.new valid_book.merge(:slug => "war-and-peace-2") 122 | refute @book.valid? 123 | @book.title = "War and Peace II" 124 | assert @book.valid? 125 | end 126 | end 127 | 128 | describe "to_partial_path" do 129 | it "should return something reasonable" do 130 | assert_equal "books/book", Book.new.to_partial_path 131 | end 132 | end 133 | 134 | end 135 | -------------------------------------------------------------------------------- /spec/adapter_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../spec_helper", __FILE__) 2 | 3 | describe Ambry::Adapter do 4 | 5 | before { Ambry.adapters.clear } 6 | after { Ambry.adapters.clear } 7 | 8 | describe "#initialize" do 9 | 10 | it "should register itself" do 11 | Ambry::Adapter.new :name => :an_adapter 12 | assert_equal :an_adapter, Ambry.adapters.keys.first 13 | end 14 | 15 | it "should use a default name if none given" do 16 | assert_equal Ambry.default_adapter_name, Ambry::Adapter.new.name 17 | end 18 | 19 | it "should raise error if a duplicate name is used" do 20 | assert_raises Ambry::AmbryError do 21 | 2.times {Ambry::Adapter.new(:name => :test_adapter)} 22 | end 23 | end 24 | 25 | it "should set an empty hash as the db" do 26 | assert_equal Hash.new, Ambry::Adapter.new.db 27 | end 28 | end 29 | 30 | describe "#db_for" do 31 | 32 | before { load_fixtures } 33 | 34 | it "should return a instance of Hash" do 35 | adapter = Ambry.adapters[:main] 36 | assert_kind_of Hash, adapter.db_for(Person) 37 | end 38 | end 39 | 40 | describe "stubbed io operations" do 41 | it "should return true" do 42 | adapter = Ambry::Adapter.new 43 | [:export_data, :import_data, :save_database].each do |method| 44 | assert adapter.send method 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/file_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.expand_path("../spec_helper", __FILE__) 3 | 4 | classes = [Ambry::Adapters::File, Ambry::Adapters::YAML] 5 | 6 | classes.each do |klass| 7 | 8 | describe klass.to_s do 9 | 10 | before do 11 | Ambry.adapters.clear 12 | @path = File.expand_path("../file_adapter_test", __FILE__) 13 | @adapter = klass.new(:file => @path) 14 | @adapter.instance_variable_set :@db, { 15 | "Class" => { 16 | :a => :b, 17 | :unicode => "ü" 18 | } 19 | } 20 | end 21 | 22 | after do 23 | Ambry.adapters.clear 24 | FileUtils.rm_f @path 25 | end 26 | 27 | describe "#export_data" do 28 | it "should be a string" do 29 | assert_kind_of String, @adapter.export_data 30 | end 31 | end 32 | 33 | describe "#save_database" do 34 | it "should write the data to disk" do 35 | assert @adapter.save_database 36 | assert File.exist? @path 37 | end 38 | 39 | it "should raise an AmbryError if it's read-only" do 40 | @adapter.read_only = true 41 | assert_raises Ambry::AmbryError do 42 | @adapter.save_database 43 | end 44 | end 45 | 46 | end 47 | 48 | describe "#load_database" do 49 | it "should load the data from the filesystem" do 50 | @adapter.save_database 51 | a2 = @adapter.class.new(:name => "a2", :file => @path) 52 | assert_equal @adapter.db["Class"][:unicode].bytes.entries, a2.db["Class"][:unicode].bytes.entries 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/fixtures.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Person: 3 | moe@3stooges.com: 4 | :name: Moe Howard 5 | shemp@3stooges.com: 6 | :name: Shemp Howard 7 | curly@3stooges.com: 8 | :name: Curly Howard 9 | larry@3stooges.com: 10 | :name: Larry Fine 11 | "MyModule::Animal": 12 | Canis Familaris: 13 | :species: Canis Familaris 14 | :common_name: Dog 15 | -------------------------------------------------------------------------------- /spec/key_set_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../spec_helper", __FILE__) 2 | 3 | describe Ambry::AbstractKeySet do 4 | 5 | before { load_fixtures } 6 | after { Ambry.adapters.clear } 7 | 8 | describe "#+" do 9 | it "should add two key sets" do 10 | key_set = Person.find {|p| p.name =~ /Curly/} + Person.find {|p| p.name =~ /Larry/} 11 | assert_equal 2, key_set.length 12 | end 13 | 14 | it "should not duplicate entries" do 15 | key_set = Person.find {|p| p.name =~ /Curly/} + Person.find {|p| p.name =~ /Curly/} 16 | assert_equal 1, key_set.length 17 | end 18 | end 19 | 20 | describe "#-" do 21 | it "should subtract a key set" do 22 | a = Person.find 23 | b = Person.find {|p| p.name =~ /Larry|Ted/} 24 | key_set = a - b 25 | assert_equal 3, key_set.length 26 | end 27 | end 28 | 29 | describe "#&" do 30 | it "should get set intersection" do 31 | key_set = Person.find & Person.find {|p| p.name =~ /Larry/} 32 | assert_equal 1, key_set.length 33 | end 34 | end 35 | 36 | describe "#first" do 37 | it "should return the first matching instance when called with a block" do 38 | assert_equal "Curly Howard", Person.alphabetical.first {|p| p.name =~ /Curly/}.name 39 | end 40 | 41 | it "should return the first instance when not called with a block" do 42 | assert_kind_of Person, Person.alphabetical.first 43 | end 44 | end 45 | 46 | describe "#last" do 47 | it "should return the last matching instance when called with a block" do 48 | assert_equal "Shemp Howard", Person.alphabetical.last {|p| p.name =~ /Howard/}.name 49 | end 50 | 51 | it "should return the last instance when not called with a block" do 52 | assert_kind_of Person, Person.alphabetical.last 53 | end 54 | end 55 | 56 | describe "#count" do 57 | it "should count matching instances when called with a block" do 58 | assert_equal 3, Person.count {|p| p.name =~ /Howard/} 59 | end 60 | 61 | it "should count all keys when called without a block" do 62 | assert_equal 4, Person.count 63 | end 64 | end 65 | 66 | describe "#find" do 67 | it "should return a KeySet of matching keys when called with a block" do 68 | key_set = Person.find {|p| p.name =~ /Larry/} 69 | assert_kind_of Ambry::AbstractKeySet, key_set 70 | assert_equal 1, key_set.size 71 | end 72 | 73 | it "should return a KeySet of all keys when called with no block" do 74 | assert_equal 4, Person.find.size 75 | end 76 | 77 | it "should yield an instance of HashProxy to the block" do 78 | Person.find {|x| assert_kind_of Ambry::HashProxy, x} 79 | end 80 | 81 | it "should be chainable" do 82 | assert_equal "Larry Fine", Person.stooges.non_howards.first.name 83 | end 84 | 85 | it "should raise error when trying to chain nonexistant method" do 86 | assert_raises NoMethodError do 87 | Person.stooges.foobar 88 | end 89 | end 90 | end 91 | 92 | describe "#find_by_key" do 93 | it "should yield a key to the block" do 94 | Person.find_by_key {|x| assert_kind_of String, x} 95 | end 96 | 97 | it "should return a KeySet of all keys when called with no block" do 98 | assert_equal 4, Person.find_by_key.size 99 | end 100 | end 101 | 102 | describe "#sort" do 103 | it "should sort" do 104 | assert_equal "Curly Howard", Person.find.sort {|a, b| a.name <=> b.name}.first.name 105 | assert_equal "Shemp Howard", Person.find.sort {|b, a| a.name <=> b.name}.first.name 106 | end 107 | end 108 | 109 | describe "#limit" do 110 | it "should limit" do 111 | assert_equal 2, Person.find.limit(2).count 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/mapper_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../spec_helper", __FILE__) 2 | 3 | MockAdapter = Struct.new(:db) 4 | 5 | class Value 6 | extend Ambry::Model 7 | field :a 8 | end 9 | 10 | describe Ambry::Mapper do 11 | 12 | describe "#initialize" do 13 | 14 | before do 15 | Ambry.adapters.clear 16 | @adapter = Ambry::Adapter.new 17 | end 18 | 19 | after do 20 | Ambry.adapters.clear 21 | end 22 | 23 | it "should set default adapter name if unspecified" do 24 | mapper = Ambry::Mapper.new "Class" 25 | assert_equal Ambry.default_adapter_name, mapper.adapter_name 26 | end 27 | 28 | it "should set adapter name if unspecified" do 29 | Ambry::Adapter.new :name => :hello 30 | mapper = Ambry::Mapper.new "Class", :hello 31 | assert_equal :hello, mapper.adapter_name 32 | end 33 | end 34 | 35 | describe "hash operations" do 36 | 37 | before { load_fixtures } 38 | after { Ambry.adapters.clear } 39 | 40 | describe "#hash" do 41 | it "should get a hash corresponding to the mapped class" do 42 | assert_equal Person.mapper.adapter.db["Person"], Person.mapper.hash 43 | end 44 | end 45 | 46 | describe "#[]" do 47 | it "should return an attributes hash" do 48 | assert_kind_of Hash, Person.mapper["moe@3stooges.com"] 49 | end 50 | 51 | it "should raise NotFoundError if key doesn't exist" do 52 | assert_raises Ambry::NotFoundError do 53 | Person.mapper["BADKEY"] 54 | end 55 | end 56 | end 57 | 58 | describe "#[]=" do 59 | it "should return the value" do 60 | value = Person.mapper[:a] = Value.new(:a => "b") 61 | assert_equal "b", value.a 62 | end 63 | 64 | it "should set value#to_hash as value for key" do 65 | Person.mapper[:bogus] = Value.new(:a => "b") 66 | assert_equal "b", Person.mapper[:bogus][:a] 67 | end 68 | 69 | it "should freeze the value" do 70 | Person.mapper[:bogus] = Value.new(:a => "b") 71 | assert Person.mapper[:bogus].frozen? 72 | end 73 | end 74 | 75 | describe "#get" do 76 | it "should return a model instance" do 77 | assert_kind_of Person, Person.mapper.get("moe@3stooges.com") 78 | end 79 | end 80 | 81 | describe "#key_set" do 82 | it "should return a Ambry::KeySet with all keys" do 83 | ks = Person.mapper.key_set 84 | assert_kind_of Ambry::AbstractKeySet, ks 85 | assert_equal Person.count, ks.count 86 | end 87 | end 88 | 89 | describe "#put" do 90 | it "should add to hash and return the value" do 91 | instance = Value.new(:a => "b") 92 | assert value = Value.mapper.put(instance) 93 | assert_equal instance, value 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/model_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../spec_helper", __FILE__) 2 | 3 | describe Ambry::Model do 4 | before { load_fixtures } 5 | after { Ambry.adapters.clear } 6 | 7 | describe "#initialize" do 8 | 9 | it "can be called with no arguments" do 10 | assert Person.new 11 | end 12 | 13 | it "can be called with a block" do 14 | p = Person.new do |person| 15 | person.name = "joe" 16 | end 17 | assert_equal "joe", p.name 18 | end 19 | 20 | it "can be called with attributes and a block" do 21 | assert Person.new(:name => "foo") {|p| p.name = "bar"} 22 | end 23 | 24 | it "calls the block after setting sttributes" do 25 | person = Person.new(:name => "foo") {|p| p.name = "bar"} 26 | assert_equal "bar", person.name 27 | end 28 | 29 | it "can set attributes from a hash" do 30 | p = Person.new :name => "joe" 31 | assert_equal "joe", p.name 32 | end 33 | 34 | # This means your custom accessors will be invoked. 35 | it "invokes attr writers" do 36 | class Person 37 | def name=(val); @name = "doe"; end 38 | end 39 | p = Person.new(:name => "joe") 40 | class Person 41 | def name=(val); @name = val; end 42 | end 43 | 44 | assert_equal "doe", p.name 45 | end 46 | 47 | # Don't loop through params that potentially came from the Internet, 48 | # because we need to cast keys to Symbol, and that could leak memory. 49 | it "iterates over attribute names, not params" do 50 | assert_send([Person.attribute_names, :each]) 51 | Person.new(:name => "joe") 52 | end 53 | end 54 | 55 | describe ".mapper" do 56 | it "lazy-loads mapper if it's not set" do 57 | Person.instance_variable_set :@mapper, nil 58 | assert Person.mapper 59 | end 60 | end 61 | 62 | describe ".use" do 63 | it "sets a new mapper for the specified adapter" do 64 | Person.instance_variable_set :@mapper, nil 65 | Person.use :main 66 | refute_nil Person.mapper 67 | end 68 | end 69 | 70 | describe ".create" do 71 | it "should add an instance to the database" do 72 | Person.create(:name => "Ted Healy", :email => "ted@3stooges.com") 73 | assert_equal "Ted Healy", Person.get("ted@3stooges.com").name 74 | end 75 | end 76 | 77 | 78 | describe "an attribute reader" do 79 | it "reads (first) from an instance var" do 80 | p = Person.first 81 | p.instance_variable_set :@name, "foo" 82 | p.instance_variable_set :@attributes, nil 83 | assert_equal "foo", p.name 84 | end 85 | 86 | # This also provides an easy way to check if a model instance has been 87 | # edited. However most of the time we don't need this because Ambry is 88 | # not intended for frequent writes. 89 | it "reads (second) from an attribute array" do 90 | p = Person.first 91 | assert_nil p.instance_variable_get :@name 92 | refute_nil p.instance_variable_get :@attributes 93 | refute_nil p.name 94 | end 95 | end 96 | 97 | describe "an attribute writer" do 98 | 99 | # The attributes hash is never written to, only replaced. 100 | it "only sets instance vars" do 101 | p = Person.first 102 | p.name = "foo" 103 | assert_equal "foo", p.instance_variable_get(:@name) 104 | refute_equal "foo", p.instance_variable_get(:@attributes)[:name] 105 | end 106 | end 107 | 108 | describe "#==" do 109 | it "returns true if the class and id are the same" do 110 | p = Person.first 111 | p2 = Person.new(:email => p.email) 112 | assert_equal p, p2 113 | end 114 | end 115 | 116 | describe "#to_hash" do 117 | it "returns a hash of the model's attributes" do 118 | p = Person.new(:name => "joe") 119 | assert_equal "joe", p.to_hash[:name] 120 | end 121 | end 122 | 123 | describe "#to_id" do 124 | it "returns the key attribute" do 125 | p = Person.first 126 | assert_equal p.email, p.to_id 127 | end 128 | end 129 | 130 | describe "#id_changed?" do 131 | it "should be true if the id changed" do 132 | p = Person.get("moe@3stooges.com") 133 | refute p.id_changed? 134 | end 135 | 136 | it "should be false if the id didn't change" do 137 | p = Person.get("moe@3stooges.com") 138 | p.email = "moe2@3stooges.com" 139 | assert p.id_changed? 140 | end 141 | end 142 | 143 | describe "#update" do 144 | it "updates the database" do 145 | p = Person.get("moe@3stooges.com") 146 | original = p.name 147 | p.update(:name => "Joe Schmoe") 148 | p = Person.get("moe@3stooges.com") 149 | refute_equal original, p.name 150 | end 151 | 152 | it "should allow updating the key" do 153 | count = Person.count 154 | p = Person.get("moe@3stooges.com") 155 | p.update(:email => "moe2@3stooges.com") 156 | assert_equal count, Person.count 157 | end 158 | 159 | it "should allow false as an update value (regression)" do 160 | p = Person.get("moe@3stooges.com") 161 | p.update(:name => false) 162 | assert_equal false, p.name 163 | end 164 | 165 | end 166 | 167 | describe "#save" do 168 | it "passes itself to Mapper#put" do 169 | p = Person.new(:name => "hello") 170 | assert_send([Person.mapper, :put, p]) 171 | p.save 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | if ENV["coverage"] 2 | require "simplecov" 3 | SimpleCov.start 4 | end 5 | require "rubygems" 6 | require "bundler/setup" 7 | require "ambry" 8 | require "ambry/adapters/yaml" 9 | require 'minitest/spec' 10 | require "fileutils" 11 | require "ffaker" 12 | 13 | class Person 14 | extend Ambry::Model 15 | field :email, :name 16 | 17 | def self.stooges 18 | with_index do 19 | find_by_key {|k| k =~ /3stooges.com/} 20 | end 21 | end 22 | 23 | filters do 24 | def non_howards 25 | find {|p| p.name !~ /Howard/} 26 | end 27 | 28 | def alphabetical 29 | sort_by {|p| p.name} 30 | end 31 | end 32 | end 33 | 34 | def load_fixtures 35 | Ambry.adapters.clear 36 | file = File.expand_path("../fixtures.yml", __FILE__) 37 | Ambry::Adapters::YAML.new :file => file 38 | Person.use :main 39 | end 40 | 41 | MiniTest.autorun 42 | --------------------------------------------------------------------------------