dm-fluiddb-adapter 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
@@ -0,0 +1,5 @@
1
+ *.sw?
2
+ .DS_Store
3
+ coverage
4
+ rdoc
5
+ pkg
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Jordan Curzon
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.
@@ -0,0 +1,56 @@
1
+ = dm-fluiddb-adapter
2
+
3
+ This is a DataMapper adapter for FluidDB (http://www.fluidinfo.com)
4
+
5
+ It makes heavy use of memcache and uses Typhoeus to parallelize
6
+ fetching tag values. TyphoeusClient can also be used independently
7
+ to make low-level requests.
8
+
9
+ The connect url for the adapter is:
10
+
11
+ fluiddb://test:test@sandbox.fluidinfo.com/test
12
+
13
+ That will connect to the sandbox instance of fluiddb as the test user
14
+ with test password and use the subnamespace "test".
15
+
16
+ It creates a namespace for each datamapper resource and a tag in that
17
+ namespace for each property. It creates a tag with the same name as
18
+ the namespace which it refers to as the 'identity tag' and tags all
19
+ the objects for that resoruce with it.
20
+
21
+ When you delete an object it removes the identity tag and adds a
22
+ 'deleted' tag from the resource's namespace to the object. The
23
+ 'deleted' tag is to help with garbage collection although garbage
24
+ collection is not implemented by the adapter. The reasoning is that
25
+ you may want to use custom tags for properties and use objects/tags
26
+ between multiple tables/views.
27
+
28
+ The about tag is not currently supported, but is on the short list for
29
+ features as is being able to customize or make optional the identity
30
+ tag and the corresponding 'identity query' which is the fluidb query
31
+ used to find the entire set of objects belonging to the datamapper
32
+ resource. 'Serial' properties are not supported and resources require
33
+ a column named 'id', type String, and marked with ':key => true'.
34
+
35
+ You can get access to the underlying http client for making direct requests
36
+ by calling:
37
+
38
+ DataMapper.repository(:default).adapter.http
39
+
40
+ The advantage to using the adapter's http instance is that Typhoeus has
41
+ to create a pool of handles to libCURL and it creates them on demand.
42
+ They are a little slow to start up, so it's better to use a warmed up
43
+ instance of the http client.
44
+
45
+
46
+ == TODO
47
+
48
+ * Add support for the 'about' fluiddb tag at creation.
49
+ * Enable setting and/or removing the identity tag and query.
50
+ * Add Set property type
51
+ * Add Tag property type
52
+ * Add range support for querying date attributes
53
+
54
+ == Copyright
55
+
56
+ Copyright (c) 2009 Jordan Curzon. See LICENSE for details.
@@ -0,0 +1,52 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "dm-fluiddb-adapter"
8
+ gem.summary = %Q{A DataMapper adapter for FluidDB}
9
+ gem.description = %Q{This is a DataMapper adapter for FluidDB (www.fluidinfo.com)\nIt makes heavy use of memcache and uses Typhoeus to parallelize fetching tag values. TyphoeusClient can also be used independently to make low-level requests.}
10
+ gem.email = "curzonj@gmail.com"
11
+ gem.homepage = "http://github.com/curzonj/dm-fluiddb-adapter"
12
+ gem.authors = ["Jordan Curzon"]
13
+ gem.add_dependency "dm-core"
14
+ gem.add_dependency "typhoeus"
15
+ gem.add_development_dependency "rspec"
16
+ gem.add_development_dependency "memcached"
17
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
18
+ end
19
+ Jeweler::GemcutterTasks.new
20
+ rescue LoadError
21
+ puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
22
+ end
23
+
24
+ require 'spec/rake/spectask'
25
+ Spec::Rake::SpecTask.new(:spec) do |spec|
26
+ spec.libs << 'lib' << 'spec'
27
+ spec.spec_files = FileList['spec/**/*_spec.rb']
28
+ end
29
+
30
+ Spec::Rake::SpecTask.new(:rcov) do |spec|
31
+ spec.libs << 'lib' << 'spec'
32
+ spec.pattern = 'spec/**/*_spec.rb'
33
+ spec.rcov = true
34
+ end
35
+
36
+ task :spec => :check_dependencies
37
+
38
+ task :default => :spec
39
+
40
+ require 'rake/rdoctask'
41
+ Rake::RDocTask.new do |rdoc|
42
+ if File.exist?('VERSION')
43
+ version = File.read('VERSION')
44
+ else
45
+ version = ""
46
+ end
47
+
48
+ rdoc.rdoc_dir = 'rdoc'
49
+ rdoc.title = "dm-fluiddb-adapter #{version}"
50
+ rdoc.rdoc_files.include('README*')
51
+ rdoc.rdoc_files.include('lib/**/*.rb')
52
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,71 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run `rake gemspec`
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{dm-fluiddb-adapter}
8
+ s.version = "0.1.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Jordan Curzon"]
12
+ s.date = %q{2009-11-08}
13
+ s.description = %q{This is a DataMapper adapter for FluidDB (www.fluidinfo.com)
14
+ It makes heavy use of memcache and uses Typhoeus to parallelize fetching tag values. TyphoeusClient can also be used independently to make low-level requests.}
15
+ s.email = %q{curzonj@gmail.com}
16
+ s.extra_rdoc_files = [
17
+ "LICENSE",
18
+ "README.rdoc"
19
+ ]
20
+ s.files = [
21
+ ".document",
22
+ ".gitignore",
23
+ "LICENSE",
24
+ "README.rdoc",
25
+ "Rakefile",
26
+ "VERSION",
27
+ "dm-fluiddb-adapter.gemspec",
28
+ "lib/dm-fluiddb-adapter/fixes.rb",
29
+ "lib/dm-fluiddb-adapter/typhoeus_client.rb",
30
+ "lib/fluiddb_adapter.rb",
31
+ "lib/loggable.rb",
32
+ "spec/fluiddb_adapter_spec.rb",
33
+ "spec/integration_spec.rb",
34
+ "spec/spec.opts",
35
+ "spec/spec_helper.rb",
36
+ "spec/typhoeus_client_spec.rb"
37
+ ]
38
+ s.homepage = %q{http://github.com/curzonj/dm-fluiddb-adapter}
39
+ s.rdoc_options = ["--charset=UTF-8"]
40
+ s.require_paths = ["lib"]
41
+ s.rubygems_version = %q{1.3.5}
42
+ s.summary = %q{A DataMapper adapter for FluidDB}
43
+ s.test_files = [
44
+ "spec/spec_helper.rb",
45
+ "spec/integration_spec.rb",
46
+ "spec/typhoeus_client_spec.rb",
47
+ "spec/fluiddb_adapter_spec.rb"
48
+ ]
49
+
50
+ if s.respond_to? :specification_version then
51
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
52
+ s.specification_version = 3
53
+
54
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
55
+ s.add_runtime_dependency(%q<dm-core>, [">= 0"])
56
+ s.add_runtime_dependency(%q<typhoeus>, [">= 0"])
57
+ s.add_development_dependency(%q<rspec>, [">= 0"])
58
+ s.add_development_dependency(%q<memcached>, [">= 0"])
59
+ else
60
+ s.add_dependency(%q<dm-core>, [">= 0"])
61
+ s.add_dependency(%q<typhoeus>, [">= 0"])
62
+ s.add_dependency(%q<rspec>, [">= 0"])
63
+ s.add_dependency(%q<memcached>, [">= 0"])
64
+ end
65
+ else
66
+ s.add_dependency(%q<dm-core>, [">= 0"])
67
+ s.add_dependency(%q<typhoeus>, [">= 0"])
68
+ s.add_dependency(%q<rspec>, [">= 0"])
69
+ s.add_dependency(%q<memcached>, [">= 0"])
70
+ end
71
+ end
@@ -0,0 +1,31 @@
1
+ module DataMapper
2
+ module Resource
3
+ def original_attributes
4
+ if frozen?
5
+ {}
6
+ else
7
+ @original_attributes ||= {}
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+ module Enumerable
14
+ def map_hash(&block)
15
+ hash = {}
16
+
17
+ list = map(&block)
18
+
19
+ list.each do |result|
20
+ if result.is_a?(Array)
21
+ hash[result.first] = result.last
22
+ elsif result.is_a?(Hash)
23
+ hash.merge!(result)
24
+ else
25
+ hash[result] = result
26
+ end
27
+ end
28
+
29
+ hash
30
+ end
31
+ end
@@ -0,0 +1,126 @@
1
+ require 'typhoeus'
2
+ require 'loggable'
3
+ require 'base64'
4
+ require 'json'
5
+
6
+ class TyphoeusClient
7
+ include Loggable
8
+
9
+ DEFAULT_HEADERS = {'Content-Type' => 'application/json', 'Accept' => '*/*'}
10
+ TIMEOUT = 10000
11
+
12
+ def initialize(host='sandbox.fluidinfo.com', username='test', password='test')
13
+ @host = host
14
+ @username = username
15
+ @password = password
16
+
17
+ @bulk = false
18
+ end
19
+
20
+ def parallel
21
+ if in_parallel?
22
+ yield
23
+ else
24
+ @bulk = true
25
+ yield
26
+ hydra.run
27
+ @bulk = false
28
+ end
29
+ end
30
+
31
+ [ :get, :post, :delete ].each do |method|
32
+ class_eval %{
33
+ def #{method}(uri, params=nil, payload=nil, headers={}, &block)
34
+ request(:#{method}, uri, params, payload, headers, &block)
35
+ end
36
+ }, __FILE__, __LINE__
37
+ end
38
+
39
+ def put(uri, params=nil, payload=nil, headers={}, &block)
40
+ if payload && payload.respond_to?(:read) && payload.respond_to?(:size) && headers['Content-Type']
41
+ streaming_put(uri, payload, headers, &block)
42
+ else
43
+ request(:put, uri, params, payload, headers, &block)
44
+ end
45
+ end
46
+
47
+ def streaming_post(uri, payload, headers, &block)
48
+ raise NotImplementedError
49
+ end
50
+
51
+ def request(method, path, params=nil, payload=nil, headers={}, &block)
52
+ headers = DEFAULT_HEADERS.merge(headers)
53
+ headers["Authorization"] = "Basic #{Base64.encode64("#{@username}:#{@password}")}".strip
54
+
55
+ opts = {
56
+ :method => method,
57
+ :params => params,
58
+ :headers => headers,
59
+ :timeout => TIMEOUT
60
+ }
61
+
62
+ opts[:params] ||= {} if method == :post
63
+
64
+ if payload
65
+ # We already merged the Content-Type into the headers
66
+ if headers['Content-Type'] == 'application/json'
67
+ opts[:body] = payload.to_json
68
+ else
69
+ opts[:body] = payload.to_s
70
+ end
71
+ end
72
+
73
+ url = 'http://' + @host + path
74
+ req = Typhoeus::Request.new(url, opts)
75
+
76
+ if in_parallel?
77
+ req.on_complete {|r| response(req, r, &block) }
78
+ hydra.queue req
79
+ req
80
+ else
81
+ hydra.queue req
82
+ hydra.run
83
+ response(req, req.response, &block)
84
+ end
85
+ end
86
+
87
+ def response(request, response)
88
+ raise "Failed to connect to #{request.url}" if response.code == 0
89
+
90
+ headers = {}
91
+ response.headers.split("\r\n").each {|header|
92
+ if header =~ /^(.+?): (.*)$/
93
+ headers[$1] = $2
94
+ end
95
+ }
96
+ response.instance_variable_set("@headers", headers)
97
+
98
+ unless (200..299).include?(response.code)
99
+ logger.warn "(#{response.code}) #{request.method.to_s.capitalize} #{request.url} -- #{response.headers.inspect}"
100
+ end
101
+
102
+ body = if headers['Content-Type'] == "application/json"
103
+ JSON.parse(response.body)
104
+ elsif headers['Content-Type'] == "application/vnd.fluiddb.value+json"
105
+ JSON.parse('[' + response.body + ']').first
106
+ else
107
+ response.body
108
+ end
109
+ response.instance_variable_set("@body", body)
110
+
111
+ if block_given?
112
+ yield response
113
+ else
114
+ response
115
+ end
116
+ end
117
+
118
+ def in_parallel?
119
+ @bulk == true
120
+ end
121
+
122
+ def hydra
123
+ @hydra ||= Typhoeus::Hydra.new
124
+ end
125
+ end
126
+
@@ -0,0 +1,611 @@
1
+ require 'dm-core'
2
+ require 'dm-core/adapters/abstract_adapter'
3
+
4
+ require 'forwardable'
5
+ require 'digest/sha1'
6
+
7
+ require 'dm-fluiddb-adapter/typhoeus_client'
8
+ require 'dm-fluiddb-adapter/fixes'
9
+
10
+ module DataMapper
11
+ module Adapters
12
+ class FluidDBAdapter < AbstractAdapter
13
+ extend Forwardable
14
+
15
+ PRIMITIVE_HEADERS = { 'Content-Type' => 'application/vnd.fluiddb.value+json' }
16
+ # TODO split this into a TTL for tag values, and a TTL for objects
17
+ CACHE_TTL = 600
18
+
19
+ attr_reader :http
20
+ def_delegators :http, :get, :post, :put, :parallel
21
+
22
+ def initialize(*args)
23
+ super(*args)
24
+
25
+ raise "Authentication required" if @options['user'].nil? || @options['password'].nil?
26
+ @http = TyphoeusClient.new(@options['host'], @options['user'], @options['password'])
27
+ end
28
+
29
+ def logger
30
+ DataMapper.logger
31
+ end
32
+
33
+ class << self
34
+ attr_writer :cache
35
+
36
+ def cache
37
+ return nil unless (@cache || defined? CACHE)
38
+
39
+ @cache ||= CACHE
40
+ end
41
+ end
42
+
43
+ def cache
44
+ self.class.cache
45
+ end
46
+
47
+ def create(resources)
48
+ parallel {
49
+ resources.each {|resource|
50
+ # TODO add about support
51
+ post("/objects", {}) {|resp|
52
+ if resp.code == 201
53
+ resource.id = resp.body['id']
54
+ save(resource)
55
+ else
56
+ raise "Failed to create object: #{resp.code}"
57
+ end
58
+ }
59
+ }
60
+ }
61
+ end
62
+
63
+ def update(attributes, collection)
64
+ attributes = attributes.map_hash do |property, value|
65
+ [ tag_name(property), value ]
66
+ end
67
+ list = query_ids(collection.query)
68
+
69
+ parallel do
70
+ list.each do |id|
71
+ attributes.each do |tag, value|
72
+ set_primitive_tag(id, tag, value)
73
+ end
74
+ end
75
+ end
76
+
77
+ list.each {|id| expire id }.size
78
+ end
79
+
80
+ # TODO is there a way we can leave the objects
81
+ # around?
82
+ def delete(collection)
83
+ model = collection.query.model
84
+ count = 0
85
+
86
+ parallel do
87
+ # It's unlikely that this method will receive invalid
88
+ # ids, so don't check them. It's not a big deal if they're
89
+ # wrong because we're not returning resource objects.
90
+ query_ids(collection.query, false) do |ids|
91
+ count = ids.size
92
+ ids.each do |id|
93
+ set_primitive_tag(id, deleted_tag(model))
94
+
95
+ identity_tag = identity_tag(model)
96
+ remove_tag(id, identity_tag, false) if identity_tag
97
+
98
+ # We don't set the existance false, because the delete
99
+ # may not mean anything if model doesn't use an identity
100
+ # tag
101
+ expire "existance:#{id}"
102
+ end
103
+ end
104
+ end
105
+
106
+ count
107
+ end
108
+
109
+ # TODO move this to the given resource
110
+ def remove_tag(id, tag, expire=true)
111
+ key = "#{id}/#{tag}"
112
+ @http.delete "/objects/#{key}"
113
+ expire(key) if expire
114
+ end
115
+
116
+ def read(query)
117
+ blocking_request
118
+ ids = query_ids(query)
119
+ fields = query_fields(query)
120
+
121
+ raise "Blank ids" if ids.any? {|id| id == '' || id.nil? }
122
+
123
+ list = fetch_fields(ids, fields, !query.reload?)
124
+
125
+ query.filter_records(list.values)
126
+ end
127
+
128
+ module Migration
129
+
130
+ def storage_exists?(storage_name)
131
+ check_namespace "#{tag_prefix}/#{storage_name}"
132
+ # TODO error handling in here
133
+
134
+ true
135
+ end
136
+
137
+ def create_model_storage(model)
138
+ identity_tag = identity_tag(model)
139
+ check_tag(identity_tag) if identity_tag
140
+
141
+ check_tag(deleted_tag(model))
142
+ model.properties.each {|field| check_dm_field(field) unless field.name == :id }
143
+ end
144
+ alias upgrade_model_storage create_model_storage
145
+
146
+ def check_tag(tag, description=nil, indexed=true)
147
+ blocking_request
148
+ description ||= "DataMapper Created Namespace"
149
+ match = tag.match(/^(.+)\/([^\/]+)/)
150
+ space = match[1]
151
+ tag_name = match[2]
152
+ check_namespace(space)
153
+
154
+ url = "/tags/#{tag}"
155
+ cached = cache_get('check:'+url)
156
+
157
+ if cached != description
158
+ if cached.nil?
159
+ resp = get(url, :returnDescription => true)
160
+ if resp.code != 200
161
+ post("/tags/#{space}", nil, :name => tag_name, :description => description, :indexed => indexed)
162
+ end
163
+ else
164
+ # An error here is unimportant, it's just the description
165
+ put(url, nil, :description => description)
166
+ end
167
+
168
+ cache_set('check:'+url, description, 3600)
169
+ end
170
+ end
171
+
172
+ def check_namespace(namespace, description=nil)
173
+ blocking_request
174
+
175
+ description ||= "DataMapper Created Namespace"
176
+ spaces = namespace.split('/')
177
+ spaces.inject('') do |prefix, name|
178
+ url = "/namespaces#{prefix}/#{name}"
179
+ cached = cache_get('check:'+url)
180
+
181
+ if cached != description
182
+ if cached.nil?
183
+ resp = get(url, :returnDescription => true)
184
+ if resp.code != 200
185
+ resp = post("/namespaces#{prefix}", nil, :name => name, :description => description)
186
+ raise "Failed (#{resp.code}) to create namespace #{prefix}" if resp.code != 201
187
+ end
188
+ elsif prefix != ''
189
+ # Don't try to change the description on top level namespaces
190
+ # An error here is unimportant, it's just the description
191
+ put(url, nil, :description => description)
192
+ end
193
+
194
+ cache_set('check:'+url, description, 3600)
195
+ end
196
+
197
+ prefix + '/' + name
198
+ end
199
+ end
200
+
201
+ module SQL
202
+ def supports_serial?
203
+ false
204
+ end
205
+ end
206
+
207
+ include SQL
208
+
209
+ end # module Migration
210
+
211
+ include Migration
212
+
213
+ private
214
+
215
+ def query_ids(query, validate_ids=true, &block)
216
+ conditions = identity_conditions(query)
217
+ query_string = conditions_statement(conditions)
218
+
219
+ # It's not a good idea to mix ids in your query with other conditions
220
+ unless (list = valid_id_list(query.model, query_string, validate_ids))
221
+ raise "Can't run an empty query" if query_string.strip == ''
222
+ fluiddb_query query_string do |list|
223
+ preturn(apply_limits(list, query), &block)
224
+ end
225
+ else
226
+ preturn(list, &block)
227
+ end
228
+ end
229
+
230
+ def valid_id_list(model, query_string, validate=true)
231
+ list = query_string.scan(/\|id:(.+?)\|/).flatten
232
+ unless list.empty?
233
+ # Sometimes we don't need to validate the ids
234
+ return list unless validate
235
+ # We can't do joins so, we have to go into this
236
+ # synchronously
237
+ blocking_request
238
+
239
+ parallel do
240
+ list.each_with_index do |id, index|
241
+ is_valid_object_id?(model, id) do |valid|
242
+ # we can't modify the array itself until
243
+ # we are synchronous again, so just nil the
244
+ # invalid index and we'll compact it later
245
+ list[index] = nil unless valid
246
+ end
247
+ end
248
+ end
249
+
250
+ list.compact
251
+ else
252
+ false
253
+ end
254
+ end
255
+
256
+ def is_valid_object_id?(model, id, &block)
257
+ # We need an alternate key to cache existance lookups because
258
+ # we are not caching objects which require the included fields
259
+ # to be specified
260
+ alt_key = "existance:#{id}"
261
+
262
+ exists = cache_get(alt_key)
263
+ if !exists.nil?
264
+ preturn(exists, &block)
265
+ else
266
+ get "/objects/#{id}" do |resp|
267
+ identity_tag = identity_tag(model)
268
+ valid = (resp.code == 200 && (identity_tag.nil? || resp.body['tagPaths'].include?(identity_tag)))
269
+
270
+ cache_set(alt_key, valid)
271
+ preturn(valid, &block)
272
+ end
273
+ end
274
+ end
275
+
276
+ # TODO is there a way to include the identity query in the generated conditions by default?
277
+ def identity_conditions(query)
278
+ identity_query = identity_query(query.model)
279
+ return query.conditions if identity_query.nil?
280
+
281
+ andc = DataMapper::Query::Conditions::AndOperation.new
282
+ andc << identity_query(query.model)
283
+ andc << query.conditions unless query.conditions.nil?
284
+
285
+ andc
286
+ end
287
+
288
+ # TODO be able to change the identity query
289
+ def identity_query(model)
290
+ identity_tag = identity_tag(model)
291
+ "has #{identity_tag}" if identity_tag
292
+ end
293
+
294
+ def apply_limits(list, query)
295
+ if query.limit
296
+ list.slice(query.offset, query.limit)
297
+ else
298
+ list
299
+ end
300
+ end
301
+
302
+ # TODO limit to the actual query fields
303
+ # TODO support links
304
+ def query_fields(query)
305
+ tags(query.model)
306
+ end
307
+
308
+ # TODO be more efficient
309
+ # TODO make sure the key column is a single string
310
+ # TODO collect fields for conditions that fluiddb doesn't support and then build a query that loads just those fields and use the filter_records method on that and then load all the other requested fields only for the ids that matched the query
311
+ def conditions_statement(conditions)
312
+ case conditions
313
+ when Query::Conditions::AbstractOperation
314
+ operation_statement(conditions)
315
+
316
+ when Query::Conditions::AbstractComparison
317
+ comparison_statement(conditions)
318
+
319
+ when String
320
+ conditions # handle raw conditions
321
+ end
322
+ end
323
+
324
+ # TODO add support for set matching with the inclusion operator
325
+ def comparison_statement(comparison)
326
+ value = comparison.value
327
+
328
+ if comparison.subject && comparison.subject.name == :id
329
+ # This gets scanned out at the query level
330
+ return "|id:#{value}|"
331
+ end
332
+
333
+ # break exclusive Range queries up into two comparisons ANDed together
334
+ if value.kind_of?(Range)
335
+ operation = Query::Conditions::Operation.new(:and,
336
+ Query::Conditions::Comparison.new(:gte, comparison.subject, value.first),
337
+ Query::Conditions::Comparison.new((value.exclude_end? ? :lt : :lte), comparison.subject, value.last)
338
+ )
339
+
340
+ return conditions_statement(operation)
341
+ elsif comparison.relationship?
342
+ #return conditions_statement(comparison.foreign_key_mapping, qualify)
343
+ # I think this is for joins which we don't support in queries
344
+ raise NotImplementedError.new("Joins not supported in object lookups")
345
+ end
346
+
347
+ operator = case comparison
348
+ when Query::Conditions::EqualToComparison then equality_operator(comparison.subject, value)
349
+ # when Query::Conditions::InclusionComparison then include_operator(comparison.subject, value)
350
+ # when Query::Conditions::RegexpComparison then regexp_operator(value)
351
+ # when Query::Conditions::LikeComparison then like_operator(value)
352
+ when Query::Conditions::GreaterThanComparison then '>'
353
+ when Query::Conditions::LessThanComparison then '<'
354
+ when Query::Conditions::GreaterThanOrEqualToComparison then '>='
355
+ when Query::Conditions::LessThanOrEqualToComparison then '<='
356
+ end
357
+
358
+ subject = comparison.subject.nil? ? '' : tag_name(comparison.subject)
359
+ operator.nil? ? '' : "#{subject} #{operator} #{value}"
360
+ end
361
+
362
+ def equality_operator(subject, value)
363
+ if value.is_a?(Numeric)
364
+ '='
365
+ else
366
+ # TODO they don't support text matching yet
367
+ end
368
+ end
369
+
370
+ def operation_statement(operation)
371
+ operands = operation.operands
372
+ statements = []
373
+
374
+ if operands.any? {|x| x.is_a?(Query::Conditions::NotOperation) }
375
+ case operation
376
+ when Query::Conditions::AndOperation
377
+ affirmatives = operands.select {|x| ! x.is_a?(Query::Conditions::NotOperation) }
378
+ statements << multipart_operation(Query::Conditions::AndOperation.new(*affirmatives))
379
+
380
+ negatives = operands - affirmatives
381
+ statements << multipart_operation(Query::Conditions::AndOperation.new(*(negatives.map(&:operands).flatten)))
382
+
383
+ join_with = 'except'
384
+ when Query::Conditions::OrOperation then 'or'
385
+ raise "Query builder can't build a negative-or, please write the query by hand"
386
+ end
387
+ else
388
+ operands.each do |operand|
389
+ statements << multipart_operation(operand)
390
+ end
391
+
392
+ join_with = case operation
393
+ when Query::Conditions::AndOperation then 'and'
394
+ when Query::Conditions::OrOperation then 'or'
395
+ end
396
+ end
397
+
398
+ statements.delete_if {|x| x.nil? || x.strip == '' }
399
+
400
+ joined = statements.join(" #{join_with} ")
401
+ joined.strip == '' ? '' : joined
402
+ end
403
+
404
+ def multipart_operation(operand)
405
+ statement = conditions_statement(operand)
406
+
407
+ if (operand.respond_to?(:operands) && operand.operands.size > 1) || operand.kind_of?(Query::Conditions::InclusionComparison) &&
408
+ statement.strip != ''
409
+ statement = "(#{statement})"
410
+ end
411
+
412
+ statement
413
+ end
414
+
415
+ def fluiddb_query(query, &block)
416
+ get '/objects', :query => query do |resp|
417
+ if resp.code != 200
418
+ raise "Failed to run query: #{query}"
419
+ else
420
+ preturn(resp.body['ids'], &block)
421
+ end
422
+ end
423
+ end
424
+
425
+ # Set use_cache = false to reload all the data from FluidDB. The
426
+ # resulting values will still be stored in memcache when available.
427
+ def fetch_fields(ids, fields, use_cache=true)
428
+ ids = ids.dup
429
+ dataset = {}
430
+
431
+ if use_cache
432
+ keys = ids.map_hash do |id|
433
+ [ object_cache_key(id,fields), id ]
434
+ end
435
+
436
+ results = cache_get(keys.keys) || []
437
+ results.each do |key, attrs|
438
+ if attrs
439
+ id = keys[key]
440
+ ids.delete(id)
441
+ dataset[id] = attrs
442
+ end
443
+ end
444
+ end
445
+
446
+ parallel do
447
+ ids.each do |id|
448
+ row = { 'id' => id }
449
+ dataset[id] = row
450
+
451
+ requests = fields.map_hash do |field, tag|
452
+ [ "#{id}/#{tag}", field ]
453
+ end
454
+
455
+ if use_cache
456
+ ret = cache_get(requests.keys) || []
457
+ ret.each do |key,value|
458
+ row[requests.delete(key)] = value if value
459
+ end
460
+ end
461
+
462
+ requests.each do |key, field|
463
+ get("/objects/#{key}") do |resp|
464
+ if [ 200, 404 ].include?(resp.code)
465
+ cache_set key, resp.body
466
+ row[field] = resp.body
467
+ else
468
+ # TODO raise a fetch error, or try again
469
+ end
470
+ end
471
+ end
472
+ end
473
+ end
474
+
475
+ ids.each do |id|
476
+ cache_set object_cache_key(id,fields), dataset[id]
477
+ end
478
+
479
+ dataset
480
+ end
481
+
482
+ # TODO some peice of code is passing the id in here
483
+ def object_cache_key(id, fields)
484
+ fields = fields.values if fields.is_a?(Hash)
485
+ fields.compact!
486
+ fields.sort!
487
+
488
+ raise "don't use the id in the cache key" if fields.any? {|x| x[/\/id$/] }
489
+ Digest::SHA1.hexdigest("id:#{id}:fields:#{fields.join(',')}")
490
+ end
491
+
492
+ def save(resource)
493
+ identity_tag = identity_tag(resource.model)
494
+ set_primitive_tag(resource, identity_tag) if identity_tag
495
+
496
+ serialized = serialize(resource)
497
+ serialized.each do |tag, value|
498
+ set_primitive_tag(resource, tag, value)
499
+ end
500
+
501
+ cache_key = object_cache_key(resource.id, serialized.keys)
502
+ cache_set cache_key, serialized.merge(:id => resource.id)
503
+ end
504
+
505
+ def serialize(resource)
506
+ hash = {}
507
+
508
+ resource.model.properties.each do |property|
509
+ if resource.model.public_method_defined?(name = property.name) && property.name != :id
510
+ tag = tag_name(property)
511
+ hash[tag] = primitive_value(resource, name)
512
+ end
513
+ end
514
+
515
+ hash
516
+ end
517
+
518
+ # TODO better type handling, like dates
519
+ def primitive_value(resource, name)
520
+ resource.send(name)
521
+ end
522
+
523
+ def check_dm_field(property)
524
+ tag = tag_name(property)
525
+ description = property.options[:description]
526
+ check_tag(tag, description)
527
+ end
528
+
529
+ def set_primitive_tag(id, tag, value=nil)
530
+ id = id.id if id.is_a?(Resource)
531
+
532
+ key = "#{id}/#{tag}"
533
+ cache_set key, value
534
+ encoded = value.nil? ? "null" : value.to_json
535
+
536
+ put "/objects/#{key}",
537
+ nil, encoded, PRIMITIVE_HEADERS
538
+ # TODO raise a persistance error on failure
539
+ end
540
+
541
+ # TODO return nil if the model doesn't have an identity tag
542
+ def identity_tag(model)
543
+ "#{tag_prefix}/#{model.storage_name(name)}"
544
+ end
545
+
546
+ def deleted_tag(model)
547
+ "#{tag_prefix}/#{model.storage_name(name)}/deleted"
548
+ end
549
+
550
+ # TODO change the field name strategy to automatically map to tag names
551
+ def tag_name(property)
552
+ field = property.field
553
+
554
+ if field.index('/')
555
+ field
556
+ else
557
+ "#{tag_prefix}/#{property.model.storage_name(name)}/#{field}"
558
+ end
559
+ end
560
+
561
+ # TODO change the field name strategy to automatically map to tag names
562
+ def tags(model)
563
+ @tags ||= model.properties.map_hash do |property|
564
+ next if property.name == :id
565
+ [ property.field, tag_name(property) ]
566
+ end
567
+ end
568
+
569
+ def preturn(value, &block)
570
+ if block_given?
571
+ yield value
572
+ else
573
+ value
574
+ end
575
+ end
576
+
577
+ def blocking_request
578
+ raise "Tried to make blocking request in parallel block" if @http.in_parallel?
579
+
580
+ yield if block_given?
581
+ end
582
+
583
+ def tag_prefix
584
+ @tag_prefix ||= @options['user']+'/'+@options['path'][1..-1]
585
+ end
586
+
587
+ def cache_get(key)
588
+ return nil if key.empty?
589
+ cache.get key rescue nil
590
+ end
591
+
592
+ def cache_set(key, value, ttl=CACHE_TTL)
593
+ raise "Failed to store empty memcache key" if key.empty?
594
+ cache.set(key, value, ttl) rescue nil
595
+ end
596
+
597
+ def expire(key)
598
+ cache.delete key rescue nil
599
+ end
600
+ end
601
+
602
+ FluiddbAdapter = FluidDBAdapter
603
+
604
+ DataMapper.extend(Migrations::SingletonMethods)
605
+ [ :Repository, :Model ].each do |name|
606
+ DataMapper.const_get(name).send(:include, Migrations.const_get(name))
607
+ end
608
+
609
+ const_added(:FluiddbAdapter)
610
+ end
611
+ end