From 750c7055962923ee3b1d66403ca00d7da7f51469 Mon Sep 17 00:00:00 2001 From: Matt Aimonetti Date: Thu, 29 Jan 2009 18:25:45 -0800 Subject: [PATCH] Started on the ExtendedDocument class with features moved to mixins. Properties got added, they define getters, setters and aliases. They will also be the base of the new validation system. --- lib/couchrest.rb | 2 + lib/couchrest/core/document.rb | 2 +- lib/couchrest/mixins/design_doc.rb | 63 +++++++ lib/couchrest/mixins/document_queries.rb | 42 +++++ .../mixins/extended_document_mixins.rb | 4 + lib/couchrest/mixins/extended_views.rb | 169 ++++++++++++++++++ lib/couchrest/mixins/properties.rb | 63 +++++++ lib/couchrest/more/extended_document.rb | 114 ++++++++++++ lib/couchrest/more/property.rb | 26 +++ spec/couchrest/core/document_spec.rb | 5 + spec/couchrest/more/property_spec.rb | 36 ++++ spec/spec_helper.rb | 15 +- 12 files changed, 533 insertions(+), 8 deletions(-) create mode 100644 lib/couchrest/mixins/design_doc.rb create mode 100644 lib/couchrest/mixins/document_queries.rb create mode 100644 lib/couchrest/mixins/extended_document_mixins.rb create mode 100644 lib/couchrest/mixins/extended_views.rb create mode 100644 lib/couchrest/mixins/properties.rb create mode 100644 lib/couchrest/more/extended_document.rb create mode 100644 lib/couchrest/more/property.rb create mode 100644 spec/couchrest/more/property_spec.rb diff --git a/lib/couchrest.rb b/lib/couchrest.rb index ad99639..74a72a3 100644 --- a/lib/couchrest.rb +++ b/lib/couchrest.rb @@ -37,6 +37,8 @@ module CouchRest autoload :FileManager, 'couchrest/helper/file_manager' autoload :Streamer, 'couchrest/helper/streamer' + autoload :ExtendedDocument, 'couchrest/more/extended_document' + require File.join(File.dirname(__FILE__), 'couchrest', 'mixins') # The CouchRest module methods handle the basic JSON serialization diff --git a/lib/couchrest/core/document.rb b/lib/couchrest/core/document.rb index 51fea23..38d965c 100644 --- a/lib/couchrest/core/document.rb +++ b/lib/couchrest/core/document.rb @@ -1,6 +1,6 @@ module CouchRest class Response < Hash - def initialize keys = {} + def initialize(keys = {}) keys.each do |k,v| self[k.to_s] = v end diff --git a/lib/couchrest/mixins/design_doc.rb b/lib/couchrest/mixins/design_doc.rb new file mode 100644 index 0000000..96e44e1 --- /dev/null +++ b/lib/couchrest/mixins/design_doc.rb @@ -0,0 +1,63 @@ +require 'digest/md5' + +module CouchRest + module Mixins + module DesignDoc + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def design_doc_id + "_design/#{design_doc_slug}" + end + + def design_doc_slug + return design_doc_slug_cache if design_doc_slug_cache && design_doc_fresh + funcs = [] + design_doc['views'].each do |name, view| + funcs << "#{name}/#{view['map']}#{view['reduce']}" + end + md5 = Digest::MD5.hexdigest(funcs.sort.join('')) + self.design_doc_slug_cache = "#{self.to_s}-#{md5}" + end + + def default_design_doc + { + "language" => "javascript", + "views" => { + 'all' => { + 'map' => "function(doc) { + if (doc['couchrest-type'] == '#{self.to_s}') { + emit(null,null); + } + }" + } + } + } + end + + def refresh_design_doc + did = design_doc_id + saved = database.get(did) rescue nil + if saved + design_doc['views'].each do |name, view| + saved['views'][name] = view + end + database.save_doc(saved) + self.design_doc = saved + else + design_doc['_id'] = did + design_doc.delete('_rev') + design_doc.database = database + design_doc.save + end + self.design_doc_fresh = true + end + + end # module ClassMethods + + end + end +end \ No newline at end of file diff --git a/lib/couchrest/mixins/document_queries.rb b/lib/couchrest/mixins/document_queries.rb new file mode 100644 index 0000000..c999ed1 --- /dev/null +++ b/lib/couchrest/mixins/document_queries.rb @@ -0,0 +1,42 @@ +module CouchRest + module Mixins + module DocumentQueries + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + + # Load all documents that have the "couchrest-type" field equal to the + # name of the current class. Take the standard set of + # CouchRest::Database#view options. + def all(opts = {}, &block) + self.design_doc ||= Design.new(default_design_doc) + unless design_doc_fresh + refresh_design_doc + end + view :all, opts, &block + end + + # Load the first document that have the "couchrest-type" field equal to + # the name of the current class. + # + # ==== Returns + # Object:: The first object instance available + # or + # Nil:: if no instances available + # + # ==== Parameters + # opts:: + # View options, see CouchRest::Database#view options for more info. + def first(opts = {}) + first_instance = self.all(opts.merge!(:limit => 1)) + first_instance.empty? ? nil : first_instance.first + end + + end + + end + end +end \ No newline at end of file diff --git a/lib/couchrest/mixins/extended_document_mixins.rb b/lib/couchrest/mixins/extended_document_mixins.rb new file mode 100644 index 0000000..8b9767e --- /dev/null +++ b/lib/couchrest/mixins/extended_document_mixins.rb @@ -0,0 +1,4 @@ +require File.join(File.dirname(__FILE__), 'properties') +require File.join(File.dirname(__FILE__), 'document_queries') +require File.join(File.dirname(__FILE__), 'extended_views') +require File.join(File.dirname(__FILE__), 'design_doc') \ No newline at end of file diff --git a/lib/couchrest/mixins/extended_views.rb b/lib/couchrest/mixins/extended_views.rb new file mode 100644 index 0000000..7f32f20 --- /dev/null +++ b/lib/couchrest/mixins/extended_views.rb @@ -0,0 +1,169 @@ +module CouchRest + module Mixins + module ExtendedViews + + def self.included(base) + base.extend(ClassMethods) + # extlib is required for the following code + base.send(:class_inheritable_accessor, :design_doc) + base.send(:class_inheritable_accessor, :design_doc_slug_cache) + base.send(:class_inheritable_accessor, :design_doc_fresh) + end + + module ClassMethods + + # 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['couchrest-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['couchrest-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_spec.rb. + + def view_by(*keys) + self.design_doc ||= Design.new(default_design_doc) + opts = keys.pop if keys.last.is_a?(Hash) + opts ||= {} + ducktype = opts.delete(:ducktype) + unless ducktype || opts[:map] + opts[:guards] ||= [] + opts[:guards].push "(doc['couchrest-type'] == '#{self.to_s}')" + end + keys.push opts + self.design_doc.view_by(*keys) + self.design_doc_fresh = false + end + + # returns stored defaults if the there is a view named this in the design doc + def has_view?(view) + view = view.to_s + design_doc && design_doc['views'] && design_doc['views'][view] + end + + # Dispatches to any named view. + def view name, query={}, &block + unless design_doc_fresh + refresh_design_doc + end + query[:raw] = true if query[:reduce] + raw = query.delete(:raw) + fetch_view_with_docs(name, query, raw, &block) + end + + def all_design_doc_versions + database.documents :startkey => "_design/#{self.to_s}-", + :endkey => "_design/#{self.to_s}-\u9999" + end + + # Deletes any non-current design docs that were created by this class. + # Running this when you're deployed version of your application is steadily + # and consistently using the latest code, is the way to clear out old design + # docs. Running it to early could mean that live code has to regenerate + # potentially large indexes. + def cleanup_design_docs! + ddocs = all_design_doc_versions + ddocs["rows"].each do |row| + if (row['id'] != design_doc_id) + database.delete_doc({ + "_id" => row['id'], + "_rev" => row['value']['rev'] + }) + end + end + end + + private + + def fetch_view_with_docs name, opts, raw=false, &block + if raw + fetch_view name, opts, &block + else + begin + view = fetch_view name, opts.merge({:include_docs => true}), &block + view['rows'].collect{|r|new(r['doc'])} if view['rows'] + rescue + # fallback for old versions of couchdb that don't + # have include_docs support + view = fetch_view name, opts, &block + view['rows'].collect{|r|new(database.get(r['id']))} if view['rows'] + end + end + end + + def fetch_view view_name, opts, &block + retryable = true + begin + design_doc.view(view_name, opts, &block) + # 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 + + end # module ClassMethods + + + end + end +end \ No newline at end of file diff --git a/lib/couchrest/mixins/properties.rb b/lib/couchrest/mixins/properties.rb new file mode 100644 index 0000000..8b92ada --- /dev/null +++ b/lib/couchrest/mixins/properties.rb @@ -0,0 +1,63 @@ +module CouchRest + module Mixins + module DocumentProperties + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + # Stores the class properties + def properties + @@properties ||= [] + end + + # This is not a thread safe operation, if you have to set new properties at runtime + # make sure to use a mutex. + def property(name, options={}) + unless properties.map{|p| p.name}.include?(name.to_s) + property = CouchRest::Property.new(name, options.delete(:type), options) + create_property_getter(property) + create_property_setter(property) unless property.read_only == true + properties << property + end + end + + protected + # defines the getter for the property + def create_property_getter(property) + meth = property.name + class_eval <<-EOS + def #{meth} + self['#{meth}'] + end + EOS + + if property.alias + class_eval <<-EOS + alias #{property.alias.to_sym} #{meth.to_sym} + EOS + end + end + + # defines the setter for the property + def create_property_setter(property) + meth = property.name + class_eval <<-EOS + def #{meth}=(value) + self['#{meth}'] = value + end + EOS + + if property.alias + class_eval <<-EOS + alias #{property.alias.to_sym}= #{meth.to_sym}= + EOS + end + end + + end # module ClassMethods + + end + end +end \ No newline at end of file diff --git a/lib/couchrest/more/extended_document.rb b/lib/couchrest/more/extended_document.rb new file mode 100644 index 0000000..bf429b0 --- /dev/null +++ b/lib/couchrest/more/extended_document.rb @@ -0,0 +1,114 @@ +require 'rubygems' +begin + gem 'extlib' + require 'extlib' +rescue + puts "CouchRest::Model requires extlib. This is left out of the gemspec on purpose." + raise +end +require 'mime/types' +require File.join(File.dirname(__FILE__), "property") +require File.join(File.dirname(__FILE__), '..', 'mixins', 'extended_document_mixins') + +module CouchRest + + # Same as CouchRest::Document but with properties and validations + class ExtendedDocument < Document + include CouchRest::Mixins::DocumentQueries + include CouchRest::Mixins::DocumentProperties + include CouchRest::Mixins::ExtendedViews + include CouchRest::Mixins::DesignDoc + + + # 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 self.timestamps! + before(:save) do + self['updated_at'] = Time.now + self['created_at'] = self['updated_at'] if new_document? + 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 self.unique_id method = nil, &block + if method + define_method :set_unique_id do + self['_id'] ||= self.send(method) + end + elsif block + define_method :set_unique_id do + uniqid = block.call(self) + raise ArgumentError, "unique_id block must not return nil" if uniqid.nil? + self['_id'] ||= uniqid + end + end + end + + ### instance methods + + # Returns the Class properties + # + # ==== Returns + # Array:: the list of properties for the instance + def properties + self.class.properties + end + + # Takes a hash as argument, and applies the values by using writer methods + # for each key. It doesn't save the document at the end. Raises a NoMethodError if the corresponding methods are + # missing. In case of error, no attributes are changed. + def update_attributes_without_saving hash + hash.each do |k, v| + raise NoMethodError, "#{k}= method not available, use key_accessor or key_writer :#{k}" unless self.respond_to?("#{k}=") + end + hash.each do |k, v| + self.send("#{k}=",v) + end + end + + # Takes a hash as argument, and applies the values by using writer methods + # for each key. Raises a NoMethodError if the corresponding methods are + # missing. In case of error, no attributes are changed. + def update_attributes hash + update_attributes_without_saving hash + save + end + + # for compatibility with old-school frameworks + alias :new_record? :new_document? + + # Overridden to set the unique ID. + # Returns a boolean value + def save bulk = false + set_unique_id if new_document? && self.respond_to?(:set_unique_id) + result = database.save_doc(self, bulk) + result["ok"] == true + end + + # Saves the document to the db using create or update. Raises an exception + # if the document is not saved properly. + def save! + raise "#{self.inspect} failed to save" unless self.save + end + + # Deletes the document from the database. Runs the :destroy callbacks. + # Removes the _id and _rev fields, preparing the + # document to be saved to a new _id. + def destroy + result = database.delete_doc self + if result['ok'] + self['_rev'] = nil + self['_id'] = nil + end + result['ok'] + end + + end +end \ No newline at end of file diff --git a/lib/couchrest/more/property.rb b/lib/couchrest/more/property.rb new file mode 100644 index 0000000..7b993ff --- /dev/null +++ b/lib/couchrest/more/property.rb @@ -0,0 +1,26 @@ +module CouchRest + + # Basic attribute support adding getter/setter + validation + class Property + attr_reader :name, :type, :validation_format, :required, :read_only, :alias + + # attribute to define + def initialize(name, type = String, options = {}) + @name = name.to_s + @type = type + parse_options(options) + self + end + + + private + def parse_options(options) + return if options.empty? + @required = true if (options[:required] && (options[:required] == true)) + @validation_format = options[:format] if options[:format] + @read_only = options[:read_only] if options[:read_only] + @alias = options[:alias] if options + end + + end +end \ No newline at end of file diff --git a/spec/couchrest/core/document_spec.rb b/spec/couchrest/core/document_spec.rb index 8caf0cd..1f3b115 100644 --- a/spec/couchrest/core/document_spec.rb +++ b/spec/couchrest/core/document_spec.rb @@ -30,6 +30,9 @@ describe CouchRest::Document do end describe "default database" do + before(:each) do + Video.use_database nil + end it "should be set using use_database on the model" do Video.new.database.should be_nil Video.use_database @db @@ -59,9 +62,11 @@ describe CouchRest::Document do @doc.rev.should be_nil @doc.id.should be_nil end + it "should freak out when saving without a database" do lambda{@doc.save}.should raise_error(ArgumentError) end + end # move to database spec diff --git a/spec/couchrest/more/property_spec.rb b/spec/couchrest/more/property_spec.rb new file mode 100644 index 0000000..780e587 --- /dev/null +++ b/spec/couchrest/more/property_spec.rb @@ -0,0 +1,36 @@ +require File.join(File.dirname(__FILE__), '..', '..', 'spec_helper') + +# check the following file to see how to use the spec'd features. +require File.join(FIXTURE_PATH, 'more', 'card') + +describe "ExtendedDocument properties" do + + before(:each) do + @card = Card.new(:first_name => "matt") + end + + it "should be accessible from the object" do + @card.properties.should be_an_instance_of(Array) + @card.properties.map{|p| p.name}.should include("first_name") + end + + it "should let you access a property value (getter)" do + @card.first_name.should == "matt" + end + + it "should let you set a property value (setter)" do + @card.last_name = "Aimonetti" + @card.last_name.should == "Aimonetti" + end + + it "should not let you set a property value if it's read only" do + lambda{@card.read_only_value = "test"}.should raise_error + end + + it "should let you use an alias for an attribute" do + @card.last_name = "Aimonetti" + @card.family_name.should == "Aimonetti" + @card.family_name.should == @card.last_name + end + +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 96ad5f0..06c7979 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,20 +1,21 @@ require "rubygems" require "spec" # Satisfies Autotest and anyone else not using the Rake tasks -require File.dirname(__FILE__) + '/../lib/couchrest' +require File.join(File.dirname(__FILE__), '/../lib/couchrest') unless defined?(FIXTURE_PATH) - FIXTURE_PATH = File.dirname(__FILE__) + '/fixtures' - SCRATCH_PATH = File.dirname(__FILE__) + '/tmp' + FIXTURE_PATH = File.join(File.dirname(__FILE__), '/fixtures') + SCRATCH_PATH = File.join(File.dirname(__FILE__), '/tmp') COUCHHOST = "http://127.0.0.1:5984" - TESTDB = 'couchrest-test' + TESTDB = 'couchrest-test' + TEST_SERVER = CouchRest.new + TEST_SERVER.default_database = TESTDB end def reset_test_db! - cr = CouchRest.new(COUCHHOST) + cr = TEST_SERVER db = cr.database(TESTDB) - db.delete! rescue nil - db = cr.create_db(TESTDB) rescue nin + db.recreate! rescue nil db end \ No newline at end of file