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
data/.gitignore
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
source 'http://rubygems.org'
|
2
|
+
|
3
|
+
gem 'activesupport', '~> 3.0.0'
|
4
|
+
gem 'activemodel', '~> 3.0.0'
|
5
|
+
gem 'rest-client'
|
6
|
+
gem 'json'
|
7
|
+
gem 'will_paginate'
|
8
|
+
|
9
|
+
group :development do
|
10
|
+
gem 'jeweler'
|
11
|
+
gem 'rake'
|
12
|
+
end
|
13
|
+
|
14
|
+
group :test do
|
15
|
+
gem 'shoulda'
|
16
|
+
gem 'timecop'
|
17
|
+
end
|
data/Gemfile.lock
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
GEM
|
2
|
+
remote: http://rubygems.org/
|
3
|
+
specs:
|
4
|
+
activemodel (3.0.0)
|
5
|
+
activesupport (= 3.0.0)
|
6
|
+
builder (~> 2.1.2)
|
7
|
+
i18n (~> 0.4.1)
|
8
|
+
activesupport (3.0.0)
|
9
|
+
builder (2.1.2)
|
10
|
+
gemcutter (0.6.1)
|
11
|
+
git (1.2.5)
|
12
|
+
i18n (0.4.1)
|
13
|
+
jeweler (1.4.0)
|
14
|
+
gemcutter (>= 0.1.0)
|
15
|
+
git (>= 1.2.5)
|
16
|
+
rubyforge (>= 2.0.0)
|
17
|
+
json (1.4.6)
|
18
|
+
json_pure (1.4.6)
|
19
|
+
mime-types (1.16)
|
20
|
+
rake (0.8.7)
|
21
|
+
rest-client (1.6.1)
|
22
|
+
mime-types (>= 1.16)
|
23
|
+
rubyforge (2.0.4)
|
24
|
+
json_pure (>= 1.1.7)
|
25
|
+
shoulda (2.11.3)
|
26
|
+
timecop (0.3.5)
|
27
|
+
will_paginate (3.0.pre2)
|
28
|
+
|
29
|
+
PLATFORMS
|
30
|
+
ruby
|
31
|
+
|
32
|
+
DEPENDENCIES
|
33
|
+
activemodel (~> 3.0.0)
|
34
|
+
activesupport (~> 3.0.0)
|
35
|
+
jeweler
|
36
|
+
json
|
37
|
+
rake
|
38
|
+
rest-client
|
39
|
+
shoulda
|
40
|
+
timecop
|
41
|
+
will_paginate
|
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2010 Joshua Miller
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
# Loose Change is a Ruby ORM for CouchDB
|
2
|
+
|
3
|
+
* where 'ORM' is as accurate as ['Holy Roman
|
4
|
+
Empire'](http://en.wikipedia.org/wiki/Holy_Roman_Empire#Analysis)
|
5
|
+
|
6
|
+
## Goals and Principles
|
7
|
+
|
8
|
+
* Take advantage of
|
9
|
+
[ActiveModel](http://yehudakatz.com/2010/01/10/activemodel-make-any-ruby-object-feel-like-activerecord/)
|
10
|
+
* Make common tasks easy; get out of your way if you need the metal
|
11
|
+
* Make working with [GeoCouch](http://github.com/vmx/couchdb) seamless
|
12
|
+
|
13
|
+
## Warnings
|
14
|
+
|
15
|
+
* This is pretty alpha at this point.
|
16
|
+
* Only tested on Ruby 1.9.2. I'm not intentionally breaking 1.8.x, but neither do I guarantee anything.
|
17
|
+
* The stuff about GeoCouch above was a goal I didn't get to yet.
|
18
|
+
|
19
|
+
## Help
|
20
|
+
|
21
|
+
* Accepted. Fork at
|
22
|
+
[Github](http://github.com/joshuamiller/loose_change)
|
23
|
+
|
24
|
+
## Shoulders of Giants
|
25
|
+
|
26
|
+
Inspiration and help from:
|
27
|
+
|
28
|
+
* [RestClient](http://github.com/archiloque/rest-client), for the
|
29
|
+
basic HTTP plumbing.
|
30
|
+
* [CouchRest](http://github.com/couchrest/couchrest), for the idea and
|
31
|
+
basic structure of using RestClient to talk to CouchDB.
|
32
|
+
* [CouchRest-Rails](http://github.com/hpoydar/couchrest-rails), for
|
33
|
+
implementation ideas for interacting with Rails.
|
34
|
+
* [will_paginate](http://github.com/mislave/will_paginate), for making
|
35
|
+
pagination a dead-simple addition.
|
36
|
+
|
37
|
+
## License
|
38
|
+
|
39
|
+
* MIT. See LICENSE for more details.
|
data/Rakefile
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler/setup'
|
3
|
+
|
4
|
+
require 'rake'
|
5
|
+
require 'rake/testtask'
|
6
|
+
|
7
|
+
Rake::TestTask.new(:test) do |t|
|
8
|
+
Bundler.require(:test)
|
9
|
+
t.pattern = 'test/**/*_test.rb'
|
10
|
+
t.verbose = true
|
11
|
+
end
|
12
|
+
|
13
|
+
begin
|
14
|
+
Bundler.require :development
|
15
|
+
Jeweler::Tasks.new do |gemspec|
|
16
|
+
gemspec.name = "loose_change"
|
17
|
+
gemspec.summary = "ActiveModel-compliant CouchDB ORM"
|
18
|
+
gemspec.email = "josh@joshinharrisburg.com"
|
19
|
+
gemspec.homepage = "http://github.com/joshuamiller/loose_change"
|
20
|
+
gemspec.authors = ["Joshua Miller"]
|
21
|
+
# Dependencies
|
22
|
+
gemspec.add_dependency 'activesupport', '~> 3.0.0'
|
23
|
+
gemspec.add_dependency 'activemodel', '~> 3.0.0'
|
24
|
+
gemspec.add_dependency 'rest-client', '~> 1.6.0'
|
25
|
+
gemspec.add_dependency 'json', '~> 1.4.6'
|
26
|
+
end
|
27
|
+
Jeweler::GemcutterTasks.new
|
28
|
+
rescue LoadError
|
29
|
+
puts "Jeweler not available."
|
30
|
+
end
|
31
|
+
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.3.1
|
data/lib/loose_change.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'active_support/core_ext'
|
3
|
+
require 'active_model'
|
4
|
+
require 'rest-client'
|
5
|
+
require 'json'
|
6
|
+
require 'will_paginate'
|
7
|
+
require 'will_paginate/collection'
|
8
|
+
|
9
|
+
module LooseChange
|
10
|
+
extend ActiveSupport::Autoload
|
11
|
+
|
12
|
+
autoload :Attributes, File.dirname(__FILE__) + '/loose_change/attributes'
|
13
|
+
autoload :Attachments, File.dirname(__FILE__) + '/loose_change/attachments'
|
14
|
+
autoload :Errors, File.dirname(__FILE__) + '/loose_change/errors'
|
15
|
+
autoload :Naming, File.dirname(__FILE__) + '/loose_change/naming'
|
16
|
+
autoload :Base, File.dirname(__FILE__) + '/loose_change/base'
|
17
|
+
autoload :Persistence, File.dirname(__FILE__) + '/loose_change/persistence'
|
18
|
+
autoload :Database, File.dirname(__FILE__) + '/loose_change/database'
|
19
|
+
autoload :Server, File.dirname(__FILE__) + '/loose_change/server'
|
20
|
+
autoload :Views, File.dirname(__FILE__) + '/loose_change/views'
|
21
|
+
autoload :Pagination, File.dirname(__FILE__) + '/loose_change/pagination'
|
22
|
+
autoload :Helpers, File.dirname(__FILE__) + '/loose_change/helpers'
|
23
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module LooseChange
|
2
|
+
|
3
|
+
module Attachments
|
4
|
+
end
|
5
|
+
|
6
|
+
module AttachmentClassMethods
|
7
|
+
|
8
|
+
# Attach a file to this model, to be stored inline in
|
9
|
+
# CouchDB. Note that the file will not actually be transferred
|
10
|
+
# to CouchDB until #save is called. The +name+ parameter will be
|
11
|
+
# used on CouchDB and in Loose Change to retrieve the attachment
|
12
|
+
# later. If you set the <tt>:content_type</tt> key in the optional +args+
|
13
|
+
# hash, that content-type will be set on the attachment in CouchDB
|
14
|
+
# and available when the model is retrieved.
|
15
|
+
#
|
16
|
+
# recipe = Recipe.create!(:name => "Lasagne")
|
17
|
+
# recipe.attach(:photo, File.open("lasagne.png"), :content_type
|
18
|
+
# => 'image/png'
|
19
|
+
# recipe.save
|
20
|
+
def attach(name, file, args = {})
|
21
|
+
attachment = args.merge :file => file, :dirty => true
|
22
|
+
@attachments = (@attachments || {}).merge(name => attachment)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Returns the file identified by +name+ on a Loose Change model
|
26
|
+
# instance, whether or not that file has been saved back to
|
27
|
+
# CouchDB. Will return nil if no attachment by that name exists.
|
28
|
+
def attachment(name)
|
29
|
+
return attachments[name][:file] if @attachments.try(:[], :name).try(:[], :file)
|
30
|
+
begin
|
31
|
+
result = retrieve_attachment(name)
|
32
|
+
@attachments = (@attachments || {}).merge(name => {:file => result[:file], :dirty => false, :content_type => result[:content_type]})
|
33
|
+
result[:file]
|
34
|
+
rescue RestClient::ResourceNotFound
|
35
|
+
nil
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Returns a hash composed of <tt>file</tt> and <tt>content_type</tt> as
|
40
|
+
# returned from CouchDB identified by +name+.
|
41
|
+
def retrieve_attachment(name)
|
42
|
+
{ :file => RestClient.get("#{ uri }/#{ name }"),
|
43
|
+
:content_type => JSON.parse(RestClient.get(uri))['_attachments']['name'] }
|
44
|
+
end
|
45
|
+
|
46
|
+
# Explicitly transfers an attachment to CouchDB without saving the
|
47
|
+
# rest of the model.
|
48
|
+
#
|
49
|
+
# recipe.attach(:photo, File.open("lasagne.png"), :content_type
|
50
|
+
# => 'image/png'
|
51
|
+
# recipe.put_attachment(:photo)
|
52
|
+
def put_attachment(name)
|
53
|
+
return unless attachments[name]
|
54
|
+
result = JSON.parse(RestClient.put("#{ uri }/#{ name }#{ '?rev=' + @_rev if @_rev }", attachments[name][:file], {:content_type => attachments[name][:content_type], :accept => 'text/json'}))
|
55
|
+
@_rev = result['rev']
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module LooseChange
|
2
|
+
|
3
|
+
module Attributes
|
4
|
+
|
5
|
+
# Set a field to be stored in a CouchDB document. Any
|
6
|
+
# JSON-encodable value can be used; there is no explicit typing.
|
7
|
+
# The <tt>:default</tt> option can be used to always store a value
|
8
|
+
# on documents where this property has not been set.
|
9
|
+
#
|
10
|
+
# class Recipe < LooseChange::Base
|
11
|
+
# property :name
|
12
|
+
# property :popularity, :default => 0
|
13
|
+
# end
|
14
|
+
def property(name, opts = {})
|
15
|
+
attr_accessor name.to_sym
|
16
|
+
self.properties = ((self.properties || []) << name.to_sym)
|
17
|
+
default(name.to_sym, opts[:default]) if opts[:default]
|
18
|
+
define_attribute_methods [name]
|
19
|
+
end
|
20
|
+
|
21
|
+
#:nodoc:
|
22
|
+
def default(property, value)
|
23
|
+
self.defaults ||= {}
|
24
|
+
self.defaults[property] = value
|
25
|
+
end
|
26
|
+
|
27
|
+
# Automatically set up <tt>:created_at</tt> and
|
28
|
+
# <tt>:updated_at</tt> properties which are set on creation and
|
29
|
+
# save, respectively.
|
30
|
+
def timestamps!
|
31
|
+
property :created_at
|
32
|
+
property :updated_at
|
33
|
+
|
34
|
+
before_create :touch_created_at
|
35
|
+
before_create :touch_updated_at
|
36
|
+
before_save :touch_updated_at
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
module AttributeClassMethods
|
41
|
+
|
42
|
+
# Returns a hash of property names and current values.
|
43
|
+
def attributes
|
44
|
+
(self.class.properties || []).inject({}) {|acc, key| acc[key] = send(key); acc}
|
45
|
+
end
|
46
|
+
|
47
|
+
# Change the value of a property and save the result to CouchDB.
|
48
|
+
def update_attribute(name, value)
|
49
|
+
send("#{name}=", value)
|
50
|
+
save
|
51
|
+
end
|
52
|
+
|
53
|
+
# Change multiple properties at once with a hash of property names
|
54
|
+
# and values, then save the result to CouchDB.
|
55
|
+
def update_attributes(args = {})
|
56
|
+
args.each do |name, value|
|
57
|
+
send("#{name}=", value)
|
58
|
+
end
|
59
|
+
save
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def touch_created_at
|
65
|
+
self.created_at = Time.now
|
66
|
+
end
|
67
|
+
|
68
|
+
def touch_updated_at
|
69
|
+
self.updated_at = Time.now
|
70
|
+
end
|
71
|
+
|
72
|
+
def apply_defaults
|
73
|
+
(self.class.defaults || {}).each do |property, value|
|
74
|
+
self.send("#{property}=", self.send(property) || value)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module LooseChange
|
2
|
+
class Base
|
3
|
+
|
4
|
+
include ActiveModel::AttributeMethods
|
5
|
+
include ActiveModel::Validations
|
6
|
+
include ActiveModel::Serialization
|
7
|
+
include ActiveModel::Serializers::JSON
|
8
|
+
include ActiveModel::Dirty
|
9
|
+
|
10
|
+
# Can't escape the following when trying to
|
11
|
+
# use a Callbacks module:
|
12
|
+
# undefined method `extlib_inheritable_reader' for LooseChange::Callbacks:Module
|
13
|
+
# so we'll throw it in here
|
14
|
+
extend ActiveModel::Callbacks
|
15
|
+
define_model_callbacks :create, :save, :destroy
|
16
|
+
|
17
|
+
extend Attributes
|
18
|
+
include AttributeClassMethods
|
19
|
+
extend Attachments
|
20
|
+
include AttachmentClassMethods
|
21
|
+
extend Naming
|
22
|
+
include NamingClassMethods
|
23
|
+
include Errors
|
24
|
+
extend Persistence
|
25
|
+
include PersistenceClassMethods
|
26
|
+
extend Views
|
27
|
+
extend Pagination
|
28
|
+
include Helpers
|
29
|
+
extend Helpers
|
30
|
+
|
31
|
+
class_inheritable_accessor :database, :properties, :defaults
|
32
|
+
attr_accessor :attachments
|
33
|
+
|
34
|
+
def to_key
|
35
|
+
persisted? ? [id] : nil
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_model
|
39
|
+
self
|
40
|
+
end
|
41
|
+
|
42
|
+
def to_param
|
43
|
+
(to_key && persisted?) ? to_key.join('-') : nil
|
44
|
+
end
|
45
|
+
|
46
|
+
def ==(other_model)
|
47
|
+
return false unless other_model && other_model.is_a?(self.class)
|
48
|
+
id == other_model.id &&
|
49
|
+
_rev == other_model._rev &&
|
50
|
+
!(changed? || other_model.changed?)
|
51
|
+
end
|
52
|
+
|
53
|
+
alias_method :eql?, :==
|
54
|
+
|
55
|
+
def hash
|
56
|
+
id.try(:hex) || super
|
57
|
+
end
|
58
|
+
|
59
|
+
def initialize(args = {})
|
60
|
+
@errors = ActiveModel::Errors.new(self)
|
61
|
+
@database = self.database
|
62
|
+
@new_record = true unless args['_id']
|
63
|
+
args.each {|property, value| self.send("#{property}=".to_sym, value)}
|
64
|
+
apply_defaults
|
65
|
+
self
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
LooseChange::Base.include_root_in_json = false
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
|
3
|
+
module LooseChange
|
4
|
+
class Database
|
5
|
+
include Helpers
|
6
|
+
extend Helpers
|
7
|
+
|
8
|
+
attr_reader :server, :name
|
9
|
+
|
10
|
+
# Build a new LooseChange::Database instance with a database named
|
11
|
+
# +name+. The +server+ is a URI string identifying the CouchDB
|
12
|
+
# server and port. If the database does not exist on the server,
|
13
|
+
# it will be created.
|
14
|
+
def initialize(name, server)
|
15
|
+
@name = name
|
16
|
+
@server = server
|
17
|
+
@uri = "/#{name.gsub('/','%2F')}"
|
18
|
+
create_database_unless_exists
|
19
|
+
end
|
20
|
+
|
21
|
+
def uri
|
22
|
+
server.uri + @uri
|
23
|
+
end
|
24
|
+
|
25
|
+
# Delete the database named +name+ on the server +server+.
|
26
|
+
def self.delete(name, server = "http://127.0.0.1:5984")
|
27
|
+
begin
|
28
|
+
RestClient.delete("#{ server }/#{ name }")
|
29
|
+
rescue RestClient::ResourceNotFound
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.setup_design(database, model_name)
|
34
|
+
begin
|
35
|
+
RestClient.get("#{ database.uri }/_design/#{ CGI.escape(model_name) }")
|
36
|
+
rescue
|
37
|
+
RestClient.put("#{ database.uri }/_design/#{ CGI.escape(model_name) }",
|
38
|
+
{ '_id' => "_design/#{ CGI.escape(model_name) }",
|
39
|
+
'language' => 'javascript'}.to_json, default_headers)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def create_database_unless_exists
|
46
|
+
unless JSON.parse(RestClient.get("#{server.uri}/_all_dbs", default_headers)).include?(name)
|
47
|
+
RestClient.put uri, "", default_headers
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
end
|