clevic 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +371 -0
- data/INSTALL +10 -0
- data/Manifest.txt +30 -0
- data/README.txt +94 -0
- data/Rakefile +100 -0
- data/TODO +131 -0
- data/accounts_models.rb +122 -0
- data/bin/clevic +64 -0
- data/lib/active_record/dirty.rb +87 -0
- data/lib/clevic.rb +4 -0
- data/lib/clevic/browser.rb +195 -0
- data/lib/clevic/cache_table.rb +281 -0
- data/lib/clevic/db_options.rb +21 -0
- data/lib/clevic/delegates.rb +383 -0
- data/lib/clevic/extensions.rb +133 -0
- data/lib/clevic/field.rb +181 -0
- data/lib/clevic/item_delegate.rb +62 -0
- data/lib/clevic/model_builder.rb +171 -0
- data/lib/clevic/model_column.rb +23 -0
- data/lib/clevic/search_dialog.rb +77 -0
- data/lib/clevic/table_model.rb +431 -0
- data/lib/clevic/table_view.rb +479 -0
- data/lib/clevic/ui/browser.ui +201 -0
- data/lib/clevic/ui/browser_ui.rb +176 -0
- data/lib/clevic/ui/icon.png +0 -0
- data/lib/clevic/ui/search_dialog.ui +216 -0
- data/lib/clevic/ui/search_dialog_ui.rb +106 -0
- data/sql/accounts.sql +302 -0
- data/sql/times.sql +197 -0
- data/times_models.rb +163 -0
- metadata +93 -0
data/lib/clevic.rb
ADDED
@@ -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
|