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.
- data/.document +5 -0
- data/.gitignore +5 -0
- data/LICENSE +20 -0
- data/README.rdoc +56 -0
- data/Rakefile +52 -0
- data/VERSION +1 -0
- data/dm-fluiddb-adapter.gemspec +71 -0
- data/lib/dm-fluiddb-adapter/fixes.rb +31 -0
- data/lib/dm-fluiddb-adapter/typhoeus_client.rb +126 -0
- data/lib/fluiddb_adapter.rb +611 -0
- data/lib/loggable.rb +27 -0
- data/spec/fluiddb_adapter_spec.rb +189 -0
- data/spec/integration_spec.rb +302 -0
- data/spec/spec.opts +2 -0
- data/spec/spec_helper.rb +24 -0
- data/spec/typhoeus_client_spec.rb +33 -0
- metadata +115 -0
data/.document
ADDED
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.
|
data/README.rdoc
ADDED
@@ -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.
|
data/Rakefile
ADDED
@@ -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
|