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/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
|