diff --git a/lib/couchrest/core/model.rb b/lib/couchrest/core/model.rb index 45119e7..2b390af 100644 --- a/lib/couchrest/core/model.rb +++ b/lib/couchrest/core/model.rb @@ -8,8 +8,7 @@ module CouchRest # # This is an example class using CouchRest::Model. It is taken from the spec/couchrest/core/model_spec.rb file, which may be even more up to date than this example. # - # class Article - # include CouchRest::Model + # class Article < CouchRest::Model # use_database CouchRest.database!('http://localhost:5984/couchrest-model-test') # unique_id :slug # @@ -38,315 +37,291 @@ module CouchRest # # before(:create, :generate_slug_from_title) # def generate_slug_from_title - # doc['slug'] = title.downcase.gsub(/[^a-z0-9]/,'-').squeeze('-').gsub(/^\-|\-$/,'') + # self['slug'] = title.downcase.gsub(/[^a-z0-9]/,'-').squeeze('-').gsub(/^\-|\-$/,'') # end # end - module Model + class Model < Hash + + # instantiates the hash by converting all the keys to strings. + def initialize keys = {} + super() + keys.each do |k,v| + self[k.to_s] = v + end + unless self['_id'] && self['_rev'] + init_doc + end + end + class << self # this is the CouchRest::Database that model classes will use unless they override it with use_database attr_accessor :default_database - end - - # instance methods on the model classes - module InstanceMethods - attr_accessor :doc - def initialize keys = {} - self.doc = {} - keys.each do |k,v| - doc[k.to_s] = v - end - unless doc['_id'] && doc['_rev'] - init_doc - end - end - - # returns the database used by this model's class - def database - self.class.database - end - - # alias for doc['_id'] - def id - doc['_id'] - end - - # alias for doc['_rev'] - def rev - doc['_rev'] - end - - # returns true if the doc has never been saved - def new_record? - !doc['_rev'] - end - - # save the doc to the db using create or update - def save - if new_record? - create - else - update - end - end - - protected - - def create - set_unique_id if respond_to?(:set_unique_id) # hack - save_doc - end - - def update - save_doc - end - - private - - def save_doc - result = database.save doc - if result['ok'] - doc['_id'] = result['id'] - doc['_rev'] = result['rev'] - end - result['ok'] - end - - def init_doc - doc['type'] = self.class.to_s - end - end # module InstanceMethods - - # Class methods for models that include CouchRest::Model - module ClassMethods # override the CouchRest::Model-wide default_database - def use_database db - @database = db - end - - # returns the CouchRest::Database instance that this class uses - def database - @database || CouchRest::Model.default_database - end - - # load a document from the database - def get id - doc = database.get id - new(doc) - end - - # Defines methods for reading and writing from fields in the document. Uses key_writer and key_reader internally. - def key_accessor *keys - key_writer *keys - key_reader *keys - end - - # For each argument key, define a method key= that sets the corresponding field on the CouchDB document. - def key_writer *keys - keys.each do |method| - key = method.to_s - define_method "#{method}=" do |value| - doc[key] = value - end - end - end + def use_database db + @database = db + end - # For each argument key, define a method key that reads the corresponding field on the CouchDB document. - def key_reader *keys - keys.each do |method| - key = method.to_s - define_method method do - doc[key] - end - end - end - - # Automatically set updated_at and created_at fields on the document whenever saving occurs. CouchRest uses a pretty decent time format by default. See Time#to_json - def timestamps! - before(:create) do - doc['updated_at'] = doc['created_at'] = Time.now - end - before(:update) do - doc['updated_at'] = Time.now - end - end - - # Name a method that will be called before the document is first saved, which returns a string to be used for the document's _id. Because CouchDB enforces a constraint that each id must be unique, this can be used to enforce eg: uniq usernames. Note that this id must be globally unique across all document types which share a database, so if you'd like to scope uniqueness to this class, you should use the class name as part of the unique id. - def unique_id method - define_method :set_unique_id do - doc['_id'] ||= self.send(method) - end - end - - end # module ClassMethods + # returns the CouchRest::Database instance that this class uses + def database + @database || CouchRest::Model.default_database + end - module MagicViews - - # Define a CouchDB view. The name of the view will be the concatenation of by and the keys joined by _and_ - # - # ==== Example views: - # - # class Post - # # view with default options - # # query with Post.by_date - # view_by :date, :descending => true - # - # # view with compound sort-keys - # # query with Post.by_user_id_and_date - # view_by :user_id, :date - # - # # view with custom map/reduce functions - # # query with Post.by_tags :reduce => true - # view_by :tags, - # :map => - # "function(doc) { - # if (doc.type == 'Post' && doc.tags) { - # doc.tags.forEach(function(tag){ - # emit(doc.tag, 1); - # }); - # } - # }", - # :reduce => - # "function(keys, values, rereduce) { - # return sum(values); - # }" - # end - # - # view_by :date will create a view defined by this Javascript function: - # - # function(doc) { - # if (doc.type == 'Post' && doc.date) { - # emit(doc.date, null); - # } - # } - # - # It can be queried by calling Post.by_date which accepts all valid options for CouchRest::Database#view. In addition, calling with the :raw => true option will return the view rows themselves. By default Post.by_date will return the documents included in the generated view. - # - # CouchRest::Database#view options can be applied at view definition time as defaults, and they will be curried and used at view query time. Or they can be overridden at query time. - # - # Custom views can be queried with :reduce => true to return reduce results. The default for custom views is to query with :reduce => false. - # - # Views are generated (on a per-model basis) lazily on first-access. This means that if you are deploying changes to a view, the views for that model won't be available until generation is complete. This can take some time with large databases. Strategies are in the works. - # - # To understand the capabilities of this view system more compeletly, it is recommended that you read the RSpec file at spec/core/model.rb. - def view_by *keys - opts = keys.pop if keys.last.is_a?(Hash) - opts ||= {} - type = self.to_s + # load a document from the database + def get id + doc = database.get id + new(doc) + end - method_name = "by_#{keys.join('_and_')}" - @@design_doc ||= default_design_doc - - if opts[:map] - view = {} - view['map'] = opts.delete(:map) - if opts[:reduce] - view['reduce'] = opts.delete(:reduce) - opts[:reduce] = false - end - @@design_doc['views'][method_name] = view - else - doc_keys = keys.collect{|k|"doc['#{k}']"} - key_protection = doc_keys.join(' && ') - key_emit = doc_keys.length == 1 ? "#{doc_keys.first}" : "[#{doc_keys.join(', ')}]" - map_function = <<-JAVASCRIPT - function(doc) { - if (doc.type == '#{type}' && #{key_protection}) { - emit(#{key_emit}, null); - } - } - JAVASCRIPT - @@design_doc['views'][method_name] = { - 'map' => map_function - } - end - - @@design_doc_fresh = false - - self.meta_class.instance_eval do - define_method method_name do |*args| - query = opts.merge(args[0] || {}) - query[:raw] = true if query[:reduce] - unless @@design_doc_fresh - refresh_design_doc - end - raw = query.delete(:raw) - view_name = "#{type}/#{method_name}" + # Defines methods for reading and writing from fields in the document. Uses key_writer and key_reader internally. + def key_accessor *keys + key_writer *keys + key_reader *keys + end + + # For each argument key, define a method key= that sets the corresponding field on the CouchDB document. + def key_writer *keys + keys.each do |method| + key = method.to_s + define_method "#{method}=" do |value| + self[key] = value + end + end + end + + # For each argument key, define a method key that reads the corresponding field on the CouchDB document. + def key_reader *keys + keys.each do |method| + key = method.to_s + define_method method do + self[key] + end + end + end + + # Automatically set updated_at and created_at fields on the document whenever saving occurs. CouchRest uses a pretty decent time format by default. See Time#to_json + def timestamps! + before(:create) do + self['updated_at'] = self['created_at'] = Time.now + end + before(:update) do + self['updated_at'] = Time.now + end + end + + # Name a method that will be called before the document is first saved, which returns a string to be used for the document's _id. Because CouchDB enforces a constraint that each id must be unique, this can be used to enforce eg: uniq usernames. Note that this id must be globally unique across all document types which share a database, so if you'd like to scope uniqueness to this class, you should use the class name as part of the unique id. + def unique_id method + define_method :set_unique_id do + self['_id'] ||= self.send(method) + end + end + + # Define a CouchDB view. The name of the view will be the concatenation of by and the keys joined by _and_ + # + # ==== Example views: + # + # class Post + # # view with default options + # # query with Post.by_date + # view_by :date, :descending => true + # + # # view with compound sort-keys + # # query with Post.by_user_id_and_date + # view_by :user_id, :date + # + # # view with custom map/reduce functions + # # query with Post.by_tags :reduce => true + # view_by :tags, + # :map => + # "function(doc) { + # if (doc.type == 'Post' && doc.tags) { + # doc.tags.forEach(function(tag){ + # emit(doc.tag, 1); + # }); + # } + # }", + # :reduce => + # "function(keys, values, rereduce) { + # return sum(values); + # }" + # end + # + # view_by :date will create a view defined by this Javascript function: + # + # function(doc) { + # if (doc.type == 'Post' && doc.date) { + # emit(doc.date, null); + # } + # } + # + # It can be queried by calling Post.by_date which accepts all valid options for CouchRest::Database#view. In addition, calling with the :raw => true option will return the view rows themselves. By default Post.by_date will return the documents included in the generated view. + # + # CouchRest::Database#view options can be applied at view definition time as defaults, and they will be curried and used at view query time. Or they can be overridden at query time. + # + # Custom views can be queried with :reduce => true to return reduce results. The default for custom views is to query with :reduce => false. + # + # Views are generated (on a per-model basis) lazily on first-access. This means that if you are deploying changes to a view, the views for that model won't be available until generation is complete. This can take some time with large databases. Strategies are in the works. + # + # To understand the capabilities of this view system more compeletly, it is recommended that you read the RSpec file at spec/core/model.rb. + def view_by *keys + opts = keys.pop if keys.last.is_a?(Hash) + opts ||= {} + type = self.to_s + + method_name = "by_#{keys.join('_and_')}" + @@design_doc ||= default_design_doc + + if opts[:map] + view = {} + view['map'] = opts.delete(:map) + if opts[:reduce] + view['reduce'] = opts.delete(:reduce) + opts[:reduce] = false + end + @@design_doc['views'][method_name] = view + else + doc_keys = keys.collect{|k|"doc['#{k}']"} + key_protection = doc_keys.join(' && ') + key_emit = doc_keys.length == 1 ? "#{doc_keys.first}" : "[#{doc_keys.join(', ')}]" + map_function = <<-JAVASCRIPT + function(doc) { + if (doc.type == '#{type}' && #{key_protection}) { + emit(#{key_emit}, null); + } + } + JAVASCRIPT + @@design_doc['views'][method_name] = { + 'map' => map_function + } + end + + @@design_doc_fresh = false + + self.meta_class.instance_eval do + define_method method_name do |*args| + query = opts.merge(args[0] || {}) + query[:raw] = true if query[:reduce] + unless @@design_doc_fresh + refresh_design_doc + end + raw = query.delete(:raw) + view_name = "#{type}/#{method_name}" + + view = fetch_view(view_name, query) + if raw + view + else + # TODO this can be optimized once the include-docs patch is applied + view['rows'].collect{|r|new(database.get(r['id']))} + end + end + end + end + + private + + def fetch_view view_name, opts + retryable = true + begin + database.view(view_name, opts) + # the design doc could have been deleted by a rouge process + rescue RestClient::ResourceNotFound => e + if retryable + refresh_design_doc + retryable = false + retry + else + raise e + end + end + end + + def design_doc_id + "_design/#{self.to_s}" + end + + def default_design_doc + { + "_id" => design_doc_id, + "language" => "javascript", + "views" => {} + } + end + + def refresh_design_doc + saved = database.get(design_doc_id) rescue nil + if saved + @@design_doc['views'].each do |name, view| + saved['views'][name] = view + end + database.save(saved) + else + database.save(@@design_doc) + end + @@design_doc_fresh = true + end + + end # class << self + - view = fetch_view(view_name, query) - if raw - view - else - # TODO this can be optimized once the include-docs patch is applied - view['rows'].collect{|r|new(database.get(r['id']))} - end - end - end - end - - private - - def fetch_view view_name, opts - retryable = true - begin - database.view(view_name, opts) - # the design doc could have been deleted by a rouge process - rescue RestClient::ResourceNotFound => e - if retryable - refresh_design_doc - retryable = false - retry - else - raise e - end - end - end - - def design_doc_id - "_design/#{self.to_s}" - end - - def default_design_doc - { - "_id" => design_doc_id, - "language" => "javascript", - "views" => {} - } - end - - def refresh_design_doc - saved = database.get(design_doc_id) rescue nil - if saved - @@design_doc['views'].each do |name, view| - saved['views'][name] = view - end - database.save(saved) - else - database.save(@@design_doc) - end - @@design_doc_fresh = true - end - - end # module MagicViews - module Callbacks - def self.included(model) - model.class_eval <<-EOS, __FILE__, __LINE__ - include Extlib::Hook - register_instance_hooks :save, :create, :update #, :destroy - EOS - end - end # module Callbacks - - # bookkeeping section - - # load the code into the model class - def self.included(model) - model.send(:include, InstanceMethods) - model.extend ClassMethods - model.extend MagicViews - model.send(:include, Callbacks) + # returns the database used by this model's class + def database + self.class.database end - end # module Model + # alias for self['_id'] + def id + self['_id'] + end + + # alias for self['_rev'] + def rev + self['_rev'] + end + + # returns true if the self has never been saved + def new_record? + !rev + end + + # save the doc to the db using create or update + def save + if new_record? + create + else + update + end + end + + protected + + def create + set_unique_id if respond_to?(:set_unique_id) # hack + save_doc + end + + def update + save_doc + end + + private + + def save_doc + result = database.save self + if result['ok'] + self['_id'] = result['id'] + self['_rev'] = result['rev'] + end + result['ok'] + end + + def init_doc + self['type'] = self.class.to_s + end + + include ::Extlib::Hook + register_instance_hooks :save, :create, :update #, :destroy + + end # class Model end # module CouchRest \ No newline at end of file diff --git a/spec/couchrest/core/model_spec.rb b/spec/couchrest/core/model_spec.rb index fc490b2..153f933 100644 --- a/spec/couchrest/core/model_spec.rb +++ b/spec/couchrest/core/model_spec.rb @@ -1,11 +1,10 @@ require File.dirname(__FILE__) + '/../../spec_helper' -class Basic - include CouchRest::Model +class Basic < CouchRest::Model + end -class Article - include CouchRest::Model +class Article < CouchRest::Model use_database CouchRest.database!('http://localhost:5984/couchrest-model-test') unique_id :slug @@ -34,7 +33,7 @@ class Article before(:create, :generate_slug_from_title) def generate_slug_from_title - doc['slug'] = title.downcase.gsub(/[^a-z0-9]/,'-').squeeze('-').gsub(/^\-|\-$/,'') + self['slug'] = title.downcase.gsub(/[^a-z0-9]/,'-').squeeze('-').gsub(/^\-|\-$/,'') end end @@ -61,6 +60,7 @@ describe CouchRest::Model do describe "a new model" do it "should be a new_record" do @obj = Basic.new + @obj.rev.should be_nil @obj.should be_a_new_record end end @@ -68,13 +68,13 @@ describe CouchRest::Model do describe "a model with key_accessors" do it "should allow reading keys" do @art = Article.new - @art.doc['title'] = 'My Article Title' + @art['title'] = 'My Article Title' @art.title.should == 'My Article Title' end it "should allow setting keys" do @art = Article.new @art.title = 'My Article Title' - @art.doc['title'].should == 'My Article Title' + @art['title'].should == 'My Article Title' end end @@ -83,7 +83,7 @@ describe CouchRest::Model do @art = Article.new t = Time.now @art.date = t - @art.doc['date'].should == t + @art['date'].should == t end it "should not allow reading keys" do @art = Article.new @@ -96,7 +96,7 @@ describe CouchRest::Model do describe "a model with key_readers" do it "should allow reading keys" do @art = Article.new - @art.doc['slug'] = 'my-slug' + @art['slug'] = 'my-slug' @art.slug.should == 'my-slug' end it "should not allow setting keys" do @@ -129,7 +129,7 @@ describe CouchRest::Model do it "should be set for resaving" do rev = @obj.rev - @obj.doc['another-key'] = "some value" + @obj['another-key'] = "some value" @obj.save @obj.rev.should_not == rev end @@ -139,7 +139,7 @@ describe CouchRest::Model do end it "should set the type" do - @obj.doc['type'].should == 'Basic' + @obj['type'].should == 'Basic' end end @@ -254,7 +254,7 @@ describe CouchRest::Model do end it "should sort correctly" do articles = Article.by_user_id_and_date - articles.collect{|a|a.doc['user_id']}.should == ['aaron', 'aaron', 'quentin', 'quentin'] + articles.collect{|a|a['user_id']}.should == ['aaron', 'aaron', 'quentin', 'quentin'] articles[1].title.should == 'not junk' end it "should be queryable with couchrest options" do