couch_tomato 0.1.0

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.
Files changed (52) hide show
  1. data/MIT-LICENSE.txt +19 -0
  2. data/README.md +96 -0
  3. data/init.rb +3 -0
  4. data/lib/core_ext/date.rb +10 -0
  5. data/lib/core_ext/duplicable.rb +43 -0
  6. data/lib/core_ext/extract_options.rb +14 -0
  7. data/lib/core_ext/inheritable_attributes.rb +222 -0
  8. data/lib/core_ext/object.rb +5 -0
  9. data/lib/core_ext/string.rb +19 -0
  10. data/lib/core_ext/symbol.rb +15 -0
  11. data/lib/core_ext/time.rb +12 -0
  12. data/lib/couch_tomato/database.rb +279 -0
  13. data/lib/couch_tomato/js_view_source.rb +182 -0
  14. data/lib/couch_tomato/migration.rb +52 -0
  15. data/lib/couch_tomato/migrator.rb +235 -0
  16. data/lib/couch_tomato/persistence/base.rb +62 -0
  17. data/lib/couch_tomato/persistence/belongs_to_property.rb +58 -0
  18. data/lib/couch_tomato/persistence/callbacks.rb +60 -0
  19. data/lib/couch_tomato/persistence/dirty_attributes.rb +27 -0
  20. data/lib/couch_tomato/persistence/json.rb +48 -0
  21. data/lib/couch_tomato/persistence/magic_timestamps.rb +15 -0
  22. data/lib/couch_tomato/persistence/properties.rb +58 -0
  23. data/lib/couch_tomato/persistence/simple_property.rb +97 -0
  24. data/lib/couch_tomato/persistence/validation.rb +18 -0
  25. data/lib/couch_tomato/persistence.rb +85 -0
  26. data/lib/couch_tomato/replicator.rb +50 -0
  27. data/lib/couch_tomato.rb +46 -0
  28. data/lib/tasks/couch_tomato.rake +128 -0
  29. data/rails/init.rb +7 -0
  30. data/spec/callbacks_spec.rb +271 -0
  31. data/spec/comment.rb +8 -0
  32. data/spec/create_spec.rb +22 -0
  33. data/spec/custom_view_spec.rb +134 -0
  34. data/spec/destroy_spec.rb +29 -0
  35. data/spec/fixtures/address.rb +9 -0
  36. data/spec/fixtures/person.rb +6 -0
  37. data/spec/property_spec.rb +103 -0
  38. data/spec/spec_helper.rb +40 -0
  39. data/spec/unit/attributes_spec.rb +26 -0
  40. data/spec/unit/callbacks_spec.rb +33 -0
  41. data/spec/unit/create_spec.rb +58 -0
  42. data/spec/unit/customs_views_spec.rb +15 -0
  43. data/spec/unit/database_spec.rb +38 -0
  44. data/spec/unit/dirty_attributes_spec.rb +113 -0
  45. data/spec/unit/string_spec.rb +13 -0
  46. data/spec/unit/view_query_spec.rb +9 -0
  47. data/spec/update_spec.rb +40 -0
  48. data/test/test_helper.rb +63 -0
  49. data/test/unit/database_test.rb +285 -0
  50. data/test/unit/js_view_test.rb +362 -0
  51. data/test/unit/property_test.rb +193 -0
  52. metadata +133 -0
@@ -0,0 +1,279 @@
1
+ require 'couchrest'
2
+ require 'pp'
3
+
4
+ module CouchTomato
5
+ class Database
6
+
7
+ class ValidationsFailedError < ::StandardError; end
8
+
9
+ # Database
10
+ class_inheritable_accessor :prefix_string
11
+ class_inheritable_accessor :database_name
12
+ class_inheritable_accessor :database_server
13
+ class_inheritable_accessor :couchrest_db
14
+ class_inheritable_accessor :views
15
+
16
+ self.views = {}
17
+ self.prefix_string = ''
18
+
19
+ def self.database
20
+ return self.couchrest_db if self.couchrest_db
21
+
22
+ self.prefix_string ||= ''
23
+
24
+ tmp_prefix = self.prefix_string + '_' unless self.prefix_string.empty?
25
+ tmp_server = self.database_server + '/' unless self.database_server.match(/\/$/)
26
+ tmp_suffix = '_' + Rails.env if defined?(Rails)
27
+
28
+ self.couchrest_db = CouchRest.database("#{tmp_server}#{tmp_prefix}#{self.database_name}#{tmp_suffix}")
29
+ begin
30
+ self.couchrest_db.info
31
+ rescue RestClient::ResourceNotFound
32
+ raise "Database '#{tmp_prefix}#{self.database_name}#{tmp_suffix}' does not exist."
33
+ end
34
+
35
+ self.couchrest_db
36
+ end
37
+
38
+ def self.prefix (name)
39
+ self.prefix_string = name || ''
40
+ end
41
+ def self.name (name)
42
+ raise 'You need to provide a database name' if name.nil?
43
+ self.database_name = (name.class == Symbol)? name.to_s : name
44
+ end
45
+
46
+ # TODO: specify db=>host mapping in yaml, and allow to differ per environment
47
+ def self.server (route='http://127.0.0.1:5984/')
48
+ self.database_server = route
49
+
50
+ # self.prefix_string ||= ''
51
+ #
52
+ # tmp_prefix = self.prefix_string + '_' unless self.prefix_string.empty?
53
+ # tmp_server = self.database_server + '/' unless self.database_server.match(/\/$/)
54
+ # tmp_suffix = '_' + Rails.env if defined?(Rails)
55
+ #
56
+ #
57
+ # self.couchrest_db ||= CouchRest.database("#{tmp_server}#{tmp_prefix}#{self.database_name}#{tmp_suffix}")
58
+ # begin
59
+ # self.couchrest_db.info
60
+ # rescue RestClient::ResourceNotFound
61
+ # raise "Database '#{tmp_prefix}#{self.database_name}#{tmp_suffix}' does not exist."
62
+ # end
63
+
64
+ end
65
+
66
+
67
+ def self.view(name, options={})
68
+ raise 'A View nemonic must be specified' if name.nil?
69
+ self.views[name] = {}
70
+ self.views[name][:design_doc] = !options[:design_doc] ? self.database_name.to_sym : options.delete(:design_doc).to_sym
71
+ self.views[name][:view_name] = options.delete(:view_name) || name.to_s
72
+ self.views[name][:model] = options.delete(:model)
73
+ self.views[name][:couch_options] = options
74
+ end
75
+
76
+ def self.save_document(document)
77
+ # TODO: Need to place some protected block here to respond to an exception in case trying to save
78
+ # a :raw document with an old _rev number
79
+
80
+ # return self.couchrest_db.save_doc(document) unless document.respond_to?(:dirty?)
81
+
82
+ # return true if document.respond_to?(:dirty?) && !document.dirty?
83
+
84
+ if document.new?
85
+ self.create_document document
86
+ else
87
+ self.update_document document
88
+ end
89
+ end
90
+
91
+ def self.save_document!(document)
92
+ save_document(document) || raise(ValidationsFailedError.new(document.errors.full_messages))
93
+ end
94
+
95
+ def self.bulk_save(documents)
96
+ doc_hashes = []
97
+
98
+ documents.each do |document|
99
+ document.run_callbacks :before_validation_on_save
100
+ document.run_callbacks(document.new? ? :before_validation_on_create : :before_validation_on_update)
101
+ return unless document.valid?
102
+ document.run_callbacks :before_save
103
+ document.run_callbacks(document.new? ? :before_create : :before_update)
104
+
105
+ doc_hashes << document.to_hash
106
+ end
107
+
108
+ res = self.couchrest_db.bulk_save(doc_hashes)
109
+
110
+ documents.each_with_index do |document, index|
111
+ is_new = document.new?
112
+ document._id = res[index]['id'] if is_new
113
+ document._rev = res[index]['rev']
114
+ document.run_callbacks :after_save
115
+ document.run_callbacks(is_new ? :after_create : :after_update)
116
+ end
117
+
118
+ true
119
+ end
120
+
121
+ def self.destroy_document(document)
122
+ document.run_callbacks :before_destroy
123
+ document._deleted = true
124
+ self.database.delete_doc document.to_hash
125
+ document.run_callbacks :after_destroy
126
+ document._id = nil
127
+ document._rev = nil
128
+ end
129
+
130
+ def self.load_document(id, options ={})
131
+ raise "Can't load a document without an id (got nil)" if id.nil?
132
+
133
+ begin
134
+ # json = self.couchrest_db.get(id)
135
+ # instance = Class.const_get(json['ruby_class']).json_create json
136
+ # # instance.database = self
137
+ # instance
138
+ json = self.database.get(id)
139
+ if options[:model] == :raw || !json['ruby_class']
140
+ {}.merge(json)
141
+ else
142
+ klass = class_from_string(json['ruby_class'])
143
+ instance = klass.json_create json
144
+ instance
145
+ end
146
+ rescue(RestClient::ResourceNotFound) #'Document not found'
147
+ nil
148
+ end
149
+ end
150
+
151
+ def self.inspect
152
+ super
153
+ puts 'Database name: ' + (self.database_name || 'nil')
154
+ puts 'Database server: ' + (self.database_server || 'nil')
155
+ puts 'Views:'
156
+ pp self.views
157
+ end
158
+
159
+ def self.query_view!(name, options={})
160
+ view = self.views[name]
161
+ raise 'View does not exist' unless view
162
+
163
+ begin
164
+ tmp_couch_opts = view[:couch_options] || {}
165
+ pr_options = options.merge(tmp_couch_opts)
166
+ results = self.query_view(name, pr_options)
167
+ self.process_results(name, results, pr_options)
168
+ rescue RestClient::ResourceNotFound# => e
169
+ raise
170
+ end
171
+ end
172
+
173
+ class << self
174
+ alias_method :save, :save_document
175
+ alias_method :save_doc, :save_document
176
+
177
+ alias_method :save!, :save_document!
178
+ alias_method :save_doc!, :save_document!
179
+
180
+ alias_method :destroy, :destroy_document
181
+ alias_method :destroy_doc, :destroy_document
182
+
183
+ alias_method :load, :load_document
184
+ alias_method :load_doc, :load_document
185
+ end
186
+
187
+ private
188
+
189
+ def self.create_document(document)
190
+ # document.database = self
191
+ document.run_callbacks :before_validation_on_save
192
+ document.run_callbacks :before_validation_on_create
193
+ return unless document.valid?
194
+ document.run_callbacks :before_save
195
+ document.run_callbacks :before_create
196
+ res = self.database.save_doc document.to_hash
197
+ document._rev = res['rev']
198
+ document._id = res['id']
199
+ document.run_callbacks :after_save
200
+ document.run_callbacks :after_create
201
+ true
202
+ end
203
+
204
+ def self.update_document(document)
205
+ document.run_callbacks :before_validation_on_save
206
+ document.run_callbacks :before_validation_on_update
207
+ return unless document.valid?
208
+ document.run_callbacks :before_save
209
+ document.run_callbacks :before_update
210
+ res = self.database.save_doc document.to_hash
211
+ document._rev = res['rev']
212
+ document.run_callbacks :after_save
213
+ document.run_callbacks :after_update
214
+ true
215
+ end
216
+
217
+ def self.query_view(name,parameters)
218
+ self.database.view "#{self.views[name][:design_doc]}/#{self.views[name][:view_name]}", parameters
219
+ end
220
+
221
+ def self.process_results(name, results, options ={})
222
+ # view = self.views[name]
223
+ options = self.views[name].merge(options)
224
+
225
+ couch_opts = options[:couch_options] || {}
226
+ return_raw = couch_opts[:reduce] || options[:model] == :raw
227
+
228
+ first_result = results['rows'][0]
229
+
230
+ if (first_result && first_result['doc'] && first_result['value'])
231
+ raise "View results contain doc and value keys. Don't pass `:include_docs => true` to a view that does not `emit(some_key,null)`"
232
+ end
233
+
234
+ field_to_read = first_result && first_result['doc'] ? 'doc' : 'value'
235
+
236
+ # TODO: This code looks like C (REVIEW)
237
+ results['rows'].map do |row|
238
+ next row[field_to_read] if return_raw
239
+
240
+ model = options[:model]
241
+ if model
242
+ model = model.kind_of?(String) ? class_from_string(model) : model
243
+ meta = {'id' => row['id']}.merge({'key' => row['key']})
244
+ model.json_create(row[field_to_read], meta)
245
+ else
246
+ if row[field_to_read]['ruby_class'].nil?
247
+ row[field_to_read]
248
+ else
249
+ meta = {'id' => row['id']}.merge({'key' => row['key']})
250
+ # Class.const_get(row[field_to_read]['ruby_class']).json_create(row[field_to_read], meta)
251
+ klass = class_from_string(row[field_to_read]['ruby_class'])
252
+ klass.json_create(row[field_to_read], meta)
253
+ end
254
+ end
255
+ end # results do
256
+ end
257
+
258
+ private
259
+
260
+ def self.class_from_string(string)
261
+ string.to_s.split('::').inject(Object){|a, m| a = a.const_get(m.to_sym)}
262
+ end
263
+
264
+ end # class
265
+ end # module
266
+
267
+ # if return_raw
268
+ # row[field_to_read]
269
+ # elsif options[:model].nil?
270
+ # if row[field_to_read]['ruby_class'].nil?
271
+ # row[field_to_read]
272
+ # else
273
+ # meta = {'id' => row['id']}.merge({'key' => row['key']})
274
+ # Class.const_get(row[field_to_read]['ruby_class']).json_create(row[field_to_read], meta)
275
+ # end
276
+ # else
277
+ # meta = {'id' => row['id']}.merge({'key' => row['key']})
278
+ # options[:model].json_create(row[field_to_read], meta)
279
+ # end
@@ -0,0 +1,182 @@
1
+ require 'digest/sha1'
2
+
3
+ module CouchTomato
4
+ class JsViewSource
5
+ # todo: provide a 'dirty?' method that can be called in an initializer and warn the developer that view are out of sync
6
+ # todo: provide more granular information about which views are being modified
7
+ # todo: limitation (bug?) where if you remove a database's views entirely from the file system, view's will not be removed from the database as may be expected
8
+ def self.push(silent=false)
9
+ fs_database_names.each do |database_name|
10
+ db = database!(database_name)
11
+
12
+ fs_docs = fs_design_docs(database_name)
13
+ db_docs = db_design_docs(db)
14
+
15
+ fs_docs.each do |design_name, fs_doc|
16
+ db_doc = db_docs[design_name]
17
+
18
+ if db_doc
19
+ fs_doc['_id'] = db_doc['_id']
20
+ fs_doc['_rev'] = db_doc['_rev']
21
+ end
22
+
23
+ if fs_doc['views'].empty?
24
+ next unless fs_doc['_rev']
25
+ puts "DELETE #{fs_doc['_id']}" unless silent
26
+ db.delete_doc(fs_doc)
27
+ else
28
+ if changed_views?(fs_doc, db_doc)
29
+ puts "UPDATE #{fs_doc['_id']}" unless silent
30
+ db.save_doc(fs_doc)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ def self.changed_views?(fs_doc, db_doc)
38
+ return true if db_doc.nil?
39
+
40
+ fs_doc['views'].each do |name, fs_view|
41
+ db_view = db_doc['views'][name]
42
+ %w(map reduce).each do |method|
43
+ return true if db_view.nil? || db_view["sha1-#{method}"] != fs_view["sha1-#{method}"]
44
+ end
45
+ end
46
+
47
+ return false
48
+ end
49
+
50
+ def self.diff
51
+ fs_database_names.each do |database_name|
52
+ db = database!(database_name)
53
+
54
+ fs_docs = fs_design_docs(database_name)
55
+ db_docs = db_design_docs(db)
56
+
57
+ # design docs on fs but not in db
58
+ (fs_docs.keys - db_docs.keys).each do |design_name|
59
+ unless fs_docs[design_name]['views'].empty?
60
+ puts " NEW: #{database_name}/_#{design_name}: #{fs_docs[design_name]['views'].keys.join(', ')}"
61
+ end
62
+ end
63
+
64
+ # design docs in db but not on fs
65
+ (db_docs.keys - fs_docs.keys).each do |design_name|
66
+ puts "REMOVED: #{database_name}/_#{design_name}"
67
+ end
68
+
69
+ # design docs in both db and fs
70
+ (fs_docs.keys & db_docs.keys).each do |design_name|
71
+ common_view_keys = (fs_docs[design_name]['views'].keys & db_docs[design_name]['views'].keys)
72
+ fs_only_view_keys = fs_docs[design_name]['views'].keys - common_view_keys
73
+ db_only_view_keys = db_docs[design_name]['views'].keys - common_view_keys
74
+
75
+ unless fs_only_view_keys.empty?
76
+ methods = fs_only_view_keys.map do |key|
77
+ %w(map reduce).map {|method| fs_docs[design_name]['views'][key][method].nil? ? nil : "#{key}.#{method}()"}.compact
78
+ end.flatten
79
+ puts " ADDED: #{database_name}/_#{design_name}: #{methods.join(', ')}"
80
+ end
81
+
82
+ unless db_only_view_keys.empty?
83
+ methods = db_only_view_keys.map do |key|
84
+ %w(map reduce).map {|method| db_docs[design_name]['views'][key][method] ? "#{key}.#{method}()" : nil}.compact
85
+ end
86
+ puts "REMOVED: #{database_name}/_#{design_name}: #{methods.join(', ')}"
87
+ end
88
+
89
+ common_view_keys.each do |common_key|
90
+ # are the sha's the same?
91
+ # map reduce sha1
92
+ fs_view = fs_docs[design_name]['views'][common_key]
93
+ db_view = db_docs[design_name]['views'][common_key]
94
+
95
+ # has either the map or reduce been added or removed
96
+ %w(map reduce).each do |method|
97
+ if db_view[method] && !fs_view[method]
98
+ puts "REMOVED: #{database_name}/_#{design_name}:#{method}()" and next
99
+ end
100
+
101
+ if fs_view[method] && !db_view[method]
102
+ puts " ADDED: #{database_name}/_#{design_name}:#{method}()" and next
103
+ end
104
+
105
+ if fs_view["sha1-#{method}"] != db_view["sha1-#{method}"]
106
+ puts "OUTDATED: #{database_name}/_#{design_name}:#{method}()" and next
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def self.path(db_name="")
118
+ "#{Rails.root}/db/views/#{db_name}" if Rails
119
+ end
120
+
121
+ def self.fs_database_names
122
+ Dir[path + "/**"].map {|path| path.split('/').last}
123
+ end
124
+
125
+ def self.db_design_docs(db)
126
+ design_docs = db.get("_all_docs", {:startkey => "_design/", :endkey => "_design0", :include_docs => true})['rows']
127
+ design_docs.inject({}) do |res, row|
128
+ doc = row['doc']
129
+ design_name = doc['_id'].split('/').last
130
+ res[design_name.to_sym] = doc#['views']
131
+ res
132
+ end
133
+ end
134
+
135
+ # :clicks => {'by_date' => {'map' => ..., 'reduce' => ..., sha1-map => ..., sha1-reduce => ...} }
136
+ def self.fs_design_docs(db_name)
137
+ design_docs = {}
138
+
139
+ path = "#{RAILS_ROOT}/couchdb/views/#{db_name}"
140
+ Dir[path + "/**"].each do |file|
141
+ throw "Invalid filename '#{File.basename(file)}': expecting '-map.js' or '-reduce.js' suffix" unless file.match(/-((map)|(reduce))\.js$/)
142
+
143
+ design_name = db_name.to_sym
144
+ design_docs[design_name] ||= {'_id' => "_design/#{db_name}", 'views' => {}}
145
+ fs_view(design_docs[design_name], file)
146
+ end
147
+
148
+ design_docs.each do |db, design|
149
+ design["views"].each do |view, functions|
150
+ if !functions["reduce"].nil?
151
+ raise "#{view}-reduce was found without a corresponding map function." if functions["map"].nil?
152
+ end
153
+ end
154
+ end
155
+
156
+ design_docs
157
+ end
158
+
159
+ def self.fs_view(design_doc, view_path)
160
+ filename = view_path.split('/').last.split('.').first
161
+ name, type = filename.split('-')
162
+
163
+ file = open(view_path)
164
+ content = file.read
165
+ file.close
166
+
167
+ sha1 = Digest::SHA1.hexdigest(content)
168
+
169
+ design_doc['views'][name] ||= {}
170
+ design_doc['views'][name][type] = content
171
+ design_doc['views'][name]["sha1-#{type}"] = sha1
172
+ design_doc
173
+ end
174
+
175
+ # todo: don't depend on "proprietary" APP_CONFIG
176
+ def self.database!(database_name)
177
+ CouchRest.database!("http://" + APP_CONFIG["couchdb_address"] + ":" + APP_CONFIG["couchdb_port"].to_s \
178
+ + "/" + APP_CONFIG["couchdb_basename"] + "_" + database_name + "_" + RAILS_ENV)
179
+ end
180
+
181
+ end
182
+ end
@@ -0,0 +1,52 @@
1
+ module CouchTomato
2
+ class Migration
3
+ class << self
4
+ # Execute this migration in the named direction
5
+ def migrate(direction, db)
6
+ return unless respond_to?(direction)
7
+
8
+ case direction
9
+ when :up then announce "migrating"
10
+ when :down then announce "reverting"
11
+ end
12
+
13
+ time = Benchmark.measure do
14
+ docs = db.get('_all_docs', :include_docs => true)['rows']
15
+ docs.each do |doc|
16
+ next if /^_design/ =~ doc['id']
17
+ send(direction, doc['doc'])
18
+ db.save_doc(doc['doc'])
19
+ end
20
+ end
21
+
22
+ case direction
23
+ when :up then announce "migrated (%.4fs)" % time.real; write
24
+ when :down then announce "reverted (%.4fs)" % time.real; write
25
+ end
26
+ end
27
+
28
+ def write(text="")
29
+ puts(text)
30
+ end
31
+
32
+ def announce(message)
33
+ text = "#{@version} #{name}: #{message}"
34
+ length = [0, 75 - text.length].max
35
+ write "== %s %s" % [text, "=" * length]
36
+ end
37
+
38
+ def say(message, subitem=false)
39
+ write "#{subitem ? " ->" : "--"} #{message}"
40
+ end
41
+
42
+ def say_with_time(message)
43
+ say(message)
44
+ result = nil
45
+ time = Benchmark.measure { result = yield }
46
+ say "%.4fs" % time.real, :subitem
47
+ say("#{result} rows", :subitem) if result.is_a?(Integer)
48
+ result
49
+ end
50
+ end
51
+ end
52
+ end