loose_change 0.3.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +41 -0
- data/LICENSE +20 -0
- data/README.md +39 -0
- data/Rakefile +31 -0
- data/VERSION +1 -0
- data/lib/loose_change.rb +23 -0
- data/lib/loose_change/attachments.rb +60 -0
- data/lib/loose_change/attributes.rb +79 -0
- data/lib/loose_change/base.rb +71 -0
- data/lib/loose_change/database.rb +52 -0
- data/lib/loose_change/errors.rb +7 -0
- data/lib/loose_change/helpers.rb +10 -0
- data/lib/loose_change/naming.rb +11 -0
- data/lib/loose_change/pagination.rb +69 -0
- data/lib/loose_change/persistence.rb +154 -0
- data/lib/loose_change/server.rb +11 -0
- data/lib/loose_change/views.rb +99 -0
- data/loose_change.gemspec +89 -0
- data/test/attachment_test.rb +22 -0
- data/test/attributes_test.rb +37 -0
- data/test/base_test.rb +22 -0
- data/test/callback_test.rb +35 -0
- data/test/inheritance_test.rb +36 -0
- data/test/pagination_test.rb +35 -0
- data/test/persistence_test.rb +126 -0
- data/test/resources/couchdb.png +0 -0
- data/test/test_helper.rb +12 -0
- data/test/view_test.rb +59 -0
- metadata +162 -0
@@ -0,0 +1,69 @@
|
|
1
|
+
module LooseChange
|
2
|
+
|
3
|
+
module Pagination
|
4
|
+
|
5
|
+
# Similar to <tt>#view_by</tt>, but adds a paginated view
|
6
|
+
# compatible with <tt>will_paginate</tt>.
|
7
|
+
def paginated_view_by(*keys)
|
8
|
+
view_name = "paginated_by_#{ keys.join('_and_') }"
|
9
|
+
map_code = "function(doc) {
|
10
|
+
if ((doc['model_name'] == '#{ model_name }') && #{ keys.map {|k| existence_check k}.join('&&') }) {
|
11
|
+
emit(#{ key_for(keys) }, 1)
|
12
|
+
}
|
13
|
+
}
|
14
|
+
"
|
15
|
+
reduce_code = "function(keys, values) { return sum(values); }"
|
16
|
+
add_view(view_name, map_code, reduce_code)
|
17
|
+
view_by(*keys)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Returns a <tt>will_paginate</tt>-compatible set of documents.
|
21
|
+
# In +opts+, <tt>:per_page</tt> is required, and <tt>:page</tt>
|
22
|
+
# will be set to 1 unless otherwise specified. All other +opts+
|
23
|
+
# will be passed to CouchDB as in <tt>:view_by</tt>.
|
24
|
+
def paginated_by(view_name, opts = {})
|
25
|
+
raise "You must include a per_page parameter" if opts[:per_page].nil?
|
26
|
+
|
27
|
+
opts[:page] ||= 1
|
28
|
+
|
29
|
+
WillPaginate::Collection.create( opts[:page], opts[:per_page] ) do |pager|
|
30
|
+
total_result = view("paginated_by_#{ view_name }", :reduce => true)
|
31
|
+
pager.total_entries = total_result ? total_result.first : 0
|
32
|
+
|
33
|
+
results = if view_name == :all
|
34
|
+
view(view_name, :limit => opts[:per_page], :skip => ((opts[:page].to_i - 1) * opts[:per_page].to_i), :include_docs => true)
|
35
|
+
else
|
36
|
+
view("by_#{ view_name }", :key => opts[:key], :limit => opts[:per_page], :skip => ((opts[:page].to_i - 1) * opts[:per_page].to_i), :include_docs => true)
|
37
|
+
end
|
38
|
+
pager.replace( results )
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Short for <tt>paginated_by(:all)</tt>; see
|
43
|
+
# <tt>#paginated_by</tt> for +opts+.
|
44
|
+
def paginate(opts = {})
|
45
|
+
paginated_by(:all, opts)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def paginated_view_by_all
|
51
|
+
view_name = "paginated_by_all"
|
52
|
+
map_code = "function(doc) {
|
53
|
+
if (doc['model_name'] == '#{ model_name }') {
|
54
|
+
emit(null, 1);
|
55
|
+
}
|
56
|
+
}
|
57
|
+
"
|
58
|
+
reduce_code = "function(keys, values) { return sum(values); }"
|
59
|
+
add_view(view_name, map_code, reduce_code)
|
60
|
+
end
|
61
|
+
|
62
|
+
def existence_check(key)
|
63
|
+
"(#{ doc_key(key) } != null)"
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
|
68
|
+
|
69
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
|
3
|
+
module LooseChange
|
4
|
+
module Persistence
|
5
|
+
|
6
|
+
# Set the database source for this model, named +db+ on +server+
|
7
|
+
# (http://localhost:5984 by default). If the database does not
|
8
|
+
# exist, it will be added.
|
9
|
+
def use_database(db, server = "http://127.0.0.1:5984")
|
10
|
+
self.database = Database.new(db, Server.new(server))
|
11
|
+
Database.setup_design(self.database, self.model_name)
|
12
|
+
view_by_all
|
13
|
+
paginated_view_by_all
|
14
|
+
end
|
15
|
+
|
16
|
+
# Retrieve a document from CouchDB with id +id+ as a Loose Change instance.
|
17
|
+
def find(id)
|
18
|
+
begin
|
19
|
+
result = JSON.parse(RestClient.get(self.database.uri + "/#{ id }"), default_headers)
|
20
|
+
rescue RestClient::ResourceNotFound
|
21
|
+
raise RecordNotFound
|
22
|
+
end
|
23
|
+
raise RecordNotFound unless result['model_name'] == model_name
|
24
|
+
instantiate_from_hash(result)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Instantiate a new Loose Change record with attributes +args+ and
|
28
|
+
# immediately save to the database.
|
29
|
+
def create(args = {})
|
30
|
+
model = new(args)
|
31
|
+
model.save
|
32
|
+
model
|
33
|
+
end
|
34
|
+
|
35
|
+
# Instantiate a new Loose Change record with attributes +args+ and
|
36
|
+
# immediately save to the database. If the record is invalid, a
|
37
|
+
# <tt>RecordInvalid</tt> error will be thrown.
|
38
|
+
def create!(args = {})
|
39
|
+
new(args).save!
|
40
|
+
end
|
41
|
+
|
42
|
+
# Build a Loose Change record from a hash of attributes +hash+ as
|
43
|
+
# returned by CouchDB.
|
44
|
+
def instantiate_from_hash(hash)
|
45
|
+
model = new(hash.reject {|k, _| 'model_name' == k || '_attachments' == k})
|
46
|
+
model.id = hash['_id']
|
47
|
+
model.new_record = false
|
48
|
+
if hash['_attachments']
|
49
|
+
attachment_names = hash['_attachments'].map {|name, _| name}
|
50
|
+
model.attachments = attachment_names.inject({}) {|acc, name| acc[name.to_sym] = {:content_type => hash['_attachments'][name]['content_type']}; acc}
|
51
|
+
end
|
52
|
+
model
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
56
|
+
|
57
|
+
module PersistenceClassMethods
|
58
|
+
|
59
|
+
attr_accessor :new_record, :destroyed, :database, :id, :_rev, :_id
|
60
|
+
|
61
|
+
def new_record?() @new_record end
|
62
|
+
def destroyed?() @destroyed end
|
63
|
+
|
64
|
+
def persisted?
|
65
|
+
!(new_record? || destroyed?)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Persist the record to CouchDB, including saving of any attachments.
|
69
|
+
def save
|
70
|
+
if new_record?
|
71
|
+
_run_create_callbacks { _run_save_callbacks { _save } }
|
72
|
+
else
|
73
|
+
_run_save_callbacks { _save }
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Persist the record to CouchDB, including saving of any
|
78
|
+
# attachments. If the record is not valid, a
|
79
|
+
# <tt>RecordInvalid</tt> error will be thrown.
|
80
|
+
def save!
|
81
|
+
if new_record?
|
82
|
+
_run_create_callbacks { _run_save_callbacks { _save! } }
|
83
|
+
else
|
84
|
+
_run_save_callbacks { _save! }
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Destroy the record on CouchDB by sending an HTTP DELETE request.
|
89
|
+
def destroy
|
90
|
+
_run_destroy_callbacks do
|
91
|
+
raise DatabaseNotSet.new("Cannot destroy without database set.") unless @database
|
92
|
+
result = JSON.parse(RestClient.delete("#{ database.uri }/#{ CGI.escape(id) }?rev=#{ @_rev }", default_headers))['ok']
|
93
|
+
@destroyed = result
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
def _save
|
100
|
+
raise DatabaseNotSet.new("Cannot save without database set.") unless @database
|
101
|
+
apply_defaults
|
102
|
+
return false unless valid?
|
103
|
+
new_record? ? post_record : put_record
|
104
|
+
put_attachments
|
105
|
+
self
|
106
|
+
end
|
107
|
+
|
108
|
+
def _save!
|
109
|
+
raise RecordInvalid, self.errors.map {|k, v| "#{k.capitalize} #{v}"}.join(', ') unless _save
|
110
|
+
self
|
111
|
+
end
|
112
|
+
|
113
|
+
def uri
|
114
|
+
"#{database.uri}/#{ CGI.escape(id) }"
|
115
|
+
end
|
116
|
+
|
117
|
+
def post_record
|
118
|
+
result = JSON.parse(RestClient.post(database.uri, self.to_json(:methods => [:model_name]), default_headers))
|
119
|
+
@id = @_id = result['id']
|
120
|
+
@_rev = result['rev']
|
121
|
+
@new_record = false
|
122
|
+
result
|
123
|
+
end
|
124
|
+
|
125
|
+
def put_record
|
126
|
+
result = JSON.parse(RestClient.put(uri, self.to_json(:methods => [:model_name, :_rev, :_id], :except => [:id]), default_headers))
|
127
|
+
@_rev = result['rev']
|
128
|
+
result
|
129
|
+
end
|
130
|
+
|
131
|
+
def put_attachments
|
132
|
+
(@attachments || {}).each { |name, attachment| put_attachment(name) if attachment[:dirty] }
|
133
|
+
end
|
134
|
+
|
135
|
+
def attachment_ivar(name)
|
136
|
+
instance_variable_get("@#{ name }")
|
137
|
+
end
|
138
|
+
|
139
|
+
def attachment_content_type(name)
|
140
|
+
instance_variable_get("@_#{ name }_content_type")
|
141
|
+
end
|
142
|
+
|
143
|
+
end
|
144
|
+
|
145
|
+
class RecordNotFound < Exception
|
146
|
+
end
|
147
|
+
|
148
|
+
class DatabaseNotSet < Exception
|
149
|
+
end
|
150
|
+
|
151
|
+
class RecordInvalid < Exception
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
|
3
|
+
module LooseChange
|
4
|
+
module Views
|
5
|
+
|
6
|
+
# Invoke a view identified by +view_name+ on this Loose Change
|
7
|
+
# model's design document on CouchDB. Options specified in the
|
8
|
+
# +opts+ hash will be passed along to CouchDB; for options see
|
9
|
+
# http://wiki.apache.org/couchdb/Introduction_to_CouchDB_views
|
10
|
+
def view(view_name, opts = {})
|
11
|
+
opts[:key] = opts[:key] ? CGI.escape(opts[:key].to_json) : nil
|
12
|
+
param_string = opts.reject {|k,v| v.nil?}.map {|k,v| "#{k}=#{v}"}.join('&')
|
13
|
+
JSON.parse(RestClient.get("#{ self.database.uri }/_design/#{ CGI.escape(self.model_name) }/_view/#{ view_name }?#{ param_string }", default_headers))['rows'].map do |row|
|
14
|
+
opts[:include_docs] ? instantiate_from_hash(row['doc']) : row['value']
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Set up a view that will allow you to query CouchDB by +keys+. A
|
19
|
+
# view will be added to the design document on CouchDB, and a
|
20
|
+
# method to retrieve documents will be added to the Loose Change
|
21
|
+
# model class.
|
22
|
+
#
|
23
|
+
# class Recipe < LooseChange::Base
|
24
|
+
# property :name
|
25
|
+
# property :popularity
|
26
|
+
# view_by :name
|
27
|
+
# view_by :name, :popularity
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# Recipe.by_name("lasagne")
|
31
|
+
# Recipe.by_name_and_popularity("lasagne", 4)
|
32
|
+
def view_by(*keys)
|
33
|
+
view_name = "by_#{ keys.join('_and_') }"
|
34
|
+
view_code = "function(doc) {
|
35
|
+
if ((doc['model_name'] == '#{ model_name }') && #{ keys.map {|k| existence_check k}.join('&&') }) {
|
36
|
+
emit(#{ key_for(keys) }, null)
|
37
|
+
}
|
38
|
+
}
|
39
|
+
"
|
40
|
+
add_view(view_name, view_code)
|
41
|
+
self.class.send(:define_method, view_name.to_sym) do |*keys|
|
42
|
+
keys = keys.first if keys.length == 1
|
43
|
+
view(view_name, :key => keys, :include_docs => true)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Retrieve all of this model's documents currently stored on CouchDB.
|
48
|
+
def all(opts = {})
|
49
|
+
view(:all, opts.merge!(:include_docs => true))
|
50
|
+
end
|
51
|
+
|
52
|
+
# Add a view to the this model's design document on CouchDB. The
|
53
|
+
# view is identified by +name+, defined by +map+ and optionally
|
54
|
+
# +reduce+, which are composed of JavaScript functions. For more
|
55
|
+
# information on CouchDB views, see
|
56
|
+
# http://wiki.apache.org/couchdb/Introduction_to_CouchDB_views
|
57
|
+
def add_view(name, map, reduce = nil)
|
58
|
+
design_doc = JSON.parse(RestClient.get("#{ self.database.uri }/_design/#{ CGI.escape(self.model_name) }"))
|
59
|
+
current_views = design_doc['views'] || {}
|
60
|
+
JSON.parse(RestClient.put("#{ self.database.uri }/_design/#{ self.model_name }",
|
61
|
+
{ '_id' => design_doc['_id'],
|
62
|
+
'_rev' => design_doc['_rev'],
|
63
|
+
'language' => 'javascript',
|
64
|
+
'views' => current_views.merge({name => {'map' => map, 'reduce' => reduce}})}.to_json, default_headers))
|
65
|
+
end
|
66
|
+
|
67
|
+
#:nodoc:
|
68
|
+
def view_by_all
|
69
|
+
view_name = "all"
|
70
|
+
view_code = "function(doc) {
|
71
|
+
if (doc['model_name'] == '#{ model_name }') {
|
72
|
+
emit(null);
|
73
|
+
}
|
74
|
+
}
|
75
|
+
"
|
76
|
+
add_view(view_name, view_code)
|
77
|
+
end
|
78
|
+
|
79
|
+
#:nodoc:
|
80
|
+
def existence_check(key)
|
81
|
+
"(#{ doc_key(key) } != null)"
|
82
|
+
end
|
83
|
+
|
84
|
+
#:nodoc:
|
85
|
+
def key_for(keys)
|
86
|
+
if keys.length == 1
|
87
|
+
doc_key(keys.first)
|
88
|
+
else
|
89
|
+
"[#{keys.map {|k| doc_key(k) }.join(',')}]"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
#:nodoc:
|
94
|
+
def doc_key(key)
|
95
|
+
"doc['#{ key }']"
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{loose_change}
|
8
|
+
s.version = "0.3.1"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Joshua Miller"]
|
12
|
+
s.date = %q{2010-11-24}
|
13
|
+
s.email = %q{josh@joshinharrisburg.com}
|
14
|
+
s.extra_rdoc_files = [
|
15
|
+
"LICENSE",
|
16
|
+
"README.md"
|
17
|
+
]
|
18
|
+
s.files = [
|
19
|
+
".gitignore",
|
20
|
+
"Gemfile",
|
21
|
+
"Gemfile.lock",
|
22
|
+
"LICENSE",
|
23
|
+
"README.md",
|
24
|
+
"Rakefile",
|
25
|
+
"VERSION",
|
26
|
+
"lib/loose_change.rb",
|
27
|
+
"lib/loose_change/attachments.rb",
|
28
|
+
"lib/loose_change/attributes.rb",
|
29
|
+
"lib/loose_change/base.rb",
|
30
|
+
"lib/loose_change/database.rb",
|
31
|
+
"lib/loose_change/errors.rb",
|
32
|
+
"lib/loose_change/helpers.rb",
|
33
|
+
"lib/loose_change/naming.rb",
|
34
|
+
"lib/loose_change/pagination.rb",
|
35
|
+
"lib/loose_change/persistence.rb",
|
36
|
+
"lib/loose_change/server.rb",
|
37
|
+
"lib/loose_change/views.rb",
|
38
|
+
"loose_change.gemspec",
|
39
|
+
"test/attachment_test.rb",
|
40
|
+
"test/attributes_test.rb",
|
41
|
+
"test/base_test.rb",
|
42
|
+
"test/callback_test.rb",
|
43
|
+
"test/inheritance_test.rb",
|
44
|
+
"test/pagination_test.rb",
|
45
|
+
"test/persistence_test.rb",
|
46
|
+
"test/resources/couchdb.png",
|
47
|
+
"test/test_helper.rb",
|
48
|
+
"test/view_test.rb"
|
49
|
+
]
|
50
|
+
s.homepage = %q{http://github.com/joshuamiller/loose_change}
|
51
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
52
|
+
s.require_paths = ["lib"]
|
53
|
+
s.rubygems_version = %q{1.3.7}
|
54
|
+
s.summary = %q{ActiveModel-compliant CouchDB ORM}
|
55
|
+
s.test_files = [
|
56
|
+
"test/attachment_test.rb",
|
57
|
+
"test/attributes_test.rb",
|
58
|
+
"test/base_test.rb",
|
59
|
+
"test/callback_test.rb",
|
60
|
+
"test/inheritance_test.rb",
|
61
|
+
"test/pagination_test.rb",
|
62
|
+
"test/persistence_test.rb",
|
63
|
+
"test/test_helper.rb",
|
64
|
+
"test/view_test.rb"
|
65
|
+
]
|
66
|
+
|
67
|
+
if s.respond_to? :specification_version then
|
68
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
69
|
+
s.specification_version = 3
|
70
|
+
|
71
|
+
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
72
|
+
s.add_runtime_dependency(%q<activesupport>, ["~> 3.0.0"])
|
73
|
+
s.add_runtime_dependency(%q<activemodel>, ["~> 3.0.0"])
|
74
|
+
s.add_runtime_dependency(%q<rest-client>, ["~> 1.6.0"])
|
75
|
+
s.add_runtime_dependency(%q<json>, ["~> 1.4.6"])
|
76
|
+
else
|
77
|
+
s.add_dependency(%q<activesupport>, ["~> 3.0.0"])
|
78
|
+
s.add_dependency(%q<activemodel>, ["~> 3.0.0"])
|
79
|
+
s.add_dependency(%q<rest-client>, ["~> 1.6.0"])
|
80
|
+
s.add_dependency(%q<json>, ["~> 1.4.6"])
|
81
|
+
end
|
82
|
+
else
|
83
|
+
s.add_dependency(%q<activesupport>, ["~> 3.0.0"])
|
84
|
+
s.add_dependency(%q<activemodel>, ["~> 3.0.0"])
|
85
|
+
s.add_dependency(%q<rest-client>, ["~> 1.6.0"])
|
86
|
+
s.add_dependency(%q<json>, ["~> 1.4.6"])
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|