loose_change 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ module LooseChange
2
+ module Errors
3
+
4
+ attr_accessor :errors
5
+
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ module LooseChange
2
+ module Helpers
3
+ def default_headers
4
+ {
5
+ :content_type => :json,
6
+ :accept => :json
7
+ }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ module LooseChange
2
+ module Naming
3
+ include ActiveModel::Naming
4
+ end
5
+
6
+ module NamingClassMethods
7
+ def model_name
8
+ self.class.model_name
9
+ end
10
+ end
11
+ end
@@ -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,11 @@
1
+ module LooseChange
2
+ class Server
3
+
4
+ attr_accessor :uri
5
+
6
+ def initialize(server = "http://127.0.0.1:5984")
7
+ @uri = server
8
+ end
9
+
10
+ end
11
+ 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
+