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 ADDED
@@ -0,0 +1,3 @@
1
+ .DS_Store
2
+ pkg
3
+ yuki.gemspec
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
@@ -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
@@ -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
@@ -0,0 +1,4 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ require File.join(File.dirname(__FILE__), *%w(.. lib yuki))
@@ -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
@@ -0,0 +1,2 @@
1
+ $: < File.join(File.dirname(__FILE__), 'lib')
2
+ require 'yuki'
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