mattly-exegesis 0.0.10 → 0.2.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.
- data/LICENSE +22 -0
- data/README.rdoc +51 -7
- data/VERSION.yml +2 -2
- data/lib/exegesis.rb +22 -29
- data/lib/exegesis/database.rb +109 -0
- data/lib/exegesis/design.rb +123 -52
- data/lib/exegesis/document.rb +72 -132
- data/lib/exegesis/model.rb +142 -0
- data/lib/exegesis/server.rb +28 -0
- data/lib/exegesis/utils/http.rb +38 -0
- data/lib/monkeypatches/time.rb +5 -0
- data/test/database_test.rb +161 -0
- data/test/design_test.rb +154 -74
- data/test/document_test.rb +159 -0
- data/test/fixtures/designs/tags/views/by_tag/map.js +8 -0
- data/test/fixtures/designs/tags/views/by_tag/reduce.js +3 -0
- data/test/http_test.rb +79 -0
- data/test/model_test.rb +230 -0
- data/test/server_test.rb +26 -0
- data/test/test_helper.rb +12 -8
- metadata +25 -12
- data/lib/exegesis/design/design_docs.rb +0 -92
- data/test/design_doc_test.rb +0 -120
- data/test/document_class_definitions_test.rb +0 -284
- data/test/document_instance_methods_test.rb +0 -40
- data/test/exegesis_test.rb +0 -28
data/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
(The MIT License)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2009 Matthew Lyon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
'Software'), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
20
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
21
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
22
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
CHANGED
|
@@ -1,19 +1,63 @@
|
|
|
1
1
|
= exegesis
|
|
2
2
|
|
|
3
|
+
by Matthew Lyon <matt@flowerpowered.com>
|
|
3
4
|
* http://github.com/mattly/exegesis
|
|
4
5
|
|
|
5
|
-
==
|
|
6
|
+
== Description:
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
A CouchDB ODM (Object/Document Mapper) in Ruby.
|
|
8
9
|
|
|
9
|
-
==
|
|
10
|
+
== Features:
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
Encourages per-"Account" databases. Actually, does not even currently provide a way to do a
|
|
13
|
+
"singleton" or global database, however this is planned. Since a given class (say, "Article")
|
|
14
|
+
cannot know what database it is supposed to get/search from you cannot do classical class-based
|
|
15
|
+
finders such as "Article.find('value')".
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
CouchDB is table-less, and Exegesis's design reflects this. In CouchDB, Documents are retrieved
|
|
18
|
+
by their unique id, or can be queried from a view function in a design document. Exegesis provides
|
|
19
|
+
tools to aid this. Additionally, since view functions can be used for map/reduce computations against
|
|
20
|
+
your documents, Exegesis helps you get non-document data out of your views.
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
== Examples:
|
|
23
|
+
|
|
24
|
+
class Account
|
|
25
|
+
include Exegesis::Database
|
|
26
|
+
|
|
27
|
+
# declares the existence of a design document named 'articles'
|
|
28
|
+
# view functions will be loaded from 'views/articles/:viewname/*.js
|
|
29
|
+
design :articles do
|
|
30
|
+
docs :by_author
|
|
31
|
+
docs :at_path
|
|
32
|
+
docs :tagged_with
|
|
33
|
+
hash :tags_count, :view => :tagged_with
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@account.articles.by_author('user-mattly')
|
|
38
|
+
# performs GET '/_design/articles/_view/by_author?key="user-mattly"&include_docs=true&reduce=false'
|
|
39
|
+
@account.articles.at_path('blog/2009'..'blog/2009/04/04')
|
|
40
|
+
# transforms the range into startkey/endkey
|
|
41
|
+
# performs GET '/_design/articles/_view/at_path?startkey="blog/2009"&endkey="blog/2009/04/04"
|
|
42
|
+
# &include_docs=true&reduce=false'
|
|
43
|
+
@account.articles.tags_count('couchdb')
|
|
44
|
+
# performs GET '/_design/articles/_view/tagged_with?key="couchdb"&group=true'
|
|
45
|
+
|
|
46
|
+
class Article
|
|
47
|
+
include Exegesis::Document
|
|
48
|
+
|
|
49
|
+
# defines readers, writers for given attributes
|
|
50
|
+
expose :path, :title, :body, :tags
|
|
51
|
+
expose :published_at, :writer => false, :as => Time
|
|
52
|
+
timestamps!
|
|
53
|
+
|
|
54
|
+
# will load the document at the id referenced by doc['author']; does not yet set writer.
|
|
55
|
+
expose :author, :as => :reference
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
== Requirements:
|
|
59
|
+
|
|
60
|
+
* RestClient
|
|
17
61
|
|
|
18
62
|
For running the tests:
|
|
19
63
|
|
data/VERSION.yml
CHANGED
data/lib/exegesis.rb
CHANGED
|
@@ -1,43 +1,36 @@
|
|
|
1
1
|
require 'time'
|
|
2
2
|
require 'pathname'
|
|
3
|
-
|
|
4
|
-
require '
|
|
3
|
+
require 'restclient'
|
|
4
|
+
require 'json'
|
|
5
5
|
|
|
6
6
|
$:.unshift File.dirname(__FILE__) unless $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
|
|
7
7
|
|
|
8
|
+
require 'monkeypatches/time'
|
|
9
|
+
|
|
8
10
|
module Exegesis
|
|
9
|
-
autoload :
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def designs_directory
|
|
19
|
-
@designs_directory ||= Pathname.new(ENV["PWD"])
|
|
20
|
-
@designs_directory
|
|
21
|
-
end
|
|
11
|
+
autoload :Http, 'exegesis/utils/http'
|
|
12
|
+
|
|
13
|
+
autoload :Server, 'exegesis/server'
|
|
14
|
+
autoload :Database, 'exegesis/database'
|
|
15
|
+
|
|
16
|
+
autoload :Model, 'exegesis/model'
|
|
17
|
+
autoload :Document, 'exegesis/document'
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
end
|
|
19
|
+
autoload :Designs, 'exegesis/designs'
|
|
20
|
+
autoload :Design, 'exegesis/design'
|
|
26
21
|
|
|
27
|
-
|
|
28
|
-
@db_template = template
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def database_template
|
|
32
|
-
@db_template ||= "http://localhost:5984/%s"
|
|
33
|
-
end
|
|
22
|
+
extend self
|
|
34
23
|
|
|
35
|
-
def
|
|
36
|
-
|
|
24
|
+
def model_classes
|
|
25
|
+
@model_classes ||= {}
|
|
37
26
|
end
|
|
38
27
|
|
|
39
|
-
def
|
|
40
|
-
|
|
28
|
+
def instantiate hash, database=nil
|
|
29
|
+
return nil if hash.nil?
|
|
30
|
+
klass = model_classes[hash['class']]
|
|
31
|
+
obj = klass.nil? ? hash : klass.new(hash)
|
|
32
|
+
obj.database = database if obj.respond_to?(:database=)
|
|
33
|
+
obj
|
|
41
34
|
end
|
|
42
35
|
|
|
43
36
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
require 'pathname'
|
|
2
|
+
module Exegesis
|
|
3
|
+
module Database
|
|
4
|
+
|
|
5
|
+
VALID_NAME_PATTERN = '[-a-z0-9_\$\(\)\+\/]+'
|
|
6
|
+
|
|
7
|
+
def self.included base
|
|
8
|
+
base.send :attr_accessor, :server, :uri
|
|
9
|
+
base.send :include, InstanceMethods
|
|
10
|
+
base.extend ClassMethods
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module ClassMethods
|
|
14
|
+
def designs_directory dir=nil
|
|
15
|
+
if dir
|
|
16
|
+
@designs_directory = Pathname.new(dir)
|
|
17
|
+
else
|
|
18
|
+
@designs_directory ||= Pathname.new('designs')
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A hash mapping design names to class names.
|
|
23
|
+
def designs
|
|
24
|
+
@designs ||= {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# declare a design document for this database. Creates a new class and yields a given block to the class to
|
|
28
|
+
# configure the design document and declare views; See Class methods for Exegesis::Design
|
|
29
|
+
def design design_name, opts={}, &block
|
|
30
|
+
klass_name = "#{design_name.to_s.capitalize}Design"
|
|
31
|
+
klass = const_set(klass_name, Class.new(Exegesis::Design))
|
|
32
|
+
designs[design_name] = klass
|
|
33
|
+
klass.design_directory = opts[:directory] || self.designs_directory + design_name.to_s
|
|
34
|
+
klass.design_name = opts[:name] || design_name.to_s
|
|
35
|
+
klass.compose_canonical
|
|
36
|
+
klass.class_eval &block
|
|
37
|
+
define_method design_name do
|
|
38
|
+
@designs ||= {}
|
|
39
|
+
@designs[design_name] ||= klass.new(self)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
module InstanceMethods
|
|
45
|
+
# Create a Database adapter for the given server and database name. Will raise
|
|
46
|
+
# RestClient::ResourceNotFound if the database does not exist.
|
|
47
|
+
def initialize server, database_name=nil
|
|
48
|
+
if database_name.nil?
|
|
49
|
+
if server.match(/\A(https?:\/\/[-0-9a-z\.]+(?::\d+))\/(#{Exegesis::Database::VALID_NAME_PATTERN})\Z/)
|
|
50
|
+
@server = Exegesis::Server.new($1)
|
|
51
|
+
database_name = $2
|
|
52
|
+
elsif server.match(/\A#{Exegesis::Database::VALID_NAME_PATTERN}\Z/)
|
|
53
|
+
@server = Exegesis::Server.new #localhost
|
|
54
|
+
database_name = server
|
|
55
|
+
else
|
|
56
|
+
raise "Not a valid database url or name"
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
@server = server
|
|
60
|
+
end
|
|
61
|
+
@uri = "#{@server.uri}/#{database_name}"
|
|
62
|
+
@server.get @uri # raise RestClient::ResourceNotFound if the database does not exist
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def raw_get id, options={}
|
|
66
|
+
keys = options.delete(:keys)
|
|
67
|
+
url = Exegesis::Http.format_url "#{@uri}/#{id}", options
|
|
68
|
+
if id.match(%r{^_design/.*/_view/.*$}) && keys
|
|
69
|
+
Exegesis::Http.post url, {:keys => keys}
|
|
70
|
+
else
|
|
71
|
+
Exegesis::Http.get url
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# GETs a document with the given id from the database
|
|
76
|
+
def get id, opts={}
|
|
77
|
+
Exegesis.instantiate raw_get(id), self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# saves a document or collection thereof
|
|
81
|
+
def save docs
|
|
82
|
+
if docs.is_a?(Array)
|
|
83
|
+
post "_bulk_docs", { 'docs' => docs }
|
|
84
|
+
else
|
|
85
|
+
result = docs['_id'].nil? ? post(docs) : put(docs['_id'], docs)
|
|
86
|
+
if result['ok']
|
|
87
|
+
docs['_id'] = result['id']
|
|
88
|
+
docs['_rev'] = result['rev']
|
|
89
|
+
end
|
|
90
|
+
docs
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# PUTs the body to the given id in the database
|
|
95
|
+
def put id, body
|
|
96
|
+
Exegesis::Http.put "#{@uri}/#{id}", body
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# POSTs the body to the database
|
|
100
|
+
def post url, body={}
|
|
101
|
+
if body.is_a?(Hash) && body.empty?
|
|
102
|
+
body = url
|
|
103
|
+
url = ''
|
|
104
|
+
end
|
|
105
|
+
Exegesis::Http.post "#{@uri}/#{url}", body
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/exegesis/design.rb
CHANGED
|
@@ -1,83 +1,154 @@
|
|
|
1
|
-
|
|
2
|
-
require 'design/design_docs'
|
|
3
|
-
|
|
1
|
+
require 'pathname'
|
|
4
2
|
module Exegesis
|
|
5
3
|
class Design
|
|
6
|
-
include Exegesis::
|
|
4
|
+
include Exegesis::Document
|
|
7
5
|
|
|
8
|
-
|
|
6
|
+
def self.design_directory= dir
|
|
7
|
+
@design_directory = Pathname.new(dir)
|
|
8
|
+
end
|
|
9
9
|
|
|
10
|
-
def
|
|
11
|
-
@
|
|
10
|
+
def self.design_directory
|
|
11
|
+
@design_directory ||= Pathname.new("designs/#{design_name}")
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def self.
|
|
15
|
-
@
|
|
14
|
+
def self.design_name= n
|
|
15
|
+
@design_name = n.to_s
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def self.
|
|
19
|
-
@
|
|
18
|
+
def self.design_name
|
|
19
|
+
@design_name ||= name.scan(/^(?:[A-Za-z0-9\:]+::)([A-Za-z0-9]+)Design$/).first.first.downcase
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def
|
|
23
|
-
|
|
22
|
+
def self.compose_canonical
|
|
23
|
+
Dir[design_directory + 'views' + '**/*.js'].each do |view_func|
|
|
24
|
+
path = view_func.split('/')
|
|
25
|
+
func = path.pop.sub(/\.js$/,'')
|
|
26
|
+
name = path.pop
|
|
27
|
+
canonical_design['views'][name] ||= {}
|
|
28
|
+
canonical_design['views'][name][func] = File.read(view_func)
|
|
29
|
+
end
|
|
24
30
|
end
|
|
25
31
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
def self.canonical_design
|
|
33
|
+
@canonical_design ||= {
|
|
34
|
+
'_id' => "_design/#{design_name}",
|
|
35
|
+
'views' => {}
|
|
36
|
+
}
|
|
30
37
|
end
|
|
31
38
|
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
def self.view name, default_options={}
|
|
40
|
+
define_method name do |key, *opts|
|
|
41
|
+
view name, key, opts.first, default_options
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.docs name, default_options={}
|
|
46
|
+
default_options = {:include_docs => true, :reduce => false}.merge(default_options)
|
|
47
|
+
define_method name do |*opts|
|
|
48
|
+
key = opts.shift
|
|
49
|
+
options = parse_opts key, opts.first, default_options
|
|
50
|
+
response = call_view name, options
|
|
51
|
+
ids = []
|
|
52
|
+
response.inject([]) do |memo, doc|
|
|
53
|
+
unless ids.include?(doc['id'])
|
|
54
|
+
ids << doc['id']
|
|
55
|
+
memo << Exegesis.instantiate(doc['doc'], database)
|
|
43
56
|
end
|
|
57
|
+
memo
|
|
44
58
|
end
|
|
45
|
-
elsif opts[:keys] && opts[:keys].empty?
|
|
46
|
-
opts.delete(:keys)
|
|
47
59
|
end
|
|
48
|
-
|
|
49
|
-
opts
|
|
50
60
|
end
|
|
51
61
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
opts
|
|
56
|
-
|
|
62
|
+
def self.hash name, default_options={}
|
|
63
|
+
default_options = {:group => true}.merge(default_options)
|
|
64
|
+
view_name = default_options.delete(:view) || name
|
|
65
|
+
define_method name do |*opts|
|
|
66
|
+
key = opts.shift
|
|
67
|
+
options = parse_opts key, opts.first, default_options
|
|
68
|
+
options.delete(:group) if options[:key]
|
|
69
|
+
|
|
70
|
+
response = call_view view_name, options
|
|
71
|
+
if response.size == 1 && response.first['key'].nil?
|
|
72
|
+
response.first['value']
|
|
73
|
+
else
|
|
74
|
+
response.inject({}) do |memo, row|
|
|
75
|
+
if ! memo.has_key?(row['key'])
|
|
76
|
+
memo[row['key']] = row['value']
|
|
77
|
+
end
|
|
78
|
+
memo
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
57
82
|
end
|
|
58
83
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
84
|
+
|
|
85
|
+
def initialize db
|
|
86
|
+
begin
|
|
87
|
+
super db.get("_design/#{design_name}"), db
|
|
88
|
+
rescue RestClient::ResourceNotFound
|
|
89
|
+
db.put("_design/#{design_name}", self.class.canonical_design)
|
|
90
|
+
retry
|
|
65
91
|
end
|
|
92
|
+
unless self['views'] == self.class.canonical_design['views']
|
|
93
|
+
self['views'].update(self.class.canonical_design['views'])
|
|
94
|
+
save
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def view name, key=nil, opts={}
|
|
99
|
+
call_view name, parse_opts(key, opts)
|
|
66
100
|
end
|
|
67
101
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
|
|
102
|
+
def call_view name, opts={}
|
|
103
|
+
url = "_design/#{design_name}/_view/#{name}"
|
|
104
|
+
database.raw_get(url, opts)['rows']
|
|
71
105
|
end
|
|
72
106
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
response.map {|row| row['key'] }
|
|
107
|
+
def design_name
|
|
108
|
+
self.class.design_name
|
|
76
109
|
end
|
|
77
110
|
|
|
78
|
-
def
|
|
79
|
-
|
|
80
|
-
|
|
111
|
+
def parse_opts key, opts={}, defaults={}
|
|
112
|
+
opts = straighten_args key, opts, defaults
|
|
113
|
+
parse_key opts
|
|
114
|
+
parse_keys opts
|
|
115
|
+
parse_range opts
|
|
116
|
+
opts
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def straighten_args key, opts, defaults
|
|
122
|
+
opts ||= {}
|
|
123
|
+
if key.is_a?(Hash)
|
|
124
|
+
opts = key
|
|
125
|
+
elsif ! key.nil?
|
|
126
|
+
opts[:key] = key
|
|
127
|
+
end
|
|
128
|
+
defaults.merge(opts)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_key opts
|
|
132
|
+
if opts[:key]
|
|
133
|
+
if opts[:key].is_a?(Range)
|
|
134
|
+
range = opts.delete(:key)
|
|
135
|
+
opts.update({:startkey => range.first, :endkey => range.last})
|
|
136
|
+
elsif opts[:key].is_a?(Array) && opts[:key].any?{|v| v.kind_of?(Range) }
|
|
137
|
+
key = opts.delete(:key)
|
|
138
|
+
opts[:startkey] = key.map {|v| v.kind_of?(Range) ? v.first : v }
|
|
139
|
+
opts[:endkey] = key.map {|v| v.kind_of?(Range) ? v.last : v }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def parse_keys opts
|
|
145
|
+
opts.delete(:keys) if opts[:keys] && opts[:keys].empty?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def parse_range opts
|
|
149
|
+
if opts[:startkey] || opts[:endkey]
|
|
150
|
+
raise ArgumentError, "both a startkey and endkey must be specified if either is" unless opts[:startkey] && opts[:endkey]
|
|
151
|
+
end
|
|
81
152
|
end
|
|
82
153
|
|
|
83
154
|
end
|