yuki 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/LICENSE +20 -0
- data/README.md +122 -0
- data/Rakefile +20 -0
- data/VERSION +1 -0
- data/lib/cast_system.rb +38 -0
- data/lib/store/abstract.rb +199 -0
- data/lib/store/cabinet.rb +28 -0
- data/lib/store/tyrant.rb +58 -0
- data/lib/yuki.rb +323 -0
- data/test/cast_system_test.rb +63 -0
- data/test/helper.rb +4 -0
- data/test/store_test.rb +53 -0
- data/test/yuki_test.rb +160 -0
- data/yuki.rb +2 -0
- metadata +73 -0
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Doug Tangren
|
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.md
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
# Yuki
|
2
|
+
|
3
|
+
a model wrapper for key-value objects around tokyo products (cabinet/tyrant)
|
4
|
+
|
5
|
+
# Play with Ninjas
|
6
|
+
|
7
|
+
class Ninja
|
8
|
+
include Yuki
|
9
|
+
store :cabinet, :path => "path/to/ninja.tct"
|
10
|
+
has :color, :string, :default => "black"
|
11
|
+
has :name, :string
|
12
|
+
has :mp, :numeric
|
13
|
+
has :last_kill, :timestamp
|
14
|
+
end
|
15
|
+
|
16
|
+
# Tyrant requires tokyo tyrant to be started
|
17
|
+
# ttserver -port 45002 data.tct
|
18
|
+
class Ninja
|
19
|
+
include Yuki
|
20
|
+
store :tyrant, :host => "localhost", :port => 45002
|
21
|
+
# ...
|
22
|
+
end
|
23
|
+
|
24
|
+
class Ninja
|
25
|
+
include Yuki
|
26
|
+
store :cabinet, :path => "/path/to/ninja.tct"
|
27
|
+
has :color, :default => "black"
|
28
|
+
has :name, :string, :default => "unknown"
|
29
|
+
has :mp, :numeric
|
30
|
+
has :last_kill, :timestamp
|
31
|
+
|
32
|
+
# object will remain in the store after deletion
|
33
|
+
# with 'deleted' # => Time of deletion
|
34
|
+
soft_delete!
|
35
|
+
|
36
|
+
def before_save
|
37
|
+
puts "kick"
|
38
|
+
end
|
39
|
+
|
40
|
+
def after_save
|
41
|
+
puts "young blood..."
|
42
|
+
end
|
43
|
+
|
44
|
+
def before_delete
|
45
|
+
puts "stabs murderer"
|
46
|
+
end
|
47
|
+
|
48
|
+
def after_delete
|
49
|
+
puts "fights as ghost"
|
50
|
+
end
|
51
|
+
|
52
|
+
def validate!
|
53
|
+
puts "ensure ninjatude"
|
54
|
+
end
|
55
|
+
|
56
|
+
# hook for serialization
|
57
|
+
# (green ninjas get more mp with serialized)
|
58
|
+
def to_h
|
59
|
+
if color == "green"
|
60
|
+
super.to_h.merge({
|
61
|
+
"mp" => (mp + 1000).to_s
|
62
|
+
})
|
63
|
+
else
|
64
|
+
super
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# queryable attributes
|
70
|
+
Ninja.new.mp? # false
|
71
|
+
|
72
|
+
ninja = Ninja.new(:mp => 700, :last_kill => Time.now)
|
73
|
+
|
74
|
+
ninja.mp? # true
|
75
|
+
|
76
|
+
# crud
|
77
|
+
ninja.save!
|
78
|
+
ninja.update!
|
79
|
+
ninja.delete!
|
80
|
+
|
81
|
+
Ninja.filter({
|
82
|
+
:color => [:eq, 'red'],
|
83
|
+
:mp => [:gt, 40],
|
84
|
+
:order => :last_kill
|
85
|
+
}) # => all red ninjas with mp > 40
|
86
|
+
|
87
|
+
Ninja.union([{
|
88
|
+
:color => [:eq, 'red'],
|
89
|
+
:mp => [:gt, 40],
|
90
|
+
}, {
|
91
|
+
:color => [:eq, 'black']
|
92
|
+
}]) # => all black ninjas mixed with red ninjas with mp > 20
|
93
|
+
|
94
|
+
Ninja.first
|
95
|
+
|
96
|
+
Ninja.last
|
97
|
+
|
98
|
+
Ninja.keys
|
99
|
+
|
100
|
+
Ninja.any?
|
101
|
+
|
102
|
+
Ninja.empty?
|
103
|
+
|
104
|
+
Ninja.build([
|
105
|
+
{ ... },
|
106
|
+
{ ... },
|
107
|
+
{ ... }
|
108
|
+
]) # 3 ninjas built from 3 hashes
|
109
|
+
|
110
|
+
## Install
|
111
|
+
> make sure to have the following tokyo products installed
|
112
|
+
- tokyocabinet-1.4.36 or greater
|
113
|
+
- tokyotyrant-1.1.37 or greater
|
114
|
+
- [install tokyo products]:(@ http://openwferu.rubyforge.org/tokyo.html)
|
115
|
+
|
116
|
+
> rip install git://github.com/softprops/yuki
|
117
|
+
|
118
|
+
> include Yuki in your model
|
119
|
+
|
120
|
+
> run with it
|
121
|
+
|
122
|
+
2009 Doug Tangren (softprops)
|
data/Rakefile
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
task :default => :test
|
2
|
+
|
3
|
+
task :test do
|
4
|
+
sh "ruby test/yuki_test.rb"
|
5
|
+
end
|
6
|
+
|
7
|
+
begin
|
8
|
+
require 'jeweler'
|
9
|
+
Jeweler::Tasks.new do |gemspec|
|
10
|
+
gemspec.name = "yuki"
|
11
|
+
gemspec.summary = "A Toyko model"
|
12
|
+
gemspec.description = "A Toyko model"
|
13
|
+
gemspec.email = "d.tangren@gmail.com"
|
14
|
+
gemspec.homepage = "http://github.com/softprops/yuki"
|
15
|
+
gemspec.authors = ["softprops"]
|
16
|
+
end
|
17
|
+
Jeweler::GemcutterTasks.new
|
18
|
+
rescue LoadError
|
19
|
+
puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
|
20
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.1.0
|
data/lib/cast_system.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
module CastSystem
|
2
|
+
TO = {
|
3
|
+
:numeric => lambda { |v| v.to_i },
|
4
|
+
:float => lambda { |v| v.to_f },
|
5
|
+
:timestamp => lambda { |v|
|
6
|
+
unless v.kind_of? Time
|
7
|
+
Time.at(v.to_i)
|
8
|
+
else
|
9
|
+
v
|
10
|
+
end
|
11
|
+
},
|
12
|
+
:boolean => lambda { |v|
|
13
|
+
case v
|
14
|
+
when "false" then false
|
15
|
+
when "true" then true
|
16
|
+
end
|
17
|
+
},
|
18
|
+
:regex => lambda { |v| Regexp.new(v) },
|
19
|
+
:string => lambda { |v| v.to_s }
|
20
|
+
}
|
21
|
+
|
22
|
+
FROM = {
|
23
|
+
:numeric => lambda { |v| FROM[:string].call v },
|
24
|
+
:float => lambda { |v| FROM[:string].call v },
|
25
|
+
:timestamp => lambda { |v| FROM[:string].call v.to_i },
|
26
|
+
:boolean => lambda { |v| FROM[:string].call v },
|
27
|
+
:regex => lambda { |v| FROM[:string].call v },
|
28
|
+
:string => lambda { |v| v.to_s }
|
29
|
+
}
|
30
|
+
|
31
|
+
def cast(val, type)
|
32
|
+
CastSystem::TO[type].call(val)
|
33
|
+
end
|
34
|
+
|
35
|
+
def uncast(val, type)
|
36
|
+
CastSystem::FROM[type].call(val)
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,199 @@
|
|
1
|
+
module HashExtentions
|
2
|
+
def without(*args)
|
3
|
+
return if Hash.respond_to? :without
|
4
|
+
self.inject({}) { |wo, (k,v)|
|
5
|
+
wo[k] = v unless args.include? k
|
6
|
+
wo
|
7
|
+
}
|
8
|
+
end
|
9
|
+
end
|
10
|
+
Hash.send :include, HashExtentions
|
11
|
+
module Yuki
|
12
|
+
module Store
|
13
|
+
class AbstractStore
|
14
|
+
class InvalidStore < Exception; end
|
15
|
+
|
16
|
+
# Determines if the current state of the store is valid
|
17
|
+
def valid?; false; end
|
18
|
+
|
19
|
+
# @see http://github.com/jmettraux/rufus-tokyo/blob/master/lib/rufus/tokyo/query.rb
|
20
|
+
# expects
|
21
|
+
# conditions = {
|
22
|
+
# :attr => [:operation, :value],
|
23
|
+
# :limit => [:offset, :max]
|
24
|
+
# :order => [:attr, :direction]
|
25
|
+
# }
|
26
|
+
# todo
|
27
|
+
# db.union( db.prepare_query{|q| q.add(att, op, val)})
|
28
|
+
#
|
29
|
+
def filter(conditions = {}, &blk)
|
30
|
+
ordering = extract_ordering!(conditions)
|
31
|
+
max, offset = *extract_limit!(conditions)
|
32
|
+
open { |db|
|
33
|
+
db.query { |q|
|
34
|
+
prepare_conditions(conditions) { |attr, op, val|
|
35
|
+
q.add_condition(attr, op, val)
|
36
|
+
}
|
37
|
+
q.order_by(ordering[0])
|
38
|
+
q.limit(max, offset) if max && offset
|
39
|
+
}
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
# unioned_conditions = db.union([
|
44
|
+
# { :attr => [:op, :val] }
|
45
|
+
# { :attr => [:op, :val2] }
|
46
|
+
# ])
|
47
|
+
def union(conditions)
|
48
|
+
ordering = extract_ordering!(conditions)
|
49
|
+
max, offset = *extract_limit!(conditions)
|
50
|
+
open { |db|
|
51
|
+
queries = conditions.inject([]) { |arr, cond|
|
52
|
+
prepare_conditions(cond) { |attr, op, val|
|
53
|
+
db.prepare_query { |q|
|
54
|
+
q.add(attr, op, val)
|
55
|
+
arr << q
|
56
|
+
}
|
57
|
+
}
|
58
|
+
arr
|
59
|
+
}
|
60
|
+
db.union(*queries).map { |k, v| v.merge!(:pk => k) }
|
61
|
+
}
|
62
|
+
end
|
63
|
+
|
64
|
+
def delete!(key)
|
65
|
+
open { |db| db.delete(key) }
|
66
|
+
end
|
67
|
+
|
68
|
+
def keys
|
69
|
+
open { |db| db.keys }
|
70
|
+
end
|
71
|
+
|
72
|
+
def any?
|
73
|
+
open { |db| db.any? }
|
74
|
+
end
|
75
|
+
|
76
|
+
def empty?
|
77
|
+
!any?
|
78
|
+
end
|
79
|
+
|
80
|
+
# Creates a new value.
|
81
|
+
# store << { 'foo' => 'bar' } => { 'foo' => 'bar', :pk => '1' }
|
82
|
+
def <<(val)
|
83
|
+
open { |db|
|
84
|
+
key = key!(db, val)
|
85
|
+
(db[key] = stringify_keys(val).without('pk')).merge(:pk => key)
|
86
|
+
}
|
87
|
+
end
|
88
|
+
|
89
|
+
# Gets an value by key.
|
90
|
+
# store['1'] => { 'foo' => 'bar', :pk => '1' }
|
91
|
+
def [](key)
|
92
|
+
open { |db| db[key] }
|
93
|
+
end
|
94
|
+
|
95
|
+
# Merges changes into an value by key.
|
96
|
+
# store['1'] = { 'foo' => 'bar', 'baz' => 'boo' } => ...
|
97
|
+
def []=(key, val)
|
98
|
+
open { |db|
|
99
|
+
db[key] = (db[key] || {}).merge(val)
|
100
|
+
}.merge({
|
101
|
+
'pk' => key
|
102
|
+
})
|
103
|
+
end
|
104
|
+
|
105
|
+
protected
|
106
|
+
|
107
|
+
# each store should override this with key-value
|
108
|
+
# pairs representing the name and explaination of
|
109
|
+
# the error
|
110
|
+
def errors
|
111
|
+
{}
|
112
|
+
end
|
113
|
+
|
114
|
+
# Override this and return a db connection
|
115
|
+
def aquire; end
|
116
|
+
|
117
|
+
## helpers
|
118
|
+
|
119
|
+
def stringified_hash(h)
|
120
|
+
h.inject({}) { |a, (k, v)| a[k.to_s] = v.to_s; a }
|
121
|
+
end
|
122
|
+
|
123
|
+
def stringify_keys(hash)
|
124
|
+
hash.inject({}) { |stringified, (k,v)| stringified.merge({k.to_s => v}) }
|
125
|
+
end
|
126
|
+
|
127
|
+
private
|
128
|
+
|
129
|
+
def formatted_errors
|
130
|
+
errors.inject([]) { |errs, (k,v)|
|
131
|
+
errs << v
|
132
|
+
}.join(', or ')
|
133
|
+
end
|
134
|
+
|
135
|
+
def extract_ordering!(hash)
|
136
|
+
ordering = hash.delete(:order) || [:pk, :asc]
|
137
|
+
ordering = [ordering] if(!ordering.kind_of?(Array))
|
138
|
+
ordering[1] = :desc if ordering.size == 1
|
139
|
+
ordering
|
140
|
+
end
|
141
|
+
|
142
|
+
def extract_limit!(hash)
|
143
|
+
hash.delete(:limit)
|
144
|
+
end
|
145
|
+
|
146
|
+
def prepare_conditions(conditions, &blk)
|
147
|
+
conditions.each { |attr, cond|
|
148
|
+
validate_condition(cond)
|
149
|
+
attr = '' if attr.to_s == 'pk'
|
150
|
+
yield attr.to_s, cond[0], cond[1]
|
151
|
+
}
|
152
|
+
end
|
153
|
+
|
154
|
+
def validate_condition(v)
|
155
|
+
raise (
|
156
|
+
ArgumentError.new("#{v.inspect} is not a valid condition")
|
157
|
+
) if invalid_condition?(v)
|
158
|
+
|
159
|
+
raise (
|
160
|
+
ArgumentError.new("#{v[0]} is not a valid operator")
|
161
|
+
) if invalid_op?(v[0])
|
162
|
+
end
|
163
|
+
|
164
|
+
def invalid_condition?(v)
|
165
|
+
!(v && v.size == 2)
|
166
|
+
end
|
167
|
+
|
168
|
+
def invalid_op?(op)
|
169
|
+
!Rufus::Tokyo::QueryConstants::OPERATORS.include?(op)
|
170
|
+
end
|
171
|
+
|
172
|
+
def key!(db, *args)
|
173
|
+
db.genuid.to_s
|
174
|
+
end
|
175
|
+
|
176
|
+
def open(&blk)
|
177
|
+
raise(
|
178
|
+
InvalidStore.new(formatted_errors)
|
179
|
+
) if !valid?
|
180
|
+
Thread.current[:db] = begin
|
181
|
+
db = aquire
|
182
|
+
yield db
|
183
|
+
ensure
|
184
|
+
db.close if db
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
# adapters
|
192
|
+
|
193
|
+
Yuki::Store.autoload :TokyoCabinet, File.join(
|
194
|
+
File.dirname(__FILE__), 'cabinet'
|
195
|
+
)
|
196
|
+
|
197
|
+
Yuki::Store.autoload :TokyoTyrant, File.join(
|
198
|
+
File.dirname(__FILE__), 'tyrant'
|
199
|
+
)
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Yuki
|
2
|
+
module Store
|
3
|
+
class TokyoCabinet < Yuki::Store::AbstractStore
|
4
|
+
require 'rufus/tokyo'
|
5
|
+
|
6
|
+
def initialize(config = {})
|
7
|
+
@file = config[:file]
|
8
|
+
end
|
9
|
+
|
10
|
+
def valid?
|
11
|
+
errors.empty?
|
12
|
+
end
|
13
|
+
|
14
|
+
protected
|
15
|
+
|
16
|
+
def errors
|
17
|
+
unless @file =~ /[.]tct$/
|
18
|
+
{ "invalid file" => "Please provide a file in the format '{name}.tct'" }
|
19
|
+
else {}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def aquire
|
24
|
+
Rufus::Tokyo::Table.new(@file)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/store/tyrant.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
module Yuki
|
2
|
+
module Store
|
3
|
+
class TokyoTyrant < Yuki::Store::AbstractStore
|
4
|
+
require 'rufus/tokyo/tyrant'
|
5
|
+
|
6
|
+
def initialize(config = {})
|
7
|
+
@socket = config[:socket] if config.include? :socket
|
8
|
+
@host, @port = config[:host], config[:port]
|
9
|
+
end
|
10
|
+
|
11
|
+
def valid?
|
12
|
+
unless(socket_valid? || host_and_port_valid?)
|
13
|
+
errors.size < 2
|
14
|
+
else {}
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def stat
|
19
|
+
open { |db| db.stat.inject('') { |s, (k, v)| s << "#{k} => #{v}\n" } }
|
20
|
+
end
|
21
|
+
|
22
|
+
protected
|
23
|
+
|
24
|
+
def errors
|
25
|
+
unless(socket_valid? || host_and_port_valid?)
|
26
|
+
errs = {}
|
27
|
+
unless(socket_valid?)
|
28
|
+
errs.merge!({
|
29
|
+
"invalid socket" => "Please provide a valid socket"
|
30
|
+
})
|
31
|
+
end
|
32
|
+
unless(host_and_port_valid?)
|
33
|
+
errs.merge!({
|
34
|
+
"invalid host and port" => "Please provde a valid host and port"
|
35
|
+
})
|
36
|
+
end
|
37
|
+
errs
|
38
|
+
else {}
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def aquire
|
43
|
+
Rufus::Tokyo::TyrantTable.new(@socket? @socket : @host, @port)
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def socket_valid?
|
49
|
+
@socket
|
50
|
+
end
|
51
|
+
|
52
|
+
def host_and_port_valid?
|
53
|
+
@host && @port
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
data/lib/yuki.rb
ADDED
@@ -0,0 +1,323 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), *%w(store abstract))
|
2
|
+
require File.join(File.dirname(__FILE__),'cast_system')
|
3
|
+
|
4
|
+
# A wrapper for tokyo-x products for persistence of ruby objects
|
5
|
+
module Yuki
|
6
|
+
class InvalidAdapter < Exception; end
|
7
|
+
|
8
|
+
def self.included(base)
|
9
|
+
base.send :include, Yuki::Resource
|
10
|
+
end
|
11
|
+
|
12
|
+
module Resource
|
13
|
+
|
14
|
+
def self.included(base)
|
15
|
+
base.send :include, InstanceMethods
|
16
|
+
base.class_eval { @store = nil }
|
17
|
+
base.instance_eval { alias __new__ new }
|
18
|
+
base.extend ClassMethods
|
19
|
+
base.extend Validations
|
20
|
+
base.instance_eval {
|
21
|
+
has :type
|
22
|
+
has :pk
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
module Callbacks
|
27
|
+
def before_save(); end
|
28
|
+
def after_save(); end
|
29
|
+
def before_delete(); end
|
30
|
+
def after_delete(); end
|
31
|
+
end
|
32
|
+
|
33
|
+
module ClassMethods
|
34
|
+
attr_reader :db
|
35
|
+
|
36
|
+
# assign the current storage adapter and config
|
37
|
+
def store(adapter, opts = {})
|
38
|
+
@db = (case adapter
|
39
|
+
when :cabinet then use_cabinet
|
40
|
+
when :tyrant then use_tyrant
|
41
|
+
else raise(
|
42
|
+
InvalidAdapter.new(
|
43
|
+
'Invalid Adapter. Try :cabinet or :tyrant.'
|
44
|
+
)
|
45
|
+
)
|
46
|
+
end).new(opts)
|
47
|
+
end
|
48
|
+
|
49
|
+
def inherited(c)
|
50
|
+
c.instance_variable_set(:@db, @db.dup || nil)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Redefines #new method in order to build the object
|
54
|
+
# from a hash. Assumes a constructor that takes a hash or a
|
55
|
+
# no-args constructor
|
56
|
+
def new(attrs = {})
|
57
|
+
begin
|
58
|
+
__new__(attrs).from_hash(attrs)
|
59
|
+
rescue
|
60
|
+
__new__.from_hash(attrs)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns all of the keys for the class's store
|
65
|
+
def keys
|
66
|
+
db.keys
|
67
|
+
end
|
68
|
+
|
69
|
+
# Gets all instances matching query criteria
|
70
|
+
# :limit
|
71
|
+
# :conditions => [[:attr, :cond, :expected]]
|
72
|
+
def filter(opts = {})
|
73
|
+
build(db.filter(opts))
|
74
|
+
end
|
75
|
+
alias_method :all, :filter
|
76
|
+
|
77
|
+
def union(opts = {})
|
78
|
+
build(db.union(opts))
|
79
|
+
end
|
80
|
+
|
81
|
+
def soft_delete!
|
82
|
+
has :deleted, :timestamp
|
83
|
+
define_method(:delete!) {
|
84
|
+
self['deleted'] = Time.now
|
85
|
+
self.save!
|
86
|
+
}
|
87
|
+
end
|
88
|
+
|
89
|
+
# Gets an instance by key
|
90
|
+
def get(key)
|
91
|
+
val = db[key]
|
92
|
+
build(val)[0] if val && val[type_desc]
|
93
|
+
end
|
94
|
+
|
95
|
+
# Updates an instance by key the the given attrs hash
|
96
|
+
def put(key, attrs)
|
97
|
+
db[key] = attrs
|
98
|
+
val = db[key]
|
99
|
+
build(val)[0] if val && val[type_desc]
|
100
|
+
end
|
101
|
+
|
102
|
+
# An object Type descriminator
|
103
|
+
# This is implicitly differentiates
|
104
|
+
# what a class a hash is associated with
|
105
|
+
def type_desc
|
106
|
+
'type'
|
107
|
+
end
|
108
|
+
|
109
|
+
# Attribute definition api.
|
110
|
+
# At a minimum this method expects the name
|
111
|
+
# of the attribute.
|
112
|
+
#
|
113
|
+
# This method also specifies type information
|
114
|
+
# about attributes. The default type is :default
|
115
|
+
# which is a String. Other valid options for type
|
116
|
+
# are.
|
117
|
+
# :numeric
|
118
|
+
# :timestamp
|
119
|
+
# :float
|
120
|
+
# :regex
|
121
|
+
#
|
122
|
+
# opts can be
|
123
|
+
# :default - defines a default value to return if a value
|
124
|
+
# is not supplied
|
125
|
+
# :mutable - determines if the attr should be mutable.
|
126
|
+
# true or false. (false is default)
|
127
|
+
#
|
128
|
+
# TODO
|
129
|
+
# opts planened to be supported in the future are
|
130
|
+
# :alias - altername name
|
131
|
+
# :collection - true or false
|
132
|
+
#
|
133
|
+
def has(attr, type = :string, opts = {})
|
134
|
+
if type.is_a?(Hash)
|
135
|
+
opts.merge!(type)
|
136
|
+
type = :string
|
137
|
+
end
|
138
|
+
define_methods(attr, type, opts)
|
139
|
+
end
|
140
|
+
|
141
|
+
# Builds one or more instance's of the class from
|
142
|
+
# a hash or array of hashes
|
143
|
+
def build(hashes)
|
144
|
+
[hashes].flatten.inject([]) do |list, hash|
|
145
|
+
type = hash[type_desc] || self.to_s.split('::').last
|
146
|
+
cls = resolve(type)
|
147
|
+
list << cls.new(hash) if cls
|
148
|
+
list
|
149
|
+
end if hashes
|
150
|
+
end
|
151
|
+
|
152
|
+
# Resolves a class given a string or hash
|
153
|
+
# If given a hash, the expected format is
|
154
|
+
# { :foo => { :type => :Bar, ... } }
|
155
|
+
# or
|
156
|
+
# "Bar"
|
157
|
+
def resolve(cls_def)
|
158
|
+
if cls_def.kind_of? Hash
|
159
|
+
class_key = cls_def.keys.first
|
160
|
+
clazz = resolve(cls_def[class_key][:type])
|
161
|
+
resource = clazz.new(info[class_key]) if clazz
|
162
|
+
else
|
163
|
+
clazz = begin
|
164
|
+
cls_def.split("::").inject(Object) { |obj, const|
|
165
|
+
obj.const_get(const)
|
166
|
+
} unless cls_def.strip.empty?
|
167
|
+
rescue NameError => e
|
168
|
+
puts "given #{cls_def} got #{e.inspect}"
|
169
|
+
raise e
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
def define_methods(attr, type, opts = {})
|
175
|
+
default_val = opts.delete(:default)
|
176
|
+
mutable = opts.delete(:mutable) || false
|
177
|
+
casted, uncasted = :"cast_#{attr}", :"uncast_#{attr}"
|
178
|
+
define_method(casted) { |val| cast(val, type) }
|
179
|
+
define_method(uncasted) { uncast(self[attr], type) }
|
180
|
+
define_method(attr) { self[attr] || default_val }
|
181
|
+
define_method(:"#{attr}=") { |v| self[attr] = v } if mutable
|
182
|
+
define_method(:"#{attr}?") { self[attr] }
|
183
|
+
end
|
184
|
+
|
185
|
+
private
|
186
|
+
|
187
|
+
def use_cabinet
|
188
|
+
Yuki::Store::TokyoCabinet
|
189
|
+
end
|
190
|
+
|
191
|
+
def use_tyrant
|
192
|
+
Yuki::Store::TokyoTyrant
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
|
197
|
+
module Validations
|
198
|
+
# config opts
|
199
|
+
# :msg => the display message
|
200
|
+
def validates_presence_of(attr, config={})
|
201
|
+
unless(send(attr.to_sym))
|
202
|
+
add_error(
|
203
|
+
"invalid #{attr}",
|
204
|
+
(config[:msg] || "#{attr} is required")
|
205
|
+
)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
module InstanceMethods
|
211
|
+
include CastSystem
|
212
|
+
include Callbacks
|
213
|
+
|
214
|
+
def save!
|
215
|
+
before_save
|
216
|
+
validate!
|
217
|
+
|
218
|
+
raise(
|
219
|
+
Exception.new("Object not valid. #{formatted_errors}")
|
220
|
+
) unless valid?
|
221
|
+
|
222
|
+
val = if(key)
|
223
|
+
db[key] = self.to_h
|
224
|
+
else
|
225
|
+
db << self.to_h
|
226
|
+
end
|
227
|
+
|
228
|
+
data.merge!('pk' => (val[:pk] || val['pk']))
|
229
|
+
after_save
|
230
|
+
self
|
231
|
+
end
|
232
|
+
|
233
|
+
def delete!
|
234
|
+
before_delete
|
235
|
+
db.delete!(key)
|
236
|
+
after_delete
|
237
|
+
self
|
238
|
+
end
|
239
|
+
|
240
|
+
def errors
|
241
|
+
@errors ||= {}
|
242
|
+
end
|
243
|
+
|
244
|
+
def add_error(k,v)
|
245
|
+
errors.merge!({k,v})
|
246
|
+
end
|
247
|
+
|
248
|
+
def formatted_errors
|
249
|
+
errors.inject([]) { |errs, (k,v)|
|
250
|
+
errs << v
|
251
|
+
}.join(', ')
|
252
|
+
end
|
253
|
+
|
254
|
+
def valid?
|
255
|
+
errors.empty?
|
256
|
+
end
|
257
|
+
|
258
|
+
def validate!; end
|
259
|
+
|
260
|
+
def key
|
261
|
+
data['pk']
|
262
|
+
end
|
263
|
+
|
264
|
+
def to_h
|
265
|
+
data.inject({}) do |h, (k, v)|
|
266
|
+
typed_val = method("uncast_#{k}").call
|
267
|
+
h[k.to_s] = typed_val
|
268
|
+
h
|
269
|
+
end if data
|
270
|
+
end
|
271
|
+
|
272
|
+
def from_hash(h)
|
273
|
+
type = { self.class.type_desc => self.class.to_s }
|
274
|
+
h.merge!(type) unless h.include? self.class.type_desc
|
275
|
+
|
276
|
+
h.each { |k, v|
|
277
|
+
if attr_defined?(k)
|
278
|
+
self[k.to_s] = v
|
279
|
+
else
|
280
|
+
p "#{k} is undef! for #{self.inspect}"
|
281
|
+
end
|
282
|
+
} if h
|
283
|
+
|
284
|
+
self
|
285
|
+
end
|
286
|
+
|
287
|
+
# access attr as if model was hash
|
288
|
+
def [](attr)
|
289
|
+
data[attr.to_s]
|
290
|
+
end
|
291
|
+
|
292
|
+
# specifies the object 'type' to serialize
|
293
|
+
def type
|
294
|
+
self['type'] || self.class
|
295
|
+
end
|
296
|
+
|
297
|
+
protected
|
298
|
+
|
299
|
+
def db
|
300
|
+
self.class.db
|
301
|
+
end
|
302
|
+
|
303
|
+
def attrs
|
304
|
+
data.dup
|
305
|
+
end
|
306
|
+
|
307
|
+
private
|
308
|
+
|
309
|
+
def []=(attr, val)
|
310
|
+
val = method("cast_#{attr}").call(val)
|
311
|
+
data[attr.to_s] = val
|
312
|
+
end
|
313
|
+
|
314
|
+
def attr_defined?(attr)
|
315
|
+
respond_to?(:"cast_#{attr}")
|
316
|
+
end
|
317
|
+
|
318
|
+
def data
|
319
|
+
@data ||= {}
|
320
|
+
end
|
321
|
+
end
|
322
|
+
end
|
323
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), *%w(helper))
|
2
|
+
#require File.join(File.dirname(__FILE__), *%w(.. lib cast_system))
|
3
|
+
|
4
|
+
class Casting
|
5
|
+
extend CastSystem
|
6
|
+
end
|
7
|
+
class CastSystemTest < Test::Unit::TestCase
|
8
|
+
context "a cast system" do
|
9
|
+
should "cast numeric values" do
|
10
|
+
assert_equal 123, Casting.cast("123", :numeric)
|
11
|
+
end
|
12
|
+
|
13
|
+
should "uncast numeric values" do
|
14
|
+
assert_equal "123", Casting.uncast(123, :numeric)
|
15
|
+
end
|
16
|
+
|
17
|
+
should "cast float values" do
|
18
|
+
assert_equal 123.09, Casting.cast("123.09", :float)
|
19
|
+
end
|
20
|
+
|
21
|
+
should "uncast float values" do
|
22
|
+
assert_equal "123.09", Casting.uncast(123.09, :float)
|
23
|
+
end
|
24
|
+
|
25
|
+
should "cast timestamp values" do
|
26
|
+
now = Time.now
|
27
|
+
assert_equal now.to_s, Casting.cast(now.to_i.to_s, :timestamp).to_s
|
28
|
+
end
|
29
|
+
|
30
|
+
should "uncast timestamp values" do
|
31
|
+
now = Time.now
|
32
|
+
assert_equal now.to_i.to_s, Casting.uncast(now, :timestamp)
|
33
|
+
end
|
34
|
+
|
35
|
+
should "cast boolean values" do
|
36
|
+
assert_equal true, Casting.cast("true", :boolean)
|
37
|
+
assert_equal false, Casting.cast("false", :boolean)
|
38
|
+
end
|
39
|
+
|
40
|
+
should "uncast boolean values" do
|
41
|
+
assert_equal "true", Casting.uncast(true, :boolean)
|
42
|
+
assert_equal "false", Casting.uncast(false, :boolean)
|
43
|
+
end
|
44
|
+
|
45
|
+
should "cast regex values" do
|
46
|
+
assert_equal /[\@]+/, Casting.cast(/[\@]+/.to_s, :regex)
|
47
|
+
end
|
48
|
+
|
49
|
+
should "uncast regex values" do
|
50
|
+
assert_equal /[\@]+/.to_s, Casting.uncast(/[\@]+/, :regex)
|
51
|
+
end
|
52
|
+
|
53
|
+
should "cast string values" do
|
54
|
+
assert_equal "foo", Casting.cast("foo", :string)
|
55
|
+
end
|
56
|
+
|
57
|
+
should "uncast string values" do
|
58
|
+
assert_equal "foo", Casting.uncast("foo", :string)
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
end
|
63
|
+
end
|
data/test/helper.rb
ADDED
data/test/store_test.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), *%w(helper))
|
2
|
+
|
3
|
+
class StoreTest < Test::Unit::TestCase
|
4
|
+
TC = Yuki::Store::TokyoCabinet
|
5
|
+
TY = Yuki::Store::TokyoTyrant
|
6
|
+
|
7
|
+
context "a tokyo cabinet store" do
|
8
|
+
context "with a valid config" do
|
9
|
+
setup do
|
10
|
+
@store = TC.new :file => "test.tct"
|
11
|
+
end
|
12
|
+
|
13
|
+
should "be valid" do
|
14
|
+
assert @store.valid?
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context "without a valid config" do
|
19
|
+
setup do
|
20
|
+
@store = TC.new
|
21
|
+
end
|
22
|
+
|
23
|
+
should "not be valid" do
|
24
|
+
assert !@store.valid?
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
context "a tokyo tyrant store" do
|
30
|
+
context "with a valid config" do
|
31
|
+
setup do
|
32
|
+
@store = TY.new({
|
33
|
+
:host => "localhost",
|
34
|
+
:port => 45002
|
35
|
+
})
|
36
|
+
end
|
37
|
+
|
38
|
+
should "be valid" do
|
39
|
+
assert @store.valid?
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context "without a valid config" do
|
44
|
+
setup do
|
45
|
+
@store = TY.new
|
46
|
+
end
|
47
|
+
|
48
|
+
should "not be valid" do
|
49
|
+
assert !@store.valid?
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
data/test/yuki_test.rb
ADDED
@@ -0,0 +1,160 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), *%w(helper))
|
2
|
+
|
3
|
+
class Ninja
|
4
|
+
include Yuki
|
5
|
+
store :cabinet, :file => File.join(
|
6
|
+
File.dirname(__FILE__), *%w(data test.tct)
|
7
|
+
)
|
8
|
+
has :weapon
|
9
|
+
has :mp, :numeric
|
10
|
+
has :last_kill, :timestamp
|
11
|
+
end
|
12
|
+
|
13
|
+
class YukiTest < Test::Unit::TestCase
|
14
|
+
context "a model's attributes" do
|
15
|
+
|
16
|
+
should "have a default object type of string" do
|
17
|
+
assert_equal '3', Ninja.new(:weapon => 3).weapon
|
18
|
+
end
|
19
|
+
|
20
|
+
should "provide a option for defaulting values" do
|
21
|
+
class Paint
|
22
|
+
include Yuki
|
23
|
+
has :color, :default => "red"
|
24
|
+
end
|
25
|
+
|
26
|
+
assert_equal "red", Paint.new.color
|
27
|
+
end
|
28
|
+
|
29
|
+
should "be typed" do
|
30
|
+
kill_time = Time.now
|
31
|
+
|
32
|
+
object = Ninja.new({
|
33
|
+
:weapon => "sword", # string
|
34
|
+
:mp => "45", # numeric
|
35
|
+
:last_kill => kill_time.to_i.to_s # timestamp
|
36
|
+
})
|
37
|
+
|
38
|
+
assert_equal "sword", object.weapon
|
39
|
+
assert_equal 45, object.mp
|
40
|
+
assert_equal kill_time.to_s, object.last_kill.to_s
|
41
|
+
end
|
42
|
+
|
43
|
+
should "serialize and deserialize with type" do
|
44
|
+
kill_time = Time.now.freeze
|
45
|
+
|
46
|
+
ninja = Ninja.new({
|
47
|
+
:weapon => 'sword', #string
|
48
|
+
:mp => 45, # numeric
|
49
|
+
:last_kill => kill_time # timestamp
|
50
|
+
}).save!
|
51
|
+
|
52
|
+
object = Ninja.get(ninja.key)
|
53
|
+
assert_equal "sword", object.weapon
|
54
|
+
assert_equal 45, object.mp
|
55
|
+
assert_equal kill_time.to_s, object.last_kill.to_s
|
56
|
+
end
|
57
|
+
|
58
|
+
should "be queryable" do
|
59
|
+
ninja = Ninja.new(:weapon => 'knife')
|
60
|
+
assert ninja.weapon?
|
61
|
+
assert !ninja.mp?
|
62
|
+
assert !ninja.last_kill?
|
63
|
+
end
|
64
|
+
|
65
|
+
should "should be immutable by default" do
|
66
|
+
class ImmutableNinja
|
67
|
+
include Yuki
|
68
|
+
has :weapon
|
69
|
+
end
|
70
|
+
|
71
|
+
assert !ImmutableNinja.new.respond_to?(:weapon=)
|
72
|
+
end
|
73
|
+
|
74
|
+
should "provide an option for mutablility" do
|
75
|
+
class MutableNinja
|
76
|
+
include Yuki
|
77
|
+
has :weapon, :mutable => true
|
78
|
+
end
|
79
|
+
|
80
|
+
assert MutableNinja.new.respond_to?(:weapon=)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
context "a model's lifecyle operations" do
|
85
|
+
should "provide callbacks" do
|
86
|
+
|
87
|
+
class Sensei < Ninja
|
88
|
+
attr_accessor :bs, :as, :bd, :ad
|
89
|
+
def initialize
|
90
|
+
@bs, @as, @bd, @ad = false, false, false, false
|
91
|
+
end
|
92
|
+
def before_save; @bs = true; end
|
93
|
+
def after_save; @as = true; end
|
94
|
+
def before_delete; @bd = true; end
|
95
|
+
def after_delete; @ad = true; end
|
96
|
+
end
|
97
|
+
|
98
|
+
object = Sensei.new(:weapon => 'test')
|
99
|
+
object.save!
|
100
|
+
object.delete!
|
101
|
+
|
102
|
+
[:bs, :as, :bd, :ad].each { |cb| assert object.send(cb) }
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
context "a model's serialized type" do
|
107
|
+
should "default to the model's class name" do
|
108
|
+
module Foo
|
109
|
+
class Bar
|
110
|
+
include Yuki
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
assert "YukiTest::Foo::Bar", Foo::Bar.new.to_h['type']
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
context "a model's api methods" do
|
119
|
+
should "provide a creation method" do
|
120
|
+
assert Ninja.new.respond_to?(:save!)
|
121
|
+
end
|
122
|
+
|
123
|
+
should "provide an update method" do
|
124
|
+
assert Ninja.respond_to?(:put)
|
125
|
+
ninja = Ninja.new.save!
|
126
|
+
ninja = Ninja.put(ninja.key, { 'mp' => 6 })
|
127
|
+
assert_equal(6, ninja.mp)
|
128
|
+
end
|
129
|
+
|
130
|
+
should "provide a deletion method" do
|
131
|
+
ninja = Ninja.new.save!
|
132
|
+
assert ninja.respond_to?(:delete!)
|
133
|
+
ninja.delete!
|
134
|
+
assert !Ninja.get(ninja.key)
|
135
|
+
end
|
136
|
+
|
137
|
+
should "provide query method(s)" do
|
138
|
+
# write me
|
139
|
+
end
|
140
|
+
|
141
|
+
end
|
142
|
+
|
143
|
+
context "an auditable model" do
|
144
|
+
should "be soft deleted" do
|
145
|
+
class ImmortalNinja
|
146
|
+
include Yuki
|
147
|
+
store :cabinet, :file => File.join(
|
148
|
+
File.dirname(__FILE__), *%w(data test.tct)
|
149
|
+
)
|
150
|
+
has :mp, :numeric
|
151
|
+
soft_delete!
|
152
|
+
end
|
153
|
+
|
154
|
+
ninja = ImmortalNinja.new(:mp => 6).save!
|
155
|
+
ninja.delete!
|
156
|
+
ninja = Ninja.get(ninja.key)
|
157
|
+
assert ninja.deleted?
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
data/yuki.rb
ADDED
metadata
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: yuki
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- softprops
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-11-18 00:00:00 -05:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description: A Toyko model
|
17
|
+
email: d.tangren@gmail.com
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files:
|
23
|
+
- LICENSE
|
24
|
+
- README.md
|
25
|
+
files:
|
26
|
+
- .gitignore
|
27
|
+
- LICENSE
|
28
|
+
- README.md
|
29
|
+
- Rakefile
|
30
|
+
- VERSION
|
31
|
+
- lib/cast_system.rb
|
32
|
+
- lib/store/abstract.rb
|
33
|
+
- lib/store/cabinet.rb
|
34
|
+
- lib/store/tyrant.rb
|
35
|
+
- lib/yuki.rb
|
36
|
+
- test/cast_system_test.rb
|
37
|
+
- test/helper.rb
|
38
|
+
- test/store_test.rb
|
39
|
+
- test/yuki_test.rb
|
40
|
+
- yuki.rb
|
41
|
+
has_rdoc: true
|
42
|
+
homepage: http://github.com/softprops/yuki
|
43
|
+
licenses: []
|
44
|
+
|
45
|
+
post_install_message:
|
46
|
+
rdoc_options:
|
47
|
+
- --charset=UTF-8
|
48
|
+
require_paths:
|
49
|
+
- lib
|
50
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: "0"
|
55
|
+
version:
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: "0"
|
61
|
+
version:
|
62
|
+
requirements: []
|
63
|
+
|
64
|
+
rubyforge_project:
|
65
|
+
rubygems_version: 1.3.5
|
66
|
+
signing_key:
|
67
|
+
specification_version: 3
|
68
|
+
summary: A Toyko model
|
69
|
+
test_files:
|
70
|
+
- test/cast_system_test.rb
|
71
|
+
- test/helper.rb
|
72
|
+
- test/store_test.rb
|
73
|
+
- test/yuki_test.rb
|