autoforme 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,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'