clevic 0.5.1

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.
data/lib/clevic.rb ADDED
@@ -0,0 +1,4 @@
1
+ require 'clevic/table_view.rb'
2
+ require 'clevic/model_builder.rb'
3
+
4
+
@@ -0,0 +1,195 @@
1
+ require 'clevic/search_dialog.rb'
2
+ require 'clevic/ui/browser_ui.rb'
3
+
4
+ module Clevic
5
+
6
+ =begin rdoc
7
+ The main application class. Each model for display should have a self.ui method
8
+ which returns a Clevic::TableView instance, usually in conjunction with
9
+ a ModelBuilder.
10
+
11
+ Clevic::TableView.new( Entry, parent ).create_model.new( Entry, parent ).create_model
12
+ .
13
+ .
14
+ .
15
+ end
16
+
17
+ Model instances may also implement <tt>self.key_press_event( event, current_index, view )</tt>
18
+ and <tt>self.data_changed( top_left_index, bottom_right_index, view )</tt> methods so that
19
+ they can respond to editing events and do Neat Stuff.
20
+ =end
21
+ class Browser < Qt::Widget
22
+ slots *%w{dump() refresh_table() filter_by_current(bool) next_tab() previous_tab() current_changed(int)}
23
+
24
+ def initialize( main_window )
25
+ super( main_window )
26
+
27
+ # do menus and widgets
28
+ @layout = Ui::Browser.new
29
+ @layout.setup_ui( main_window )
30
+
31
+ # set icon. MUST come after call to setup_ui
32
+ icon_path = Pathname.new( __FILE__ ).parent + "ui/icon.png"
33
+ raise "icon.png not found" unless icon_path.file?
34
+ main_window.window_icon = Qt::Icon.new( icon_path.realpath.to_s )
35
+
36
+ # add the tables tab
37
+ @tables_tab = Qt::TabWidget.new( @layout.main_widget )
38
+ @layout.main_widget.layout.add_widget @tables_tab
39
+ @tables_tab.tab_bar.focus_policy = Qt::NoFocus
40
+
41
+ # connect slots
42
+ @layout.action_dump.connect SIGNAL( 'triggered()' ), &method( :dump )
43
+ @layout.action_refresh.connect SIGNAL( 'triggered()' ), &method( :refresh_table )
44
+ @layout.action_filter.connect SIGNAL( 'triggered(bool)' ), &method( :filter_by_current )
45
+ @layout.action_next.connect SIGNAL( 'triggered()' ), &method( :next_tab )
46
+ @layout.action_previous.connect SIGNAL( 'triggered()' ), &method( :previous_tab )
47
+ @layout.action_find.connect SIGNAL( 'triggered()' ), &method( :find )
48
+ @layout.action_find_next.connect SIGNAL( 'triggered()' ), &method( :find_next )
49
+ @layout.action_new_row.connect SIGNAL( 'triggered()' ), &method( :new_row )
50
+
51
+ tables_tab.connect SIGNAL( 'currentChanged(int)' ), &method( :current_changed )
52
+
53
+ # as an example
54
+ #~ tables_tab.connect SIGNAL( 'currentChanged(int)' ) { |index| puts "other current_changed: #{index}" }
55
+ end
56
+
57
+ # activated by Ctrl-D for debugging
58
+ def dump
59
+ puts "table_view.model: #{table_view.model.inspect}" if table_view.class == Clevic::TableView
60
+ end
61
+
62
+ # return the Clevic::TableView object in the currently displayed tab
63
+ def table_view
64
+ tables_tab.current_widget
65
+ end
66
+
67
+ def tables_tab
68
+ @tables_tab
69
+ end
70
+
71
+ # display a search dialog, and find the entered text
72
+ def find
73
+ @search_dialog ||= SearchDialog.new
74
+ result = @search_dialog.exec( table_view.current_index.gui_value )
75
+
76
+ override_cursor( Qt::BusyCursor ) do
77
+ case result
78
+ when Qt::Dialog::Accepted
79
+ search_for = @search_dialog.search_text
80
+ table_view.search( @search_dialog )
81
+ when Qt::Dialog::Rejected
82
+ puts "Don't search"
83
+ else
84
+ puts "unknown dialog code #{result}"
85
+ end
86
+ end
87
+ end
88
+
89
+ def find_next
90
+ if @search_dialog.nil?
91
+ @layout.statusbar.show_message( 'No previous find' )
92
+ else
93
+ override_cursor( Qt::BusyCursor ) do
94
+ save_from_start = @search_dialog.from_start?
95
+ @search_dialog.from_start = false
96
+ table_view.search( @search_dialog )
97
+ @search_dialog.from_start = save_from_start
98
+ end
99
+ end
100
+ end
101
+
102
+ # force a complete reload of the current tab's data
103
+ def refresh_table
104
+ override_cursor( Qt::BusyCursor ) do
105
+ table_view.model.reload_data
106
+ end
107
+ end
108
+
109
+ # toggle the filter, based on current selection.
110
+ def filter_by_current( bool_filter )
111
+ # TODO if there's no selection, use the current index instead
112
+ table_view.filter_by_indexes( table_view.selection_model.selected_indexes )
113
+
114
+ # set the checkbox in the menu item
115
+ @layout.action_filter.checked = table_view.filtered
116
+
117
+ # update the tab, so there's a visual indication of filtering
118
+ tab_title = table_view.filtered ? translate( '| ' + table_view.model_class.name.humanize ) : translate( table_view.model_class.name.humanize )
119
+ tables_tab.set_tab_text( tables_tab.current_index, tab_title )
120
+ end
121
+
122
+ # slot to handle Ctrl-Tab and move to next tab, or wrap around
123
+ def next_tab
124
+ tables_tab.current_index =
125
+ if tables_tab.current_index >= tables_tab.count - 1
126
+ 0
127
+ else
128
+ tables_tab.current_index + 1
129
+ end
130
+ end
131
+
132
+ # slot to handle Ctrl-Backtab and move to previous tab, or wrap around
133
+ def previous_tab
134
+ tables_tab.current_index =
135
+ if tables_tab.current_index <= 0
136
+ tables_tab.count - 1
137
+ else
138
+ tables_tab.current_index - 1
139
+ end
140
+ end
141
+
142
+ def new_row
143
+ table_view.model.add_new_item
144
+ end
145
+
146
+ # slot to handle the currentChanged signal from tables_tab, and
147
+ # set focus on the grid
148
+ def current_changed( current_tab_index )
149
+ tables_tab.current_widget.setFocus
150
+ @layout.action_filter.checked = table_view.filtered
151
+ end
152
+
153
+ # shortcut for the Qt translate call
154
+ def translate( st )
155
+ Qt::Application.translate("Browser", st, nil, Qt::Application::UnicodeUTF8)
156
+ end
157
+
158
+ # return the list of models in $options[:models] or find them
159
+ # as descendants of ActiveRecord::Base
160
+ def find_models( models = $options[:models] )
161
+ if models.nil? || models.empty?
162
+ models = []
163
+ ObjectSpace.each_object( Class ) {|x| models << x if x.superclass == ActiveRecord::Base }
164
+ models
165
+ else
166
+ models
167
+ end
168
+ end
169
+
170
+ # Create the tabs, each with a collection for a particular model class.
171
+ #
172
+ # models parameter can be an array of Model objects, in order of display.
173
+ # if models is nil, find_models is called
174
+ def open( *models )
175
+ models = $options[:models] if models.empty?
176
+
177
+ # Add all existing model objects as tabs, one each
178
+ find_models( models ).each do |model|
179
+ if model.respond_to?( :ui )
180
+ tab = model.ui( tables_tab )
181
+ tab.connect( SIGNAL( 'status_text(QString)' ) ) { |msg| @layout.statusbar.show_message( msg, 20000 ) }
182
+ else
183
+ raise "Can't build ui for #{model.name}. Provide a self.ui method."
184
+ end
185
+ tables_tab.add_tab( tab, translate( model.name.humanize ) )
186
+ end
187
+ end
188
+
189
+ # make sure all outstanding records are saved
190
+ def save_all
191
+ tables_tab.each {|x| x.save_row( x.current_index ) }
192
+ end
193
+ end
194
+
195
+ end
@@ -0,0 +1,281 @@
1
+ require 'rubygems'
2
+ require 'active_record'
3
+ require 'active_record/dirty.rb'
4
+ require 'bsearch'
5
+
6
+ require 'profiler'
7
+
8
+ =begin rdoc
9
+ Store the SQL order_by attributes with ascending and descending values
10
+ =end
11
+ class OrderAttribute
12
+ attr_reader :direction, :attribute
13
+
14
+ def initialize( model_class, sql_order_fragment )
15
+ @model_class = model_class
16
+ if sql_order_fragment =~ /.*\.(.*?) *asc/
17
+ @direction = :asc
18
+ @attribute = $1
19
+ elsif sql_order_fragment =~ /.*\.(.*?) *desc/
20
+ @direction = :desc
21
+ @attribute = $1
22
+ else
23
+ @direction = :asc
24
+ @attribute = sql_order_fragment
25
+ end
26
+ end
27
+
28
+ # return ORDER BY field name
29
+ def to_s
30
+ @string ||= attribute
31
+ end
32
+
33
+ def to_sym
34
+ @sym ||= attribute.to_sym
35
+ end
36
+
37
+ # return 'field ASC' or 'field DESC', depending
38
+ def to_sql
39
+ "#{@model_class.table_name}.#{attribute} #{direction.to_s}"
40
+ end
41
+
42
+ def reverse( direction )
43
+ case direction
44
+ when :asc; :desc
45
+ when :desc; :asc
46
+ else; raise "unknown direction #{direction}"
47
+ end
48
+ end
49
+
50
+ # return the opposite ASC or DESC from to_sql
51
+ def to_reverse_sql
52
+ "#{@model_class.table_name}.#{attribute} #{reverse(direction).to_s}"
53
+ end
54
+
55
+ end
56
+
57
+ =begin rdoc
58
+ Fetch rows from the db on demand, rather than all up front.
59
+
60
+ Being able to change the recordset on the fly and still find a previously
61
+ known entity in the set requires a defined ordering, so if no ordering
62
+ is specified, the primary key of the entity will be used.
63
+
64
+ It hasn't been tested with compound primary keys.
65
+ --
66
+ TODO drop rows when they haven't been accessed for a while
67
+
68
+ TODO how to handle a quickly-changing underlying table? invalidate cache
69
+ for each call?
70
+ =end
71
+ class CacheTable < Array
72
+ # the number of records loaded in one call to the db
73
+ attr_accessor :preload_count
74
+ attr_reader :options
75
+
76
+ def initialize( model_class, find_options = {} )
77
+ @preload_count = 20
78
+ # must be before sanitise_options
79
+ @model_class = model_class
80
+ # must be before anything that uses options
81
+ @options = sanitise_options( find_options )
82
+
83
+ # size the array and fill it with nils. They'll be filled
84
+ # in by the [] operator
85
+ @row_count = sql_count
86
+ super(@row_count)
87
+ end
88
+
89
+ # The count of the records according to the db, which may be different to
90
+ # the records in the cache
91
+ def sql_count
92
+ @model_class.count( :conditions => @options[:conditions] )
93
+ end
94
+
95
+ def order
96
+ @options[:order]
97
+ end
98
+
99
+ def reverse_order
100
+ @order_attributes.map{|x| x.to_reverse_sql}.join(',')
101
+ end
102
+
103
+ def quote_column( field_name )
104
+ @model_class.connection.quote_column_name( field_name )
105
+ end
106
+
107
+ # recursively create a case statement to do the comparison
108
+ # because and ... and ... and filters on *each* one rather than
109
+ # consecutively.
110
+ # operator is either '<' or '>'
111
+ def build_recursive_comparison( operator, index = 0 )
112
+ # end recursion
113
+ return 'false' if index == @order_attributes.size
114
+
115
+ # fetch the current attribute
116
+ attribute = @order_attributes[index]
117
+
118
+ # build case statement, including recusion
119
+ st = <<-EOF
120
+ case
121
+ when #{quote_column attribute} #{operator} :#{attribute} then true
122
+ when #{quote_column attribute} = :#{attribute} then #{build_recursive_comparison( operator, index+1 )}
123
+ else false
124
+ end
125
+ EOF
126
+ end
127
+
128
+ # return a Hash containing
129
+ # :sql => the sql statement to be used as part of a where clause
130
+ # :params => the parameters corresponding to :sql
131
+ # entity is an AR model object
132
+ # direction is either :forwards or :backwards
133
+ def build_sql_find( entity, direction )
134
+ operator =
135
+ case direction
136
+ when :forwards; '>'
137
+ when :backwards; '<'
138
+ else; raise "unknown direction #{direction.inspect}"
139
+ end
140
+
141
+ # build the sql comparison where clause fragment
142
+ sql = build_recursive_comparison( operator )
143
+
144
+ # build parameter values
145
+ params = {}
146
+ @order_attributes.each {|x| params[x.to_sym] = entity.send( x.attribute )}
147
+ { :sql => sql, :params => params }
148
+ end
149
+
150
+ # add an id to options[:order] if it's not in there
151
+ # also create @order_attributes
152
+ def sanitise_options( options )
153
+ options[:order] ||= ''
154
+ @order_attributes = options[:order].split( /, */ ).map{|x| OrderAttribute.new(@model_class, x)}
155
+
156
+ # add the primary key if nothing is specified
157
+ # because we need an ordering of some kind otherwise
158
+ # index_for_entity will not work
159
+ if !@order_attributes.any? {|x| x.attribute == @model_class.primary_key }
160
+ @order_attributes << OrderAttribute.new( @model_class, @model_class.primary_key )
161
+ end
162
+
163
+ # recreate the options[:order] entry
164
+ options[:order] = @order_attributes.map{|x| x.to_sql}.join(',')
165
+
166
+ # give back the sanitised options
167
+ options
168
+ end
169
+
170
+ # Execute the block with the specified preload_count,
171
+ # and restore the existing one when done.
172
+ # Return the value of the block
173
+ def preload_limit( limit, &block )
174
+ old_limit = preload_count
175
+ self.preload_count = limit
176
+ retval = yield
177
+ self.preload_count = old_limit
178
+ retval
179
+ end
180
+
181
+ # Fetch the entity for the given index from the db, and store it
182
+ # in the array. Also, preload preload_count records to avoid subsequent
183
+ # hits on the db
184
+ def fetch_entity( index )
185
+ # calculate negative indices for the SQL offset
186
+ offset = index < 0 ? index + @row_count : index
187
+
188
+ # fetch self.preload_count records
189
+ records = @model_class.find( :all, @options.merge( :offset => offset, :limit => preload_count ) )
190
+ records.each_with_index {|x,i| self[i+index] = x if !cached_at?( i+index )}
191
+
192
+ # return the first one
193
+ records[0]
194
+ end
195
+
196
+ # return the entity at the given index. Fetch it from the
197
+ # db if it isn't in this array yet
198
+ def []( index )
199
+ super( index ) || fetch_entity( index )
200
+ end
201
+
202
+ # make a new instance that has the attributes of this one, but an empty
203
+ # data set. pass in ActiveRecord options to filter
204
+ def renew( options = {} )
205
+ clear
206
+ self.class.new( @model_class, @options.merge( options ) )
207
+ end
208
+
209
+ # Return the set of OrderAttribute objects for this collection
210
+ def order_attributes
211
+ # This is sorted in @options[:order], so use that for the search
212
+ if @order_attributes.nil?
213
+ @order_attributes = @options[:order].to_s.split( /, */ ).map{|x| OrderAttribute.new(@model_class, x)}
214
+
215
+ # add the primary key if nothing is specified
216
+ # because we need an ordering of some kind otherwise
217
+ # index_for_entity will not work
218
+ if !@order_attributes.any? {|x| x.attribute == @model_class.primary_key }
219
+ @order_attributes << OrderAttribute.new( @model_class, @model_class.primary_key )
220
+ end
221
+ end
222
+ @order_attributes
223
+ end
224
+
225
+ # find the index for the given entity, using a binary search algorithm (bsearch).
226
+ # The order_by ActiveRecord style options are used to do the binary search.
227
+ # 0 is returned if the entity is nil
228
+ # nil is returned if the array is empty
229
+ def index_for_entity( entity )
230
+ return nil if size == 0
231
+ return nil if entity.nil?
232
+
233
+ # only load one record at a time
234
+ preload_limit( 1 ) do
235
+ #~ puts "entity: #{entity.inspect}"
236
+ # do the binary search based on what we know about the search order
237
+ bsearch do |candidate|
238
+ #~ puts "candidate: #{candidate.inspect}"
239
+ # find using all sort attributes
240
+ order_attributes.inject(0) do |result,attribute|
241
+ if result == 0
242
+ method = attribute.attribute.to_sym
243
+ # compare taking ordering direction into account
244
+ retval =
245
+ if attribute.direction == :asc
246
+ #~ candidate.send( method ) <=> entity.send( method )
247
+ candidate[method] <=> entity[method]
248
+ else
249
+ #~ entity.send( method ) <=> candidate.send( method )
250
+ entity[method] <=> candidate[method]
251
+ end
252
+ # exit now because we have a difference
253
+ next( retval ) if retval != 0
254
+
255
+ # otherwise try with the next order attribute
256
+ retval
257
+ else
258
+ # they're equal, so try next order attribute
259
+ result
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
265
+
266
+ def delete_at( index )
267
+ retval = super
268
+ if self.size == 0
269
+ self << @model_class.new
270
+ end
271
+ retval
272
+ end
273
+
274
+ end
275
+
276
+ class Array
277
+ # For use with CacheTable. Return true if something is cached, false otherwise
278
+ def cached_at?( index )
279
+ !at(index).nil?
280
+ end
281
+ end