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 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
- == DESCRIPTION:
6
+ == Description:
6
7
 
7
- An ODM (Object/Document Mapper) for Couchdb. Still very much a work in progress
8
+ A CouchDB ODM (Object/Document Mapper) in Ruby.
8
9
 
9
- == FEATURES/PROBLEMS:
10
+ == Features:
10
11
 
11
- * Encourages Per-"Account" databases. This is both a feature and a problem, since classes cannot know which database to pull from you cannot do f.e. "Article.find('foo')" as Article doesn't know what database to use.
12
- * Does not provide it's own validators or callbacks. I have a solution in mind for this involving state machines.
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
- == REQUIREMENTS:
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
- * Johnson (and Spidermonkey, if you have CouchDB you have Spidermonkey)
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
@@ -1,4 +1,4 @@
1
1
  ---
2
2
  :major: 0
3
- :minor: 0
4
- :patch: 10
3
+ :minor: 2
4
+ :patch: 0
data/lib/exegesis.rb CHANGED
@@ -1,43 +1,36 @@
1
1
  require 'time'
2
2
  require 'pathname'
3
-
4
- require 'couchrest'
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 :Document, 'exegesis/document'
10
- autoload :Design, 'exegesis/design'
11
-
12
- extend self
13
-
14
- def designs_directory= dir
15
- @designs_directory = Pathname.new(dir)
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
- def design_file name
24
- File.read(designs_directory + name)
25
- end
19
+ autoload :Designs, 'exegesis/designs'
20
+ autoload :Design, 'exegesis/design'
26
21
 
27
- def database_template= template
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 database_for name
36
- database_template % name
24
+ def model_classes
25
+ @model_classes ||= {}
37
26
  end
38
27
 
39
- def document_classes
40
- @document_classes ||= Hash.new(Exegesis::Document)
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
@@ -1,83 +1,154 @@
1
- $:.unshift File.dirname(__FILE__)
2
- require 'design/design_docs'
3
-
1
+ require 'pathname'
4
2
  module Exegesis
5
3
  class Design
6
- include Exegesis::Design::DesignDocs
4
+ include Exegesis::Document
7
5
 
8
- attr_accessor :database
6
+ def self.design_directory= dir
7
+ @design_directory = Pathname.new(dir)
8
+ end
9
9
 
10
- def initialize(db)
11
- @database = db
10
+ def self.design_directory
11
+ @design_directory ||= Pathname.new("designs/#{design_name}")
12
12
  end
13
13
 
14
- def self.use_design_doc_name name
15
- @design_doc_name = name.to_s
14
+ def self.design_name= n
15
+ @design_name = n.to_s
16
16
  end
17
17
 
18
- def self.design_doc_name
19
- @design_doc_name ||= name.to_s.sub(/(Design)$/,'').downcase
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 design_doc_name
23
- self.class.design_doc_name
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 get(id)
27
- doc = Exegesis::Document.instantiate database.get(id)
28
- doc.database = self.database
29
- doc
32
+ def self.canonical_design
33
+ @canonical_design ||= {
34
+ '_id' => "_design/#{design_name}",
35
+ 'views' => {}
36
+ }
30
37
  end
31
38
 
32
- def parse_opts(opts={})
33
- if opts[:key]
34
- case opts[:key]
35
- when Range
36
- range = opts.delete(:key)
37
- opts.update({:startkey => range.first, :endkey => range.last})
38
- when Array
39
- if opts[:key].any?{|v| v.kind_of?(Range) }
40
- key = opts.delete(:key)
41
- opts[:startkey] = key.map {|v| v.kind_of?(Range) ? v.first : v }
42
- opts[:endkey] = key.map {|v| v.kind_of?(Range) ? v.last : v }
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 view view_name, opts={}
53
- opts = parse_opts opts
54
- return [] unless opts[:key] || opts[:startkey] || opts[:keys] || opts[:all]
55
- opts.delete(:all)
56
- database.view("#{design_doc_name}/#{view_name}", opts)['rows']
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
- def docs_for view_name, opts={}
60
- response = view view_name, opts.update({:include_docs => true})
61
- response.map do |doc|
62
- model = Exegesis::Document.instantiate doc['doc']
63
- model.database = database
64
- model
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 values_for view_name, opts={}
69
- response = view view_name, opts
70
- response.map {|row| row['value'] }
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 keys_for view_name, opts={}
74
- response = view view_name, opts
75
- response.map {|row| row['key'] }
107
+ def design_name
108
+ self.class.design_name
76
109
  end
77
110
 
78
- def ids_for view_name, opts={}
79
- response = view view_name, opts
80
- response.map {|row| row['id'] }
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