yuki 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/.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
|