dm-fluiddb-adapter 0.1.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.
@@ -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