autoforme 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,344 @@
1
+ module AutoForme
2
+ module Models
3
+ # Sequel specific model class for AutoForme
4
+ class Sequel < Model
5
+ # Short reference to top level Sequel module, for easily calling methods
6
+ S = ::Sequel
7
+
8
+ # What association types to recognize. Other association types are ignored.
9
+ SUPPORTED_ASSOCIATION_TYPES = [:many_to_one, :one_to_one, :one_to_many, :many_to_many]
10
+
11
+ # Make sure the forme plugin is loaded into the model.
12
+ def initialize(*)
13
+ super
14
+ @model.plugin :forme
15
+ end
16
+
17
+ # The base class for the underlying model, ::Sequel::Model.
18
+ def base_class
19
+ S::Model
20
+ end
21
+
22
+ # A completely empty search object, with no defaults.
23
+ def new_search
24
+ @model.call({})
25
+ end
26
+
27
+ # The name of the form param for the given association.
28
+ def form_param_name(assoc)
29
+ "#{model.send(:underscore, model.name)}[#{association_key(assoc)}]"
30
+ end
31
+
32
+ # Set the fields for the given action type to the object based on the request params.
33
+ def set_fields(obj, type, request, params)
34
+ columns_for(type, request).each do |col|
35
+ column = col
36
+
37
+ if association?(col)
38
+ ref = model.association_reflection(col)
39
+ ds = ref.associated_dataset
40
+ if model_class = associated_model_class(col)
41
+ ds = model_class.apply_filter(:association, request, ds)
42
+ end
43
+
44
+ if v = params[ref[:key]]
45
+ v = ds.first!(S.qualify(ds.model.table_name, ref.primary_key)=>v)
46
+ end
47
+ else
48
+ v = params[col]
49
+ end
50
+
51
+ obj.send("#{column}=", v)
52
+ end
53
+ end
54
+
55
+ # Whether the column represents an association.
56
+ def association?(column)
57
+ case column
58
+ when String
59
+ model.associations.map{|x| x.to_s}.include?(column)
60
+ else
61
+ model.association_reflection(column)
62
+ end
63
+ end
64
+
65
+ # The associated class for the given association
66
+ def associated_class(assoc)
67
+ model.association_reflection(assoc).associated_class
68
+ end
69
+
70
+ # A short type for the association, either :one for a
71
+ # singular association, :new for an association where
72
+ # you can create new objects, or :edit for association
73
+ # where you can add/remove members from the association.
74
+ def association_type(assoc)
75
+ case model.association_reflection(assoc)[:type]
76
+ when :many_to_one, :one_to_one
77
+ :one
78
+ when :one_to_many
79
+ :new
80
+ when :many_to_many
81
+ :edit
82
+ end
83
+ end
84
+
85
+ # The foreign key column for the given many to one association.
86
+ def association_key(assoc)
87
+ model.association_reflection(assoc)[:key]
88
+ end
89
+
90
+ # An array of pairs mapping foreign keys in associated class
91
+ # to primary key value of current object
92
+ def associated_new_column_values(obj, assoc)
93
+ ref = model.association_reflection(assoc)
94
+ ref[:keys].zip(ref[:primary_keys].map{|k| obj.send(k)})
95
+ end
96
+
97
+ # Array of many to many association name strings.
98
+ def mtm_association_names
99
+ association_names([:many_to_many])
100
+ end
101
+
102
+ # Array of association name strings for given association types
103
+ def association_names(types=SUPPORTED_ASSOCIATION_TYPES)
104
+ model.all_association_reflections.select{|r| types.include?(r[:type])}.map{|r| r[:name]}.sort_by{|n| n.to_s}
105
+ end
106
+
107
+ # Save the object, returning the object if successful, or nil if not.
108
+ def save(obj)
109
+ obj.raise_on_save_failure = false
110
+ obj.save
111
+ end
112
+
113
+ # The primary key value for the given object.
114
+ def primary_key_value(obj)
115
+ obj.pk
116
+ end
117
+
118
+ # The namespace for form parameter names for this model, needs to match
119
+ # the ones automatically used by Forme.
120
+ def params_name
121
+ @model.send(:underscore, @model.name)
122
+ end
123
+
124
+ # Retrieve underlying model instance with matching primary key
125
+ def with_pk(type, request, pk)
126
+ dataset_for(type, request).with_pk!(pk)
127
+ end
128
+
129
+ # Retrieve all matching rows for this model.
130
+ def all_rows_for(type, request)
131
+ all_dataset_for(type, request).all
132
+ end
133
+
134
+ # Return the default columns for this model
135
+ def default_columns
136
+ columns = model.columns - Array(model.primary_key)
137
+ model.all_association_reflections.each do |reflection|
138
+ next unless reflection[:type] == :many_to_one
139
+ if i = columns.index(reflection[:key])
140
+ columns[i] = reflection[:name]
141
+ end
142
+ end
143
+ columns.sort_by{|s| s.to_s}
144
+ end
145
+
146
+ # Add a filter restricting access to only rows where the column name
147
+ # matching the session value. Also add a before_create hook that sets
148
+ # the column value to the session value.
149
+ def session_value(column)
150
+ filter do |ds, type, req|
151
+ ds.where(S.qualify(model.table_name, column)=>req.session[column])
152
+ end
153
+ before_create do |obj, req|
154
+ obj.send("#{column}=", req.session[column])
155
+ end
156
+ end
157
+
158
+ # Returning array of matching objects for the current search page using the given parameters.
159
+ def search_results(type, request)
160
+ params = request.params
161
+ ds = apply_associated_eager(:search, request, all_dataset_for(type, request))
162
+ columns_for(:search_form, request).each do |c|
163
+ if (v = params[c]) && !v.empty?
164
+ if association?(c)
165
+ ref = model.association_reflection(c)
166
+ ads = ref.associated_dataset
167
+ if model_class = associated_model_class(c)
168
+ ads = model_class.apply_filter(:association, request, ads)
169
+ end
170
+ primary_key = S.qualify(ref.associated_class.table_name, ref.primary_key)
171
+ ds = ds.where(ref[:key]=>ads.where(primary_key=>v).select(primary_key))
172
+ elsif column_type(c) == :string
173
+ ds = ds.where(S.ilike(c, "%#{ds.escape_like(v.to_s)}%"))
174
+ else
175
+ ds = ds.where(c=>v.to_s)
176
+ end
177
+ end
178
+ end
179
+ paginate(type, request, ds)
180
+ end
181
+
182
+ # Return array of matching objects for the current page.
183
+ def browse(type, request)
184
+ paginate(type, request, apply_associated_eager(:browse, request, all_dataset_for(type, request)))
185
+ end
186
+
187
+ # Do very simple pagination, by selecting one more object than necessary,
188
+ # and noting if there is a next page by seeing if more objects are returned than the limit.
189
+ def paginate(type, request, ds)
190
+ limit = limit_for(type, request)
191
+ offset = ((request.id.to_i||1)-1) * limit
192
+ objs = ds.limit(limit+1, (offset if offset > 0)).all
193
+ next_page = false
194
+ if objs.length > limit
195
+ next_page = true
196
+ objs.pop
197
+ end
198
+ [next_page, objs]
199
+ end
200
+
201
+ # On the browse/search results pages, in addition to eager loading based on the current model's eager
202
+ # loading config, also eager load based on the associated models config.
203
+ def apply_associated_eager(type, request, ds)
204
+ columns_for(type, request).each do |col|
205
+ if association?(col)
206
+ if model = associated_model_class(col)
207
+ eager = model.eager_for(:association, request) || model.eager_graph_for(:association, request)
208
+ ds = ds.eager(col=>eager)
209
+ else
210
+ ds = ds.eager(col)
211
+ end
212
+ end
213
+ end
214
+ ds
215
+ end
216
+
217
+ # The schema type for the column
218
+ def column_type(column)
219
+ (sch = model.db_schema[column]) && sch[:type]
220
+ end
221
+
222
+ # Apply the model's filter to the given dataset
223
+ def apply_filter(type, request, ds)
224
+ if filter = filter_for
225
+ ds = filter.call(ds, type, request)
226
+ end
227
+ ds
228
+ end
229
+
230
+ # Apply the model's filter, eager, and order to the given dataset
231
+ def apply_dataset_options(type, request, ds)
232
+ ds = apply_filter(type, request, ds)
233
+ if order = order_for(type, request)
234
+ ds = ds.order(*order)
235
+ end
236
+ if eager = eager_for(type, request)
237
+ ds = ds.eager(eager)
238
+ end
239
+ if eager_graph = eager_graph_for(type, request)
240
+ ds = ds.eager_graph(eager_graph)
241
+ end
242
+ ds
243
+ end
244
+
245
+ # Whether to autocomplete for the given association.
246
+ def association_autocomplete?(assoc, request)
247
+ (c = associated_model_class(assoc)) && c.autocomplete_options_for(:association, request)
248
+ end
249
+
250
+ # Return array of autocompletion strings for the request. Options:
251
+ # :type :: Action type symbol
252
+ # :request :: AutoForme::Request instance
253
+ # :association :: Association symbol
254
+ # :query :: Query string submitted by the user
255
+ # :exclude :: Primary key value of current model, excluding already associated values (used when
256
+ # editing many to many associations)
257
+ def autocomplete(opts={})
258
+ type, request, assoc, query, exclude = opts.values_at(:type, :request, :association, :query, :exclude)
259
+ if assoc
260
+ if exclude && association_type(assoc) == :edit
261
+ ref = model.association_reflection(assoc)
262
+ block = lambda do |ds|
263
+ ds.exclude(S.qualify(ref.associated_class.table_name, ref.right_primary_key)=>model.db.from(ref[:join_table]).where(ref[:left_key]=>exclude).select(ref[:right_key]))
264
+ end
265
+ end
266
+ return associated_model_class(assoc).autocomplete(opts.merge(:type=>:association, :association=>nil), &block)
267
+ end
268
+ opts = autocomplete_options_for(type, request)
269
+ callback_opts = {:type=>type, :request=>request, :query=>query}
270
+ ds = all_dataset_for(type, request)
271
+ ds = opts[:callback].call(ds, callback_opts) if opts[:callback]
272
+ display = opts[:display] || S.qualify(model.table_name, :name)
273
+ display = display.call(callback_opts) if display.respond_to?(:call)
274
+ limit = opts[:limit] || 10
275
+ limit = limit.call(callback_opts) if limit.respond_to?(:call)
276
+ opts[:filter] ||= lambda{|ds, opts| ds.where(S.ilike(display, "%#{ds.escape_like(query)}%"))}
277
+ ds = opts[:filter].call(ds, callback_opts)
278
+ ds = ds.select(S.join([S.qualify(model.table_name, model.primary_key), display], ' - ').as(:v)).
279
+ limit(limit)
280
+ ds = yield ds if block_given?
281
+ ds.map(:v)
282
+ end
283
+
284
+ # Update the many to many association. add and remove should be arrays of primary key values
285
+ # of associated objects to add to the association.
286
+ def mtm_update(request, assoc, obj, add, remove)
287
+ ref = model.association_reflection(assoc)
288
+ assoc_class = associated_model_class(assoc)
289
+ ret = nil
290
+ model.db.transaction do
291
+ [[add, ref.add_method], [remove, ref.remove_method]].each do |ids, meth|
292
+ if ids
293
+ ids.each do |id|
294
+ next if id.to_s.empty?
295
+ ret = assoc_class ? assoc_class.with_pk(:association, request, id) : ref.associated_dataset.with_pk!(id)
296
+ obj.send(meth, ret)
297
+ end
298
+ end
299
+ end
300
+ end
301
+ ret
302
+ end
303
+
304
+ # The currently associated many to many objects for the association
305
+ def associated_mtm_objects(request, assoc, obj)
306
+ ds = obj.send("#{assoc}_dataset")
307
+ if assoc_class = associated_model_class(assoc)
308
+ ds = assoc_class.apply_dataset_options(:association, request, ds)
309
+ end
310
+ ds
311
+ end
312
+
313
+ # All objects in the associated table that are not currently associated to the given object.
314
+ def unassociated_mtm_objects(request, assoc, obj)
315
+ ref = model.association_reflection(assoc)
316
+ assoc_class = associated_model_class(assoc)
317
+ lambda do |ds|
318
+ subquery = model.db.from(ref[:join_table]).
319
+ select(ref.qualified_right_key).
320
+ where(ref.qualified_left_key=>obj.pk)
321
+ ds = ds.exclude(S.qualify(ref.associated_class.table_name, model.primary_key)=>subquery)
322
+ ds = assoc_class.apply_dataset_options(:association, request, ds) if assoc_class
323
+ ds
324
+ end
325
+ end
326
+
327
+ private
328
+
329
+ def dataset_for(type, request)
330
+ ds = @model.dataset
331
+ if filter = filter_for
332
+ ds = filter.call(ds, type, request)
333
+ end
334
+ ds
335
+ end
336
+
337
+ def all_dataset_for(type, request)
338
+ apply_dataset_options(type, request, @model.dataset)
339
+ end
340
+ end
341
+ end
342
+
343
+ register_model(:sequel, Models::Sequel)
344
+ end
@@ -0,0 +1,29 @@
1
+ module AutoForme
2
+ module OptsAttributes
3
+ # Setup methods for each given argument such that if the method is called with an argument or
4
+ # block, it sets the value of the related option to that argument or block. If called without
5
+ # an argument or block, it returns the stored option value.
6
+ def opts_attribute(*meths)
7
+ meths.each do |meth|
8
+ define_method(meth) do |*args, &block|
9
+ if block
10
+ if args.empty?
11
+ opts[meth] = block
12
+ else
13
+ raise ArgumentError, "No arguments allowed if passing a block"
14
+ end
15
+ end
16
+
17
+ case args.length
18
+ when 0
19
+ opts[meth]
20
+ when 1
21
+ opts[meth] = args.first
22
+ else
23
+ raise ArgumentError, "Only 0-1 arguments allowed"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,53 @@
1
+ module AutoForme
2
+ # Request wraps a specific web request for a given framework.
3
+ class Request
4
+ # The underlying web framework request instance for the request
5
+ attr_reader :controller
6
+
7
+ # The request method (GET or POST) for the request
8
+ attr_reader :method
9
+
10
+ # A string representing the model for the request
11
+ attr_reader :model
12
+
13
+ # A string representing the action type for the request
14
+ attr_reader :action_type
15
+
16
+ # A string representing the path that the root of
17
+ # the application is mounted at
18
+ attr_reader :path
19
+
20
+ # The id related to the request, which is usually the primary
21
+ # key of the related model instance, but for browse/search
22
+ # pages is used as the page
23
+ attr_reader :id
24
+
25
+ # The params for the current request
26
+ attr_reader :params
27
+
28
+ # The session variables for the current request
29
+ attr_reader :session
30
+
31
+ # Whether the current request used the POST HTTP method.
32
+ def post?
33
+ method == 'POST'
34
+ end
35
+
36
+ # The query string for the current request
37
+ def query_string
38
+ @env['QUERY_STRING']
39
+ end
40
+
41
+ # Set the flash at notice level when redirecting, so it shows
42
+ # up on the redirected page.
43
+ def set_flash_notice(message)
44
+ @controller.flash[:notice] = message
45
+ end
46
+
47
+ # Set the current flash at error level, used when displaying
48
+ # pages when there is an error.
49
+ def set_flash_now_error(message)
50
+ @controller.flash.now[:error] = message
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,70 @@
1
+ module AutoForme
2
+ # Helper class for formating HTML tables used for the browse/search results pages.
3
+ class Table
4
+ # The AutoForme::Action for the current table
5
+ attr_reader :action
6
+
7
+ # The AutoForme::Model for the current table
8
+ attr_reader :model
9
+
10
+ # The AutoForme::Request for the current table
11
+ attr_reader :request
12
+
13
+ # The action type for the current table
14
+ attr_reader :type
15
+
16
+ # The data columns for the current table
17
+ attr_reader :columns
18
+
19
+ # An array of objects to show in the table
20
+ attr_reader :objs
21
+
22
+ # Any options for the table
23
+ attr_reader :opts
24
+
25
+ def initialize(action, objs, opts={})
26
+ @action = action
27
+ @request = action.request
28
+ @model = action.model
29
+ @type = action.normalized_type
30
+ @columns = model.columns_for(type, request)
31
+ @objs = objs
32
+ @opts = opts
33
+ end
34
+
35
+ def h(s)
36
+ action.h(s)
37
+ end
38
+
39
+ # Return an HTML string for the table.
40
+ def to_s
41
+ html = "<table class=\"#{model.table_class_for(type, request)}\">"
42
+ if caption = opts[:caption]
43
+ html << "<caption>#{h caption}</caption>"
44
+ end
45
+
46
+ html << "<thead><tr>"
47
+ columns.each do |column|
48
+ html << "<th>#{h action.column_label_for(type, request, model, column)}</th>"
49
+ end
50
+ html << "<th>Show</th>" if show = model.supported_action?(:show, request)
51
+ html << "<th>Edit</th>" if edit = model.supported_action?(:edit, request)
52
+ html << "<th>Delete</th>" if delete = model.supported_action?(:delete, request)
53
+ html << "</tr></thead>"
54
+
55
+ html << "<tbody>"
56
+ objs.each do |obj|
57
+ html << "<tr>"
58
+ columns.each do |column|
59
+ html << "<td>#{h model.column_value(type, request, obj, column)}</td>"
60
+ end
61
+ html << "<td><a href=\"#{action.url_for("show/#{model.primary_key_value(obj)}")}\" class=\"btn btn-mini btn-info\">Show</a></td>" if show
62
+ html << "<td><a href=\"#{action.url_for("edit/#{model.primary_key_value(obj)}")}\" class=\"btn btn-mini btn-primary\">Edit</a></td>" if edit
63
+ html << "<td><a href=\"#{action.url_for("delete/#{model.primary_key_value(obj)}")}\" class=\"btn btn-mini btn-danger\">Delete</a></td>" if delete
64
+ html << "</tr>"
65
+ end
66
+ html << "</tbody></table>"
67
+ html
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,9 @@
1
+ module AutoForme
2
+ # Version constant, use <tt>AutoForme.version</tt> instead.
3
+ VERSION = '0.5.0'.freeze
4
+
5
+ # Returns the version as a frozen string (e.g. '0.1.0')
6
+ def self.version
7
+ VERSION
8
+ end
9
+ end
data/lib/autoforme.rb ADDED
@@ -0,0 +1,57 @@
1
+ require 'forme'
2
+ require 'thread'
3
+ require 'rack/utils'
4
+
5
+ module AutoForme
6
+ # Map of framework type symbols to framework classes
7
+ FRAMEWORKS = {}
8
+
9
+ # Map of model type symbols to model classes
10
+ MODELS = {}
11
+ @mutex = Mutex.new
12
+
13
+ # AutoForme specific error class
14
+ class Error < StandardError
15
+ end
16
+
17
+ [[:framework, FRAMEWORKS], [:model, MODELS]].each do |map_type, map|
18
+ singleton_class = class << self; self; end
19
+
20
+ singleton_class.send(:define_method, :"register_#{map_type}") do |type, klass|
21
+ @mutex.synchronize{map[type] = klass}
22
+ end
23
+
24
+ singleton_class.send(:define_method, :"#{map_type}_class_for") do |type|
25
+ unless klass = @mutex.synchronize{map[type]}
26
+ require "autoforme/#{map_type}s/#{type}"
27
+ unless klass = @mutex.synchronize{map[type]}
28
+ raise Error, "unsupported framework: #{type.inspect}"
29
+ end
30
+ end
31
+ klass
32
+ end
33
+ end
34
+
35
+ # Create a new set of model forms. Arguments:
36
+ # type :: A type symbol for the type of framework in use (:sinatra or :rails)
37
+ # controller :: The controller class in which to load the forms
38
+ # opts :: Options hash. Current supports a :prefix option if you want to mount
39
+ # the forms in a different prefix.
40
+ #
41
+ # Example:
42
+ #
43
+ # AutoForme.for(:sinatra, Sinatra::Application, :prefix=>'/path') do
44
+ # model Artist
45
+ # end
46
+ def self.for(type, controller, opts={}, &block)
47
+ Framework.for(type, controller, opts, &block)
48
+ end
49
+ end
50
+
51
+ require 'autoforme/opts_attributes'
52
+ require 'autoforme/model'
53
+ require 'autoforme/framework'
54
+ require 'autoforme/request'
55
+ require 'autoforme/action'
56
+ require 'autoforme/table'
57
+ require 'autoforme/version'