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.
This commit is contained in:
Matt Aimonetti 2009-01-29 18:25:45 -08:00 committed by Chris Anderson
parent 0c27fa6498
commit 83d7341553
12 changed files with 533 additions and 8 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<Hash>::
# View options, see <tt>CouchRest::Database#view</tt> 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

View file

@ -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')

View file

@ -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 <tt>by</tt> and the keys joined by <tt>_and_</tt>
#
# ==== 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
#
# <tt>view_by :date</tt> 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 <tt>Post.by_date</tt> which accepts all
# valid options for CouchRest::Database#view. In addition, calling with
# the <tt>:raw => true</tt> option will return the view rows
# themselves. By default <tt>Post.by_date</tt> 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 <tt>:reduce => true</tt> to return
# reduce results. The default for custom views is to query with
# <tt>:reduce => false</tt>.
#
# 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
# <tt>spec/core/model_spec.rb</tt>.
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

View file

@ -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

View file

@ -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 <tt>updated_at</tt> and <tt>created_at</tt> 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 <tt>_id</tt>.
# 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 <tt>_id</tt> and <tt>_rev</tt> fields, preparing the
# document to be saved to a new <tt>_id</tt>.
def destroy
result = database.delete_doc self
if result['ok']
self['_rev'] = nil
self['_id'] = nil
end
result['ok']
end
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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