yuki 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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