sencha-model 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,373 @@
1
+ module Sencha
2
+ module Model
3
+
4
+ def self.included(model)
5
+ model.send(:extend, ClassMethods)
6
+ model.send(:include, InstanceMethods)
7
+ ##
8
+ # @config {String} sencha_parent_trail_template This a template used to render mapped field-names.
9
+ # Default is Proc.new{ |field_name| "_#{field_name}" }
10
+ # You could also use the Rails standard
11
+ # Proc.new{ |field_name| "[#{field_name}]" }
12
+ #
13
+ model.cattr_accessor :sencha_parent_trail_template
14
+ model.sencha_parent_trail_template = Proc.new{ |field_name| "_#{field_name}" } if model.sencha_parent_trail_template.nil?
15
+ end
16
+
17
+ ##
18
+ # InstanceMethods
19
+ #
20
+ module InstanceMethods
21
+
22
+ ##
23
+ # Converts a model instance to a record compatible with javascript applications
24
+ #
25
+ # The first parameter should be the fieldset for which the record will be returned.
26
+ # If no parameter is provided, then the default fieldset will be choosen
27
+ # Alternativly the first parameter can be a Hash with a :fields member to directly specify
28
+ # the fields to use for the record.
29
+ #
30
+ # All these are valid calls:
31
+ #
32
+ # user.to_record # returns record for :default fieldset
33
+ # # (fieldset is autmatically defined, if not set)
34
+ #
35
+ # user.to_record :fieldset # returns record for :fieldset fieldset
36
+ # # (fieldset is autmatically defined, if not set)
37
+ #
38
+ # user.to_record :fields => [:id, :password]
39
+ # # returns record for the fields 'id' and 'password'
40
+ #
41
+ # For even more valid options for this method (which all should not be neccessary to use)
42
+ # have a look at Whorm::Model::Util.extract_fieldset_and_options
43
+ def to_record(*params)
44
+ fieldset, options = Util.extract_fieldset_and_options params
45
+
46
+ fields = []
47
+ if options[:fields].empty?
48
+ fields = self.class.sencha_get_fields_for_fieldset(fieldset)
49
+ else
50
+ fields = self.class.process_fields(*options[:fields])
51
+ end
52
+
53
+ assns = self.class.sencha_associations
54
+ pk = self.class.sencha_primary_key
55
+
56
+ # build the initial field data-hash
57
+ data = {pk => self.send(pk)}
58
+
59
+ fields.each do |field|
60
+ next if data.has_key? field[:name] # already processed (e.g. explicit mentioning of :id)
61
+
62
+ value = nil
63
+ if association_reflection = assns[field[:name]] # if field is an association
64
+ association = self.send(field[:name])
65
+
66
+ # skip this association if we already visited it
67
+ # otherwise we could end up in a cyclic reference
68
+ next if options[:visited_classes].include? association.class
69
+
70
+ case association_reflection[:type]
71
+ when :belongs_to, :has_one
72
+ if association.respond_to? :to_record
73
+ assn_fields = field[:fields]
74
+ if assn_fields.nil?
75
+ assn_fields = association.class.sencha_get_fields_for_fieldset(field.fetch(:fieldset, fieldset))
76
+ end
77
+
78
+ value = association.to_record :fields => assn_fields,
79
+ :visited_classes => options[:visited_classes] + [self.class]
80
+ else
81
+ value = {}
82
+ (field[:fields]||[]).each do |sub_field|
83
+ value[sub_field[:name]] = association.send(sub_field[:name]) if association.respond_to? sub_field[:name]
84
+ end
85
+ end
86
+ if association_reflection[:type] == :belongs_to
87
+ # Append associations foreign_key to data
88
+ data[association_reflection[:foreign_key]] = self.send(association_reflection[:foreign_key])
89
+ if association_reflection[:is_polymorphic]
90
+ foreign_type = self.class.sencha_polymorphic_type(association_reflection[:foreign_key])
91
+ data[foreign_type] = self.send(foreign_type)
92
+ end
93
+ end
94
+ when :many
95
+ value = association.collect { |r| r.to_record } # use carefully, can get HUGE
96
+ end
97
+ else # not an association -> get the method's value
98
+ value = self.send(field[:name])
99
+ value = value.to_record if value.respond_to? :to_record
100
+ end
101
+ data[field[:name]] = value
102
+ end
103
+ data
104
+ end
105
+ end
106
+
107
+ ##
108
+ # ClassMethods
109
+ #
110
+ module ClassMethods
111
+ ##
112
+ # render AR columns to Ext.data.Record.create format
113
+ # eg: {name:'foo', type: 'string'}
114
+ #
115
+ # The first parameter should be the fieldset for which the record definition will be returned.
116
+ # If no parameter is provided, then the default fieldset will be choosen
117
+ # Alternativly the first parameter can be a Hash with a :fields member to directly specify
118
+ # the fields to use for the record config.
119
+ #
120
+ # All these are valid calls:
121
+ #
122
+ # User.sencha_schema # returns record config for :default fieldset
123
+ # # (fieldset is autmatically defined, if not set)
124
+ #
125
+ # User.sencha_schema :fieldset # returns record config for :fieldset fieldset
126
+ # # (fieldset is autmatically defined, if not set)
127
+ #
128
+ # User.sencha_schema :fields => [:id, :password]
129
+ # # returns record config for the fields 'id' and 'password'
130
+ #
131
+ # For even more valid options for this method (which all should not be neccessary to use)
132
+ # have a look at Whorm::Model::Util.extract_fieldset_and_options
133
+ def sencha_schema(*params)
134
+ fieldset, options = Util.extract_fieldset_and_options params
135
+
136
+ if options[:fields].empty?
137
+ fields = self.sencha_get_fields_for_fieldset(fieldset)
138
+ else
139
+ fields = self.process_fields(*options[:fields])
140
+ end
141
+
142
+ associations = self.sencha_associations
143
+ columns = self.sencha_columns_hash
144
+ pk = self.sencha_primary_key
145
+ rs = []
146
+
147
+ fields.each do |field|
148
+
149
+ field = Marshal.load(Marshal.dump(field)) # making a deep copy
150
+
151
+ if col = columns[field[:name]] # <-- column on this model
152
+ rs << self.sencha_field(field, col)
153
+ elsif assn = associations[field[:name]]
154
+ # skip this association if we already visited it
155
+ # otherwise we could end up in a cyclic reference
156
+ next if options[:visited_classes].include? assn[:class]
157
+
158
+ assn_fields = field[:fields]
159
+ if assn[:class].respond_to?(:sencha_schema) # <-- exec sencha_schema on assn Model.
160
+ if assn_fields.nil?
161
+ assn_fields = assn[:class].sencha_get_fields_for_fieldset(field.fetch(:fieldset, fieldset))
162
+ end
163
+
164
+ record = assn[:class].sencha_schema(field.fetch(:fieldset, fieldset), { :visited_classes => options[:visited_classes] + [self], :fields => assn_fields})
165
+ rs.concat(record[:fields].collect { |assn_field|
166
+ self.sencha_field(assn_field, :parent_trail => field[:name], :mapping => field[:name], :allowBlank => true) # <-- allowBlank on associated data?
167
+ })
168
+ elsif assn_fields # <-- :parent => [:id, :name, :sub => [:id, :name]]
169
+ field_collector = Proc.new do |parent_trail, mapping, assn_field|
170
+ if assn_field.is_a?(Hash) && assn_field.keys.size == 1 && assn_field.keys[0].is_a?(Symbol) && assn_field.values[0].is_a?(Array)
171
+ field_collector.call(parent_trail.to_s + self.sencha_parent_trail_template.call(assn_field.keys.first), "#{mapping}.#{assn_field.keys.first}", assn_field.values.first)
172
+ else
173
+ self.sencha_field(assn_field, :parent_trail => parent_trail, :mapping => mapping, :allowBlank => true)
174
+ end
175
+ end
176
+ rs.concat(assn_fields.collect { |assn_field| field_collector.call(field[:name], field[:name], assn_field) })
177
+ else
178
+ rs << sencha_field(field)
179
+ end
180
+
181
+ # attach association's foreign_key if not already included.
182
+ if columns.has_key?(assn[:foreign_key]) && !rs.any? { |r| r[:name] == assn[:foreign_key] }
183
+ rs << sencha_field({:name => assn[:foreign_key]}, columns[assn[:foreign_key]])
184
+ end
185
+ # attach association's type if polymorphic association and not alredy included
186
+ if assn[:is_polymorphic]
187
+ foreign_type = self.sencha_polymorphic_type(assn[:foreign_key])
188
+ if columns.has_key?(foreign_type) && !rs.any? { |r| r[:name] == foreign_type }
189
+ rs << sencha_field({:name => foreign_type}, columns[foreign_type])
190
+ end
191
+ end
192
+ else # property is a method?
193
+ rs << sencha_field(field)
194
+ end
195
+ end
196
+
197
+ return {
198
+ :fields => rs,
199
+ :idProperty => pk,
200
+ :associations => associations.keys.map {|a| { # <-- New, experimental for ExtJS-4.0
201
+ :type => associations[a][:type],
202
+ :model => associations[a][:class].to_s,
203
+ :name => associations[a][:class].to_s.downcase.pluralize
204
+ }}
205
+ }
206
+ end
207
+
208
+ ##
209
+ # meant to be used within a Model to define the sencha record fields.
210
+ # eg:
211
+ # class User
212
+ # sencha_fieldset :grid, [:first, :last, :email => {"sortDir" => "ASC"}, :company => [:id, :name]]
213
+ # end
214
+ # or
215
+ # class User
216
+ # sencha_fieldset :last, :email => {"sortDir" => "ASC"}, :company => [:id, :name] # => implies fieldset name :default
217
+ # end
218
+ #
219
+ def sencha_fieldset(*params)
220
+ fieldset, options = Util.extract_fieldset_and_options params
221
+ var_name = :"@sencha_fieldsets__#{fieldset}"
222
+ self.instance_variable_set( var_name, self.process_fields(*options[:fields]) )
223
+ end
224
+
225
+ def sencha_get_fields_for_fieldset(fieldset)
226
+ var_name = :"@sencha_fieldsets__#{fieldset}"
227
+ super_value = nil
228
+ unless self.instance_variable_get( var_name )
229
+ if self.superclass.respond_to? :sencha_get_fields_for_fieldset
230
+ super_value = self.superclass.sencha_get_fields_for_fieldset(fieldset)
231
+ end
232
+ self.sencha_fieldset(fieldset, self.sencha_column_names) unless super_value
233
+ end
234
+ super_value || self.instance_variable_get( var_name )
235
+ end
236
+
237
+ ##
238
+ # shortcut to define the default fieldset. For backwards-compatibility.
239
+ #
240
+ def sencha_fields(*params)
241
+ self.sencha_fieldset(:default, {
242
+ :fields => params
243
+ })
244
+ end
245
+
246
+ ##
247
+ # Prepare a field configuration list into a normalized array of Hashes, {:name => "field_name"}
248
+ # @param {Mixed} params
249
+ # @return {Array} of Hashes
250
+ #
251
+ def process_fields(*params)
252
+ fields = []
253
+ if params.size == 1 && params.last.is_a?(Hash) # peek into argument to see if its an option hash
254
+ options = params.last
255
+ if options.has_key?(:additional) && options[:additional].is_a?(Array)
256
+ return self.process_fields(*(self.sencha_column_names + options[:additional].map(&:to_sym)))
257
+ elsif options.has_key?(:exclude) && options[:exclude].is_a?(Array)
258
+ return self.process_fields(*(self.sencha_column_names - options[:exclude].map(&:to_sym)))
259
+ elsif options.has_key?(:only) && options[:only].is_a?(Array)
260
+ return self.process_fields(*options[:only])
261
+ end
262
+ end
263
+
264
+ params = self.sencha_column_names if params.empty?
265
+
266
+ params.each do |f|
267
+ if f.kind_of?(Hash)
268
+ if f.keys.size == 1 && f.keys[0].is_a?(Symbol) && f.values[0].is_a?(Array) # {:association => [:field1, :field2]}
269
+ fields << {
270
+ :name => f.keys[0],
271
+ :fields => process_fields(*f.values[0])
272
+ }
273
+ elsif f.keys.size == 1 && f.keys[0].is_a?(Symbol) && f.values[0].is_a?(Hash) # {:field => {:sortDir => 'ASC'}}
274
+ fields << f.values[0].update(:name => f.keys[0])
275
+ elsif f.has_key?(:name) # already a valid Hash, just copy it over
276
+ fields << f
277
+ else
278
+ raise ArgumentError, "encountered a Hash that I don't know anything to do with `#{f.inspect}:#{f.class}`"
279
+ end
280
+ else # should be a String or Symbol
281
+ raise ArgumentError, "encountered a fields Array that I don't understand: #{params.inspect} -- `#{f.inspect}:#{f.class}` is not a Symbol or String" unless f.is_a?(Symbol) || f.is_a?(String)
282
+ fields << {:name => f.to_sym}
283
+ end
284
+ end
285
+
286
+ fields
287
+ end
288
+
289
+ ##
290
+ # Render a column-config object
291
+ # @param {Hash/Column} field Field-configuration Hash, probably has :name already set and possibly Ext.data.Field options.
292
+ # @param {ORM Column Object from AR, DM or MM}
293
+ #
294
+ def sencha_field(field, config=nil)
295
+ if config.kind_of? Hash
296
+ if config.has_key?(:mapping) && config.has_key?(:parent_trail)
297
+ field.update( # <-- We use a template for rendering mapped field-names.
298
+ :name => config[:parent_trail].to_s + self.sencha_parent_trail_template.call(field[:name]),
299
+ :mapping => "#{config[:mapping]}.#{field[:name]}"
300
+ )
301
+ end
302
+ field.update(config.except(:mapping, :parent_trail))
303
+ elsif !config.nil? # <-- Hopfully an ORM Column object.
304
+ field.update(
305
+ :allowBlank => self.sencha_allow_blank(config),
306
+ :type => self.sencha_type(config),
307
+ :defaultValue => self.sencha_default(config)
308
+ )
309
+ field[:dateFormat] = "c" if field[:type] === "date" && field[:dateFormat].nil? # <-- ugly hack for date
310
+ end
311
+ field.update(:type => "auto") if field[:type].nil?
312
+ # convert Symbol values to String values
313
+ field.keys.each do |k|
314
+ raise ArgumentError, "sencha_field expects a Hash as first parameter with all it's keys Symbols. Found key #{k.inspect}:#{k.class.to_s}" unless k.is_a?(Symbol)
315
+ field[k] = field[k].to_s if field[k].is_a?(Symbol)
316
+ end
317
+ field
318
+ end
319
+
320
+ # ##
321
+ # # Returns an array of symbolized association names that will be referenced by a call to to_record
322
+ # # i.e. [:parent1, :parent2]
323
+ # #
324
+ # def sencha_used_associations
325
+ # if @sencha_used_associations.nil?
326
+ # assoc = []
327
+ # self.sencha_record_fields.each do |f|
328
+ # #This needs to be the first condition because the others will break if f is an Array
329
+ # if sencha_associations[f[:name]]
330
+ # assoc << f[:name]
331
+ # end
332
+ # end
333
+ # @sencha_used_associations = assoc.uniq
334
+ # end
335
+ # @sencha_used_associations
336
+ # end
337
+ end
338
+
339
+ module Util
340
+
341
+ ##
342
+ # returns the fieldset from the arguments and normalizes the options.
343
+ # @return [{Symbol}, {Hash}]
344
+ def self.extract_fieldset_and_options arguments
345
+ orig_args = arguments
346
+ fieldset = :default
347
+ options = { # default options
348
+ :visited_classes => [],
349
+ :fields => []
350
+ }
351
+ if arguments.size > 2 || (arguments.size == 2 && !arguments[0].is_a?(Symbol))
352
+ raise ArgumentError, "Don't know how to handle #{arguments.inspect}"
353
+ elsif arguments.size == 2 && arguments[0].is_a?(Symbol)
354
+ fieldset = arguments.shift
355
+ if arguments[0].is_a?(Array)
356
+ options.update({
357
+ :fields => arguments[0]
358
+ })
359
+ elsif arguments[0].is_a?(Hash)
360
+ options.update(arguments[0])
361
+ end
362
+ elsif arguments.size == 1 && (arguments[0].is_a?(Symbol) || arguments[0].is_a?(String))
363
+ fieldset = arguments.shift.to_sym
364
+ elsif arguments.size == 1 && arguments[0].is_a?(Hash)
365
+ fieldset = arguments[0].delete(:fieldset) || :default
366
+ options.update(arguments[0])
367
+ end
368
+ [fieldset, options]
369
+ end
370
+ end
371
+ end
372
+ end
373
+
@@ -0,0 +1,5 @@
1
+ module Sencha
2
+ module Model
3
+ VERSION = "0.5.0"
4
+ end
5
+ end
data/rails/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'sencha-model'
@@ -0,0 +1,99 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "sencha-model/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "sencha-model"
7
+ s.version = Sencha::Model::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Chris Scott"]
10
+ s.email = ["christocracy@gmail.com"]
11
+ s.homepage = "http://www.440solutions.com"
12
+ s.summary = %q{This gem auto-generates ExtJS compatible model specifications from your ORM (eg: ActiveRecord, DataMapper, MongoMapper)}
13
+ s.description = %q{This gem auto-generates ExtJS compatible model specifications from your ORM (eg: ActiveRecord, DataMapper, MongoMapper)}
14
+
15
+ s.add_development_dependency "shoulda"
16
+ s.add_development_dependency "mocha"
17
+ s.add_development_dependency "extlib"
18
+ s.add_development_dependency "activerecord", ">= 3.0.0"
19
+
20
+ s.files = `git ls-files`.split("\n")
21
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
22
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
23
+ s.require_paths = ["lib"]
24
+ end
25
+
26
+
27
+
28
+ # Generated by jeweler
29
+ # DO NOT EDIT THIS FILE DIRECTLY
30
+ # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
31
+ # -*- encoding: utf-8 -*-
32
+
33
+ #Gem::Specification.new do |s|
34
+ # s.name = %q{whorm}
35
+ # s.version = "0.4.0"
36
+
37
+ # s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
38
+ # s.authors = ["Chris Scott"]
39
+ # s.date = %q{2010-03-09}
40
+ # s.description = %q{Whorm contains a Model-mixin named Whorm::Model. Once included, your Model now exposes a class-method named #whorm_schema which will return Hash representation of the Model-schema.}
41
+ # s.email = %q{christocracy@gmail.com}
42
+ # s.extra_rdoc_files = [
43
+ ## "LICENSE",
44
+ # "README.rdoc"
45
+ # ]
46
+ # s.files = [
47
+ # "LICENSE",
48
+ # "README.rdoc",
49
+ # "Rakefile",
50
+ # "VERSION",
51
+ # "lib/test/macros.rb",
52
+ # "lib/whorm.rb",
53
+ # "lib/whorm/adapters/active_record.rb",
54
+ # "lib/whorm/adapters/data_mapper.rb",
55
+ # "lib/whorm/adapters/mongo_mapper.rb",
56
+ # "lib/whorm/model.rb",
57
+ # "test/active_record_test.rb",
58
+ # "test/app/config/application.rb",
59
+ # "test/app/config/database.yml",
60
+ # "test/app/db/schema.rb",
61
+ # "test/app/models/active_record/address.rb",
62
+ # "test/app/models/active_record/data_type.rb",
63
+ # "test/app/models/active_record/group.rb",
64
+ # "test/app/models/active_record/house.rb",
65
+ # "test/app/models/active_record/location.rb",
66
+ # "test/app/models/active_record/person.rb",
67
+ # "test/app/models/active_record/user.rb",
68
+ # "test/app/models/active_record/user_group.rb",
69
+ # "test/data_mapper_test.rb",
70
+ # "test/model_test.rb",
71
+ # "test/mongo_mapper_test.rb",
72
+ # "test/test_helper.rb"
73
+ # ]
74
+ # s.homepage = %q{http://github.com/christocracy/whorm}
75
+ # s.rdoc_options = ["--charset=UTF-8"]
76
+ # s.require_paths = ["lib"]
77
+ # s.rubygems_version = %q{1.3.6}
78
+ # s.summary = %q{Ruby ORM-inspecting tools to assist with generating JSON representations of database schemas and recordsets.}
79
+
80
+ # if s.respond_to? :specification_version then
81
+ # current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
82
+ # s.specification_version = 3
83
+
84
+ # if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
85
+ # s.add_development_dependency(%q<shoulda>, [">= 0"])
86
+ # s.add_development_dependency(%q<mocha>, [">= 0"])
87
+ # s.add_development_dependency(%q<extlib>, [">= 0"])
88
+ # else
89
+ # s.add_dependency(%q<shoulda>, [">= 0"])
90
+ ## s.add_dependency(%q<mocha>, [">= 0"])
91
+ # s.add_dependency(%q<extlib>, [">= 0"])
92
+ # end
93
+ # else
94
+ # s.add_dependency(%q<shoulda>, [">= 0"])
95
+ # s.add_dependency(%q<mocha>, [">= 0"])
96
+ # s.add_dependency(%q<extlib>, [">= 0"])
97
+ # end
98
+ #end
99
+