sencha-model 0.5.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.
@@ -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
+