couch_tomato 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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